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());
+ }
}