83 - Add a per-channel publication breakdown to the dashboard

This commit is contained in:
myrmidex 2026-08-12 00:41:10 +02:00
parent 8a1001d274
commit 55f76d0d09
6 changed files with 257 additions and 27 deletions

View file

@ -0,0 +1,47 @@
<?php
namespace App\Dashboard\Stats;
use App\Models\ArticlePublication;
use App\Models\PlatformChannel;
use App\Support\DateRange;
class PublicationsPerChannel implements BreakdownStat
{
public function key(): string
{
return 'publications-per-channel';
}
public function label(): string
{
return 'Publications per Channel';
}
public function for(DateRange $range): BreakdownResult
{
/** @var array<int, int> $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);
}
}

View file

@ -4,6 +4,8 @@
use App\Dashboard\Stats\ArticlesPerFeed; use App\Dashboard\Stats\ArticlesPerFeed;
use App\Dashboard\Stats\BreakdownResult; use App\Dashboard\Stats\BreakdownResult;
use App\Dashboard\Stats\BreakdownStat;
use App\Dashboard\Stats\PublicationsPerChannel;
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;
@ -28,7 +30,11 @@ public function mount(): void
* *
* @var array<int, string> * @var array<int, string>
*/ */
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 public function applyPreset(string $preset): void
{ {
@ -114,11 +120,25 @@ public function articleStats(): array
#[Computed] #[Computed]
public function articlesPerFeed(): BreakdownResult public function articlesPerFeed(): BreakdownResult
{
return $this->breakdown(ArticlesPerFeed::class);
}
#[Computed]
public function publicationsPerChannel(): BreakdownResult
{
return $this->breakdown(PublicationsPerChannel::class);
}
/**
* @param class-string<BreakdownStat> $stat
*/
private function breakdown(string $stat): BreakdownResult
{ {
$range = $this->range(); $range = $this->range();
return $range instanceof DateRange return $range instanceof DateRange
? app(ArticlesPerFeed::class)->for($range) ? app($stat)->for($range)
: new BreakdownResult([]); : new BreakdownResult([]);
} }

View file

@ -188,31 +188,22 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
<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>
@island('articles-per-feed') @island('articles-per-feed')
<div @class(['bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800', 'opacity-40' => ! $rangeIsValid])> @include('livewire.partials.breakdown-panel', [
@if ($this->articlesPerFeed->rows === []) 'result' => $this->articlesPerFeed,
<p class="text-sm text-gray-500 dark:text-gray-400">No feeds are configured yet.</p> 'emptyMessage' => 'No feeds are configured yet.',
@else ])
<ul class="space-y-3"> @endisland
@foreach ($this->articlesPerFeed->rows as $row) </div>
<li>
<div class="flex items-center justify-between text-sm"> <!-- Publications per Channel -->
<span class="font-medium text-gray-700 dark:text-gray-200">{{ $row->label }}</span> <div class="mt-8">
<span class="text-gray-500 dark:text-gray-400"> <h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Publications per Channel</h2>
{{ $row->count }}
<span class="ml-1 text-xs">({{ $this->articlesPerFeed->shareOf($row) }}%)</span> @island('publications-per-channel')
</span> @include('livewire.partials.breakdown-panel', [
</div> 'result' => $this->publicationsPerChannel,
<div class="mt-1 h-2 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700"> 'emptyMessage' => 'No channels are configured yet.',
<div ])
class="h-full rounded-full bg-blue-500"
style="width: {{ $this->articlesPerFeed->shareOf($row) }}%"
></div>
</div>
</li>
@endforeach
</ul>
@endif
</div>
@endisland @endisland
</div> </div>
</div> </div>

View file

@ -0,0 +1,22 @@
<div @class(['bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800', 'opacity-40' => ! $rangeIsValid])>
@if ($result->rows === [])
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $emptyMessage }}</p>
@else
<ul class="space-y-3">
@foreach ($result->rows as $row)
<li>
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-200">{{ $row->label }}</span>
<span class="text-gray-500 dark:text-gray-400">
{{ $row->count }}
<span class="ml-1 text-xs">({{ $result->shareOf($row) }}%)</span>
</span>
</div>
<div class="mt-1 h-2 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
<div class="h-full rounded-full bg-blue-500" style="width: {{ $result->shareOf($row) }}%"></div>
</div>
</li>
@endforeach
</ul>
@endif
</div>

View file

@ -5,6 +5,7 @@
use App\Livewire\Dashboard; use App\Livewire\Dashboard;
use App\Models\Article; use App\Models\Article;
use App\Models\Feed; use App\Models\Feed;
use App\Models\PlatformChannel;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Livewire\Features\SupportTesting\Testable; 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); $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 public function test_it_re_renders_the_article_statistics_island_when_a_preset_is_applied(): void
{ {
Carbon::setTestNow('2026-07-15 13:45:00'); Carbon::setTestNow('2026-07-15 13:45:00');

View file

@ -0,0 +1,126 @@
<?php
namespace Tests\Unit\Dashboard\Stats;
use App\Dashboard\Stats\Breakdown;
use App\Dashboard\Stats\PublicationsPerChannel;
use App\Models\ArticlePublication;
use App\Models\PlatformChannel;
use App\Support\DateRange;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;
class PublicationsPerChannelTest extends TestCase
{
use RefreshDatabase;
private function range(): DateRange
{
return new DateRange(
Carbon::parse('2026-07-01 00:00:00'),
Carbon::parse('2026-07-31 23:59:59'),
);
}
private function publish(PlatformChannel $channel, string $at): void
{
ArticlePublication::factory()->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<int, Breakdown> $rows
* @return array<string, int>
*/
private function pluck(array $rows): array
{
$out = [];
foreach ($rows as $row) {
$out[$row->label] = $row->count;
}
return $out;
}
}