From 4b895263e2e9edc1ec0b66554ca735f024772ae0 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Thu, 13 Aug 2026 00:10:34 +0200 Subject: [PATCH] 83 - Show the fetched-vs-published trend on the dashboard --- app/Dashboard/Stats/SeriesResult.php | 15 ++++ app/Livewire/Dashboard.php | 13 +++ resources/js/chart.js | 32 +++---- resources/views/livewire/dashboard.blade.php | 13 +++ .../livewire/partials/trend-panel.blade.php | 17 ++++ tests/Feature/Livewire/DashboardTest.php | 86 +++++++++++++++++++ .../Unit/Dashboard/Stats/SeriesResultTest.php | 40 +++++++++ 7 files changed, 197 insertions(+), 19 deletions(-) create mode 100644 resources/views/livewire/partials/trend-panel.blade.php diff --git a/app/Dashboard/Stats/SeriesResult.php b/app/Dashboard/Stats/SeriesResult.php index a84fe4fd..41777c1d 100644 --- a/app/Dashboard/Stats/SeriesResult.php +++ b/app/Dashboard/Stats/SeriesResult.php @@ -38,8 +38,23 @@ public function isTooWide(): bool return $this->tooWide; } + /** True only when no axis was built; a real range always has one label per day. */ public function isEmpty(): bool { 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; + } } diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index 8e9e4700..0d4c1723 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -3,9 +3,11 @@ namespace App\Livewire; use App\Dashboard\Stats\ArticlesPerFeed; +use App\Dashboard\Stats\ArticlesTrend; use App\Dashboard\Stats\BreakdownResult; use App\Dashboard\Stats\BreakdownStat; use App\Dashboard\Stats\PublicationsPerChannel; +use App\Dashboard\Stats\SeriesResult; use App\Services\DashboardStatsService; use App\Support\DateRange; use Illuminate\Contracts\View\View; @@ -32,6 +34,7 @@ public function mount(): void */ private const RANGE_DEPENDENT_ISLANDS = [ 'article-statistics', + 'articles-trend', 'articles-per-feed', 'publications-per-channel', ]; @@ -118,6 +121,16 @@ public function articleStats(): array 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] public function articlesPerFeed(): BreakdownResult { diff --git a/resources/js/chart.js b/resources/js/chart.js index 8ad125fc..aff02bb8 100644 --- a/resources/js/chart.js +++ b/resources/js/chart.js @@ -21,6 +21,8 @@ Chart.register( Tooltip, ); +const palette = ['#3b82f6', '#10b981']; + export default function trendChart({ labels = [], series = [] } = {}) { return { chart: null, @@ -28,7 +30,17 @@ export default function trendChart({ labels = [], series = [] } = {}) { init() { this.chart = new Chart(this.$refs.canvas, { 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: { responsive: true, 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() { this.chart?.destroy(); this.chart = null; diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 66dd8fe1..d018fb1e 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -183,6 +183,19 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin @endisland + +
+

Fetched vs Published

+ + @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 +
+

Articles per Feed

diff --git a/resources/views/livewire/partials/trend-panel.blade.php b/resources/views/livewire/partials/trend-panel.blade.php new file mode 100644 index 00000000..483a5906 --- /dev/null +++ b/resources/views/livewire/partials/trend-panel.blade.php @@ -0,0 +1,17 @@ +@php($payload = ['labels' => $result->labels, 'series' => collect($result->series)->map(fn ($series) => ['name' => $series->name, 'values' => $series->values])->all()]) + +
! $rangeIsValid])> + @if ($result->isTooWide()) +

{{ $tooWideMessage }}

+ @elseif ($result->isEmpty() || $result->hasNoData()) +

{{ $emptyMessage }}

+ @else +
+ +
+ @endif +
diff --git a/tests/Feature/Livewire/DashboardTest.php b/tests/Feature/Livewire/DashboardTest.php index 4488f2b3..4ec2b437 100644 --- a/tests/Feature/Livewire/DashboardTest.php +++ b/tests/Feature/Livewire/DashboardTest.php @@ -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*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); + } } diff --git a/tests/Unit/Dashboard/Stats/SeriesResultTest.php b/tests/Unit/Dashboard/Stats/SeriesResultTest.php index 8cc51423..42a898fc 100644 --- a/tests/Unit/Dashboard/Stats/SeriesResultTest.php +++ b/tests/Unit/Dashboard/Stats/SeriesResultTest.php @@ -60,4 +60,44 @@ public function test_an_empty_but_valid_result_reports_itself_as_empty(): void $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()); + } }