diff --git a/app/Dashboard/Stats/PublicationsPerChannel.php b/app/Dashboard/Stats/PublicationsPerChannel.php new file mode 100644 index 00000000..9864895f --- /dev/null +++ b/app/Dashboard/Stats/PublicationsPerChannel.php @@ -0,0 +1,47 @@ + $counts */ + $counts = ArticlePublication::query() + ->whereBetween('published_at', [$range->from, $range->to]) + ->selectRaw('platform_channel_id, COUNT(*) as aggregate') + ->groupBy('platform_channel_id') + ->pluck('aggregate', 'platform_channel_id') + ->all(); + + // Zero-fill in PHP; assumes the channel table stays small enough to load whole. + $rows = PlatformChannel::query() + ->get() + ->map(fn (PlatformChannel $channel): Breakdown => new Breakdown( + $channel->display_name, + (int) ($counts[$channel->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/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index 9778ad8c..8e9e4700 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -4,6 +4,8 @@ use App\Dashboard\Stats\ArticlesPerFeed; use App\Dashboard\Stats\BreakdownResult; +use App\Dashboard\Stats\BreakdownStat; +use App\Dashboard\Stats\PublicationsPerChannel; use App\Services\DashboardStatsService; use App\Support\DateRange; use Illuminate\Contracts\View\View; @@ -28,7 +30,11 @@ public function mount(): void * * @var array */ - private const RANGE_DEPENDENT_ISLANDS = ['article-statistics', 'articles-per-feed']; + private const RANGE_DEPENDENT_ISLANDS = [ + 'article-statistics', + 'articles-per-feed', + 'publications-per-channel', + ]; public function applyPreset(string $preset): void { @@ -114,11 +120,25 @@ public function articleStats(): array #[Computed] public function articlesPerFeed(): BreakdownResult + { + return $this->breakdown(ArticlesPerFeed::class); + } + + #[Computed] + public function publicationsPerChannel(): BreakdownResult + { + return $this->breakdown(PublicationsPerChannel::class); + } + + /** + * @param class-string $stat + */ + private function breakdown(string $stat): BreakdownResult { $range = $this->range(); return $range instanceof DateRange - ? app(ArticlesPerFeed::class)->for($range) + ? app($stat)->for($range) : new BreakdownResult([]); } diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 8af5afa0..66dd8fe1 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -188,31 +188,22 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin

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 -
+ @include('livewire.partials.breakdown-panel', [ + 'result' => $this->articlesPerFeed, + 'emptyMessage' => 'No feeds are configured yet.', + ]) + @endisland + + + +
+

Publications per Channel

+ + @island('publications-per-channel') + @include('livewire.partials.breakdown-panel', [ + 'result' => $this->publicationsPerChannel, + 'emptyMessage' => 'No channels are configured yet.', + ]) @endisland
diff --git a/resources/views/livewire/partials/breakdown-panel.blade.php b/resources/views/livewire/partials/breakdown-panel.blade.php new file mode 100644 index 00000000..04906111 --- /dev/null +++ b/resources/views/livewire/partials/breakdown-panel.blade.php @@ -0,0 +1,22 @@ +
! $rangeIsValid])> + @if ($result->rows === []) +

{{ $emptyMessage }}

+ @else +
    + @foreach ($result->rows as $row) +
  • +
    + {{ $row->label }} + + {{ $row->count }} + ({{ $result->shareOf($row) }}%) + +
    +
    +
    +
    +
  • + @endforeach +
