83 - Add a per-channel publication breakdown to the dashboard
This commit is contained in:
parent
8a1001d274
commit
55f76d0d09
6 changed files with 257 additions and 27 deletions
47
app/Dashboard/Stats/PublicationsPerChannel.php
Normal file
47
app/Dashboard/Stats/PublicationsPerChannel.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
22
resources/views/livewire/partials/breakdown-panel.blade.php
Normal file
22
resources/views/livewire/partials/breakdown-panel.blade.php
Normal 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>
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
126
tests/Unit/Dashboard/Stats/PublicationsPerChannelTest.php
Normal file
126
tests/Unit/Dashboard/Stats/PublicationsPerChannelTest.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue