Release v1.4.0 #146
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\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<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
|
||||
{
|
||||
|
|
@ -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<BreakdownStat> $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([]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
@island('articles-per-feed')
|
||||
<div @class(['bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800', 'opacity-40' => ! $rangeIsValid])>
|
||||
@if ($this->articlesPerFeed->rows === [])
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No feeds are configured yet.</p>
|
||||
@else
|
||||
<ul class="space-y-3">
|
||||
@foreach ($this->articlesPerFeed->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">({{ $this->articlesPerFeed->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: {{ $this->articlesPerFeed->shareOf($row) }}%"
|
||||
></div>
|
||||
</div>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
@include('livewire.partials.breakdown-panel', [
|
||||
'result' => $this->articlesPerFeed,
|
||||
'emptyMessage' => 'No feeds are configured yet.',
|
||||
])
|
||||
@endisland
|
||||
</div>
|
||||
|
||||
<!-- Publications per Channel -->
|
||||
<div class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Publications per Channel</h2>
|
||||
|
||||
@island('publications-per-channel')
|
||||
@include('livewire.partials.breakdown-panel', [
|
||||
'result' => $this->publicationsPerChannel,
|
||||
'emptyMessage' => 'No channels are configured yet.',
|
||||
])
|
||||
@endisland
|
||||
</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\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');
|
||||
|
|
|
|||
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