83 - Show the fetched-vs-published trend on the dashboard

This commit is contained in:
myrmidex 2026-08-13 00:10:34 +02:00
parent 7e6e6ce4e1
commit 4b895263e2
7 changed files with 197 additions and 19 deletions

View file

@ -38,8 +38,23 @@ public function isTooWide(): bool
return $this->tooWide; return $this->tooWide;
} }
/** True only when no axis was built; a real range always has one label per day. */
public function isEmpty(): bool public function isEmpty(): bool
{ {
return ! $this->tooWide && $this->labels === []; return ! $this->tooWide && $this->labels === [];
} }
/** True when every value is null or zero — an axis exists but nothing happened on it. */
public function hasNoData(): bool
{
foreach ($this->series as $one) {
foreach ($one->values as $value) {
if ($value !== null && $value != 0) {
return false;
}
}
}
return true;
}
} }

View file

@ -3,9 +3,11 @@
namespace App\Livewire; namespace App\Livewire;
use App\Dashboard\Stats\ArticlesPerFeed; use App\Dashboard\Stats\ArticlesPerFeed;
use App\Dashboard\Stats\ArticlesTrend;
use App\Dashboard\Stats\BreakdownResult; use App\Dashboard\Stats\BreakdownResult;
use App\Dashboard\Stats\BreakdownStat; use App\Dashboard\Stats\BreakdownStat;
use App\Dashboard\Stats\PublicationsPerChannel; use App\Dashboard\Stats\PublicationsPerChannel;
use App\Dashboard\Stats\SeriesResult;
use App\Services\DashboardStatsService; use App\Services\DashboardStatsService;
use App\Support\DateRange; use App\Support\DateRange;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
@ -32,6 +34,7 @@ public function mount(): void
*/ */
private const RANGE_DEPENDENT_ISLANDS = [ private const RANGE_DEPENDENT_ISLANDS = [
'article-statistics', 'article-statistics',
'articles-trend',
'articles-per-feed', 'articles-per-feed',
'publications-per-channel', 'publications-per-channel',
]; ];
@ -118,6 +121,16 @@ public function articleStats(): array
return app(DashboardStatsService::class)->getStats($range); return app(DashboardStatsService::class)->getStats($range);
} }
#[Computed]
public function articlesTrend(): SeriesResult
{
$range = $this->range();
return $range instanceof DateRange
? app(ArticlesTrend::class)->for($range)
: new SeriesResult([], []);
}
#[Computed] #[Computed]
public function articlesPerFeed(): BreakdownResult public function articlesPerFeed(): BreakdownResult
{ {

View file

@ -21,6 +21,8 @@ Chart.register(
Tooltip, Tooltip,
); );
const palette = ['#3b82f6', '#10b981'];
export default function trendChart({ labels = [], series = [] } = {}) { export default function trendChart({ labels = [], series = [] } = {}) {
return { return {
chart: null, chart: null,
@ -28,7 +30,17 @@ export default function trendChart({ labels = [], series = [] } = {}) {
init() { init() {
this.chart = new Chart(this.$refs.canvas, { this.chart = new Chart(this.$refs.canvas, {
type: 'line', type: 'line',
data: this.data(labels, series), data: {
labels,
datasets: series.map((one, index) => ({
label: one.name,
data: one.values,
borderColor: palette[index % palette.length],
backgroundColor: palette[index % palette.length],
spanGaps: false,
tension: 0.3,
})),
},
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
@ -40,24 +52,6 @@ export default function trendChart({ labels = [], series = [] } = {}) {
}); });
}, },
data(labels, series) {
return {
labels,
datasets: series.map((one, index) => ({
label: one.name,
data: one.values,
borderColor: this.palette(index),
backgroundColor: this.palette(index),
spanGaps: false,
tension: 0.3,
})),
};
},
palette(index) {
return ['#3b82f6', '#10b981'][index % 2];
},
destroy() { destroy() {
this.chart?.destroy(); this.chart?.destroy();
this.chart = null; this.chart = null;

View file

@ -183,6 +183,19 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
@endisland @endisland
</div> </div>
<!-- Articles Fetched vs Published -->
<div class="mt-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Fetched vs Published</h2>
@island('articles-trend')
@include('livewire.partials.trend-panel', [
'result' => $this->articlesTrend,
'emptyMessage' => 'No articles or publications in this range.',
'tooWideMessage' => 'This range is too wide to chart by day. Pick a range under two years.',
])
@endisland
</div>
<!-- Articles per Feed --> <!-- Articles per Feed -->
<div class="mt-8"> <div class="mt-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Articles per Feed</h2> <h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Articles per Feed</h2>

View file

@ -0,0 +1,17 @@
@php($payload = ['labels' => $result->labels, 'series' => collect($result->series)->map(fn ($series) => ['name' => $series->name, 'values' => $series->values])->all()])
<div @class(['bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800', 'opacity-40' => ! $rangeIsValid])>
@if ($result->isTooWide())
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $tooWideMessage }}</p>
@elseif ($result->isEmpty() || $result->hasNoData())
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $emptyMessage }}</p>
@else
<div
wire:key="trend-{{ md5(json_encode($payload)) }}"
x-data="trendChart({{ Js::from($payload) }})"
class="relative h-72"
>
<canvas x-ref="canvas"></canvas>
</div>
@endif
</div>

View file

@ -259,4 +259,90 @@ public function test_it_re_renders_the_article_statistics_island_when_a_preset_i
$this->assertMatchesRegularExpression('/Articles Fetched.*?>\s*2\s*</s', $fragments); $this->assertMatchesRegularExpression('/Articles Fetched.*?>\s*2\s*</s', $fragments);
} }
public function test_it_renders_the_trend_chart_on_mount(): void
{
Carbon::setTestNow('2026-07-15 13:45:00');
Article::factory()->create(['created_at' => Carbon::parse('2026-07-15 09:00:00')]);
Livewire::test(Dashboard::class)
->assertSee('Fetched vs Published')
->assertSee('trendChart(', false)
->assertSee('x-ref="canvas"', false);
}
public function test_it_renders_the_trend_chart_in_its_own_island(): void
{
$fragments = $this->islandFragments(
Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31')
);
$this->assertStringContainsString('name=articles-trend|', $fragments);
}
public function test_it_refreshes_the_trend_island_when_the_range_changes(): void
{
Article::factory()->count(2)->create(['created_at' => Carbon::parse('2026-07-10 12:00:00')]);
$fragments = $this->islandFragments(
Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31')
);
$this->assertStringContainsString('name=articles-trend|', $fragments);
$this->assertStringContainsString('2026-07-10', $fragments);
$this->assertStringContainsString('Fetched', $fragments);
$this->assertStringContainsString('Published', $fragments);
}
public function test_it_reports_a_too_wide_range_instead_of_charting_it(): void
{
Carbon::setTestNow('2026-07-15 13:45:00');
$fragments = $this->islandFragments(
Livewire::test(Dashboard::class)->call('applyPreset', 'all')
);
$this->assertStringContainsString('too wide to chart by day', $fragments);
$this->assertStringNotContainsString('x-ref="canvas"', $fragments);
}
public function test_it_reports_an_empty_range_separately_from_a_too_wide_one(): void
{
$fragments = $this->islandFragments(
Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31')
);
$this->assertStringContainsString('No articles or publications in this range.', $fragments);
$this->assertStringNotContainsString('too wide to chart by day', $fragments);
}
public function test_it_keys_the_chart_on_its_data_so_a_range_change_replaces_it(): void
{
Article::factory()->create(['created_at' => Carbon::parse('2026-07-10 12:00:00')]);
Article::factory()->create(['created_at' => Carbon::parse('2026-08-10 12:00:00')]);
$july = $this->islandFragments(
Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31')
);
$august = $this->islandFragments(
Livewire::test(Dashboard::class)->call('applyRange', '2026-08-01', '2026-08-31')
);
preg_match('/wire:key="(trend-[a-f0-9]+)"/', $july, $julyKey);
preg_match('/wire:key="(trend-[a-f0-9]+)"/', $august, $augustKey);
$this->assertNotEmpty($julyKey);
$this->assertNotEmpty($augustKey);
$this->assertNotSame($julyKey[1], $augustKey[1]);
}
public function test_it_renders_an_empty_trend_for_an_invalid_range(): void
{
Livewire::test(Dashboard::class)
->set('from', 'not-a-date')
->assertSee('Fetched vs Published')
->assertDontSee('x-ref="canvas"', false);
}
} }

View file

@ -60,4 +60,44 @@ public function test_an_empty_but_valid_result_reports_itself_as_empty(): void
$this->assertTrue($result->isEmpty()); $this->assertTrue($result->isEmpty());
} }
public function test_a_result_whose_series_are_all_zero_has_no_data(): void
{
$result = new SeriesResult(
['2026-07-01', '2026-07-02'],
[new Series('Fetched', [0, 0]), new Series('Published', [0, 0])],
);
$this->assertTrue($result->hasNoData());
}
public function test_a_result_with_any_non_zero_value_has_data(): void
{
$result = new SeriesResult(
['2026-07-01', '2026-07-02'],
[new Series('Fetched', [0, 0]), new Series('Published', [0, 1])],
);
$this->assertFalse($result->hasNoData());
}
public function test_a_result_of_only_nulls_has_no_data(): void
{
$result = new SeriesResult(
['2026-07-01', '2026-07-02'],
[new Series('Approval Rate', [null, null])],
);
$this->assertTrue($result->hasNoData());
}
public function test_zero_values_are_indistinguishable_from_absent_ones(): void
{
$result = new SeriesResult(
['2026-07-01', '2026-07-02'],
[new Series('Approval Rate', [null, 0.0])],
);
$this->assertTrue($result->hasNoData());
}
} }