+ @endif +
diff --git a/tests/Feature/Livewire/DashboardTest.php b/tests/Feature/Livewire/DashboardTest.php index 7c7225f9..4488f2b3 100644 --- a/tests/Feature/Livewire/DashboardTest.php +++ b/tests/Feature/Livewire/DashboardTest.php @@ -5,6 +5,7 @@ use App\Livewire\Dashboard; use App\Models\Article; use App\Models\Feed; +use App\Models\PlatformChannel; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Carbon; use Livewire\Features\SupportTesting\Testable; @@ -223,6 +224,29 @@ public function test_it_refreshes_the_articles_per_feed_island_when_the_range_ch $this->assertStringContainsString('Example Feed', $fragments); } + public function test_it_renders_the_publications_per_channel_breakdown_on_mount(): void + { + Carbon::setTestNow('2026-07-15 13:45:00'); + + PlatformChannel::factory()->create(['display_name' => 'Example Channel']); + + Livewire::test(Dashboard::class) + ->assertSee('Publications per Channel') + ->assertSee('Example Channel'); + } + + public function test_it_refreshes_the_publications_per_channel_island_when_the_range_changes(): void + { + PlatformChannel::factory()->create(['display_name' => 'Example Channel']); + + $fragments = $this->islandFragments( + Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31') + ); + + $this->assertStringContainsString('name=publications-per-channel|', $fragments); + $this->assertStringContainsString('Example Channel', $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/PublicationsPerChannelTest.php b/tests/Unit/Dashboard/Stats/PublicationsPerChannelTest.php new file mode 100644 index 00000000..cbfd567f --- /dev/null +++ b/tests/Unit/Dashboard/Stats/PublicationsPerChannelTest.php @@ -0,0 +1,126 @@ +create([ + 'platform_channel_id' => $channel->id, + 'published_at' => Carbon::parse($at), + ]); + } + + public function test_it_counts_publications_per_channel(): void + { + $busy = PlatformChannel::factory()->create(['display_name' => 'Busy Channel']); + $quiet = PlatformChannel::factory()->create(['display_name' => 'Quiet Channel']); + + $this->publish($busy, '2026-07-10 12:00:00'); + $this->publish($busy, '2026-07-11 12:00:00'); + $this->publish($quiet, '2026-07-12 12:00:00'); + + $rows = (new PublicationsPerChannel)->for($this->range())->rows; + + $this->assertSame(2, $this->pluck($rows)['Busy Channel']); + $this->assertSame(1, $this->pluck($rows)['Quiet Channel']); + } + + public function test_it_reports_zero_for_a_channel_with_no_publications(): void + { + PlatformChannel::factory()->create(['display_name' => 'Silent Channel']); + + $rows = (new PublicationsPerChannel)->for($this->range())->rows; + + $this->assertSame(['Silent Channel' => 0], $this->pluck($rows)); + } + + public function test_it_excludes_publications_outside_the_range(): void + { + $channel = PlatformChannel::factory()->create(['display_name' => 'Channel']); + + $this->publish($channel, '2026-06-30 23:59:59'); + $this->publish($channel, '2026-08-01 00:00:00'); + + $rows = (new PublicationsPerChannel)->for($this->range())->rows; + + $this->assertSame(0, $this->pluck($rows)['Channel']); + } + + public function test_it_breaks_ties_alphabetically(): void + { + foreach (['Zulu', 'Alpha'] as $name) { + $channel = PlatformChannel::factory()->create(['display_name' => $name]); + $this->publish($channel, '2026-07-10 12:00:00'); + } + + $labels = array_map( + fn (Breakdown $row): string => $row->label, + (new PublicationsPerChannel)->for($this->range())->rows, + ); + + $this->assertSame(['Alpha', 'Zulu'], array_slice($labels, 0, 2)); + } + + public function test_it_labels_channels_by_display_name(): void + { + PlatformChannel::factory()->create(['name' => 'raw-name', 'display_name' => 'Friendly Name']); + + $rows = (new PublicationsPerChannel)->for($this->range())->rows; + + $this->assertArrayHasKey('Friendly Name', $this->pluck($rows)); + } + + public function test_it_reports_the_range_total(): void + { + $channel = PlatformChannel::factory()->create(); + + $this->publish($channel, '2026-07-10 12:00:00'); + $this->publish($channel, '2026-07-11 12:00:00'); + + $this->assertSame(2, (new PublicationsPerChannel)->for($this->range())->total()); + } + + public function test_it_exposes_a_stable_key_and_label(): void + { + $stat = new PublicationsPerChannel; + + $this->assertSame('publications-per-channel', $stat->key()); + $this->assertSame('Publications per Channel', $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; + } +}