From 8a1001d27437c2af15eb233227d5679ae7e43472 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 12 Aug 2026 00:32:46 +0200 Subject: [PATCH] 83 - Add a per-feed article breakdown to the dashboard --- app/Dashboard/Stats/ArticlesPerFeed.php | 47 ++++++ app/Dashboard/Stats/Breakdown.php | 11 ++ app/Dashboard/Stats/BreakdownResult.php | 30 ++++ app/Dashboard/Stats/BreakdownStat.php | 10 ++ app/Dashboard/Stats/Stat.php | 13 ++ app/Livewire/Dashboard.php | 14 +- resources/views/livewire/dashboard.blade.php | 33 ++++ tests/Feature/Livewire/DashboardTest.php | 45 ++++++ .../Dashboard/Stats/ArticlesPerFeedTest.php | 149 ++++++++++++++++++ .../Dashboard/Stats/BreakdownResultTest.php | 58 +++++++ .../Unit/Dashboard/Stats/StatContractTest.php | 33 ++++ 11 files changed, 442 insertions(+), 1 deletion(-) create mode 100644 app/Dashboard/Stats/ArticlesPerFeed.php create mode 100644 app/Dashboard/Stats/Breakdown.php create mode 100644 app/Dashboard/Stats/BreakdownResult.php create mode 100644 app/Dashboard/Stats/BreakdownStat.php create mode 100644 app/Dashboard/Stats/Stat.php create mode 100644 tests/Unit/Dashboard/Stats/ArticlesPerFeedTest.php create mode 100644 tests/Unit/Dashboard/Stats/BreakdownResultTest.php create mode 100644 tests/Unit/Dashboard/Stats/StatContractTest.php diff --git a/app/Dashboard/Stats/ArticlesPerFeed.php b/app/Dashboard/Stats/ArticlesPerFeed.php new file mode 100644 index 00000000..fdb4be06 --- /dev/null +++ b/app/Dashboard/Stats/ArticlesPerFeed.php @@ -0,0 +1,47 @@ + $counts */ + $counts = Article::query() + ->whereBetween('created_at', [$range->from, $range->to]) + ->selectRaw('feed_id, COUNT(*) as aggregate') + ->groupBy('feed_id') + ->pluck('aggregate', 'feed_id') + ->all(); + + // Zero-fill in PHP; assumes the feed table stays small enough to load whole. + $rows = Feed::query() + ->get() + ->map(fn (Feed $feed): Breakdown => new Breakdown( + $feed->name, + (int) ($counts[$feed->id] ?? 0), + )) + ->all(); + + usort( + $rows, + fn (Breakdown $a, Breakdown $b): int => [$b->count, $a->label] <=> [$a->count, $b->label], + ); + + return new BreakdownResult($rows); + } +} diff --git a/app/Dashboard/Stats/Breakdown.php b/app/Dashboard/Stats/Breakdown.php new file mode 100644 index 00000000..1d579f91 --- /dev/null +++ b/app/Dashboard/Stats/Breakdown.php @@ -0,0 +1,11 @@ + $rows + */ + public function __construct( + public readonly array $rows, + ) {} + + public function total(): int + { + return array_sum(array_map(fn (Breakdown $row): int => $row->count, $this->rows)); + } + + public function shareOf(Breakdown $row): float + { + $total = $this->total(); + + return $total > 0 ? round(($row->count / $total) * 100, 1) : 0.0; + } + + public function isEmpty(): bool + { + return $this->total() === 0; + } +} diff --git a/app/Dashboard/Stats/BreakdownStat.php b/app/Dashboard/Stats/BreakdownStat.php new file mode 100644 index 00000000..25167d41 --- /dev/null +++ b/app/Dashboard/Stats/BreakdownStat.php @@ -0,0 +1,10 @@ + */ - private const RANGE_DEPENDENT_ISLANDS = ['article-statistics']; + private const RANGE_DEPENDENT_ISLANDS = ['article-statistics', 'articles-per-feed']; public function applyPreset(string $preset): void { @@ -110,6 +112,16 @@ public function articleStats(): array return app(DashboardStatsService::class)->getStats($range); } + #[Computed] + public function articlesPerFeed(): BreakdownResult + { + $range = $this->range(); + + return $range instanceof DateRange + ? app(ArticlesPerFeed::class)->for($range) + : new BreakdownResult([]); + } + /** * @return array */ diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 42e44b7e..8af5afa0 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -182,4 +182,37 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin @endisland + + +
+

Articles per Feed

+ + @island('articles-per-feed') +
! $rangeIsValid])> + @if ($this->articlesPerFeed->rows === []) +

No feeds are configured yet.

+ @else +
    + @foreach ($this->articlesPerFeed->rows as $row) +
  • +
    + {{ $row->label }} + + {{ $row->count }} + ({{ $this->articlesPerFeed->shareOf($row) }}%) + +
    +
    +
    +
    +
  • + @endforeach +
+ @endif +
+ @endisland +
diff --git a/tests/Feature/Livewire/DashboardTest.php b/tests/Feature/Livewire/DashboardTest.php index 9b13fa5d..7c7225f9 100644 --- a/tests/Feature/Livewire/DashboardTest.php +++ b/tests/Feature/Livewire/DashboardTest.php @@ -4,6 +4,7 @@ use App\Livewire\Dashboard; use App\Models\Article; +use App\Models\Feed; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Carbon; use Livewire\Features\SupportTesting\Testable; @@ -178,6 +179,50 @@ public function test_it_refreshes_only_the_range_dependent_island(): void $this->assertStringNotContainsString('Active Feeds', $fragments); } + public function test_it_renders_the_articles_per_feed_breakdown_on_mount(): void + { + Carbon::setTestNow('2026-07-15 13:45:00'); + + $feed = Feed::factory()->create(['name' => 'Example Feed']); + + Article::factory()->count(2)->create([ + 'feed_id' => $feed->id, + 'created_at' => Carbon::parse('2026-07-15 09:00:00'), + ]); + + Livewire::test(Dashboard::class) + ->assertSee('Example Feed') + ->assertSee('Articles per Feed'); + } + + public function test_it_counts_articles_per_feed_for_the_selected_range(): void + { + $feed = Feed::factory()->create(['name' => 'Example Feed']); + + Article::factory()->count(2)->create([ + 'feed_id' => $feed->id, + '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->assertMatchesRegularExpression('/Example Feed.*?>\s*2\s*/s', $fragments); + } + + public function test_it_refreshes_the_articles_per_feed_island_when_the_range_changes(): void + { + Feed::factory()->create(['name' => 'Example Feed']); + + $fragments = $this->islandFragments( + Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31') + ); + + $this->assertStringContainsString('name=articles-per-feed|', $fragments); + $this->assertStringContainsString('Example Feed', $fragments); + } + public function test_it_re_renders_the_article_statistics_island_when_a_preset_is_applied(): void { Carbon::setTestNow('2026-07-15 13:45:00'); diff --git a/tests/Unit/Dashboard/Stats/ArticlesPerFeedTest.php b/tests/Unit/Dashboard/Stats/ArticlesPerFeedTest.php new file mode 100644 index 00000000..3c7ecafd --- /dev/null +++ b/tests/Unit/Dashboard/Stats/ArticlesPerFeedTest.php @@ -0,0 +1,149 @@ +create(['name' => 'Busy Feed']); + $quiet = Feed::factory()->create(['name' => 'Quiet Feed']); + + Article::factory()->count(3)->create([ + 'feed_id' => $busy->id, + 'created_at' => Carbon::parse('2026-07-10 12:00:00'), + ]); + Article::factory()->create([ + 'feed_id' => $quiet->id, + 'created_at' => Carbon::parse('2026-07-11 12:00:00'), + ]); + + $rows = (new ArticlesPerFeed)->for($this->range())->rows; + + $this->assertSame(['Busy Feed' => 3, 'Quiet Feed' => 1], $this->pluck($rows)); + } + + public function test_it_reports_zero_for_a_feed_with_no_articles_in_the_range(): void + { + Feed::factory()->create(['name' => 'Silent Feed']); + + $rows = (new ArticlesPerFeed)->for($this->range())->rows; + + $this->assertSame(['Silent Feed' => 0], $this->pluck($rows)); + } + + public function test_it_excludes_articles_outside_the_range(): void + { + $feed = Feed::factory()->create(['name' => 'Feed']); + + Article::factory()->create([ + 'feed_id' => $feed->id, + 'created_at' => Carbon::parse('2026-06-30 23:59:59'), + ]); + Article::factory()->create([ + 'feed_id' => $feed->id, + 'created_at' => Carbon::parse('2026-08-01 00:00:00'), + ]); + + $rows = (new ArticlesPerFeed)->for($this->range())->rows; + + $this->assertSame(['Feed' => 0], $this->pluck($rows)); + } + + public function test_it_orders_feeds_by_count_descending(): void + { + $low = Feed::factory()->create(['name' => 'Low']); + $high = Feed::factory()->create(['name' => 'High']); + + Article::factory()->create([ + 'feed_id' => $low->id, + 'created_at' => Carbon::parse('2026-07-10 12:00:00'), + ]); + Article::factory()->count(5)->create([ + 'feed_id' => $high->id, + 'created_at' => Carbon::parse('2026-07-10 12:00:00'), + ]); + + $rows = (new ArticlesPerFeed)->for($this->range())->rows; + + $this->assertSame(['High', 'Low'], array_map(fn ($row) => $row->label, $rows)); + } + + public function test_it_breaks_ties_alphabetically(): void + { + foreach (['Zulu', 'Alpha', 'Mike'] as $name) { + $feed = Feed::factory()->create(['name' => $name]); + + Article::factory()->create([ + 'feed_id' => $feed->id, + 'created_at' => Carbon::parse('2026-07-10 12:00:00'), + ]); + } + + $rows = (new ArticlesPerFeed)->for($this->range())->rows; + + $this->assertSame(['Alpha', 'Mike', 'Zulu'], array_map(fn ($row) => $row->label, $rows)); + } + + public function test_it_reports_the_range_total(): void + { + $feed = Feed::factory()->create(); + + Article::factory()->count(4)->create([ + 'feed_id' => $feed->id, + 'created_at' => Carbon::parse('2026-07-10 12:00:00'), + ]); + + $this->assertSame(4, (new ArticlesPerFeed)->for($this->range())->total()); + } + + public function test_it_reports_a_zero_total_with_no_feeds(): void + { + $result = (new ArticlesPerFeed)->for($this->range()); + + $this->assertSame(0, $result->total()); + $this->assertSame([], $result->rows); + } + + public function test_it_exposes_a_stable_key_and_label(): void + { + $stat = new ArticlesPerFeed; + + $this->assertSame('articles-per-feed', $stat->key()); + $this->assertSame('Articles per Feed', $stat->label()); + } + + /** + * @param array $rows + * @return array + */ + private function pluck(array $rows): array + { + $out = []; + + foreach ($rows as $row) { + $out[$row->label] = $row->count; + } + + return $out; + } +} diff --git a/tests/Unit/Dashboard/Stats/BreakdownResultTest.php b/tests/Unit/Dashboard/Stats/BreakdownResultTest.php new file mode 100644 index 00000000..d6af4ccf --- /dev/null +++ b/tests/Unit/Dashboard/Stats/BreakdownResultTest.php @@ -0,0 +1,58 @@ +assertSame(7, $result->total()); + } + + public function test_it_reports_a_zero_total_when_empty(): void + { + $this->assertSame(0, (new BreakdownResult([]))->total()); + } + + public function test_it_calculates_a_row_share_of_the_total(): void + { + $result = new BreakdownResult([ + new Breakdown('A', 3), + new Breakdown('B', 1), + ]); + + $this->assertSame(75.0, $result->shareOf($result->rows[0])); + $this->assertSame(25.0, $result->shareOf($result->rows[1])); + } + + public function test_it_reports_a_zero_share_when_nothing_was_counted(): void + { + $result = new BreakdownResult([new Breakdown('A', 0)]); + + $this->assertSame(0.0, $result->shareOf($result->rows[0])); + } + + public function test_it_has_no_data_without_rows(): void + { + $this->assertTrue((new BreakdownResult([]))->isEmpty()); + } + + public function test_it_has_no_data_when_every_row_is_zero(): void + { + $this->assertTrue((new BreakdownResult([new Breakdown('A', 0)]))->isEmpty()); + } + + public function test_it_has_data_when_any_row_is_counted(): void + { + $this->assertFalse((new BreakdownResult([new Breakdown('A', 1)]))->isEmpty()); + } +} diff --git a/tests/Unit/Dashboard/Stats/StatContractTest.php b/tests/Unit/Dashboard/Stats/StatContractTest.php new file mode 100644 index 00000000..9f662711 --- /dev/null +++ b/tests/Unit/Dashboard/Stats/StatContractTest.php @@ -0,0 +1,33 @@ + $method->getName(), + (new \ReflectionClass(Stat::class))->getMethods(), + ); + + sort($methods); + + $this->assertSame(['key', 'label'], $methods); + } + + public function test_a_breakdown_stat_is_a_stat(): void + { + $this->assertTrue(is_subclass_of(BreakdownStat::class, Stat::class)); + } + + public function test_articles_per_feed_is_a_breakdown_stat(): void + { + $this->assertInstanceOf(BreakdownStat::class, new ArticlesPerFeed); + } +}