Release v1.4.0 #146

Merged
myrmidex merged 71 commits from release/v1.4.0 into main 2026-08-15 00:36:54 +02:00
11 changed files with 442 additions and 1 deletions
Showing only changes of commit 8a1001d274 - Show all commits

View file

@ -0,0 +1,47 @@
<?php
namespace App\Dashboard\Stats;
use App\Models\Article;
use App\Models\Feed;
use App\Support\DateRange;
class ArticlesPerFeed implements BreakdownStat
{
public function key(): string
{
return 'articles-per-feed';
}
public function label(): string
{
return 'Articles per Feed';
}
public function for(DateRange $range): BreakdownResult
{
/** @var array<int, int> $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);
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace App\Dashboard\Stats;
class Breakdown
{
public function __construct(
public readonly string $label,
public readonly int $count,
) {}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Dashboard\Stats;
class BreakdownResult
{
/**
* @param array<int, Breakdown> $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;
}
}

View file

@ -0,0 +1,10 @@
<?php
namespace App\Dashboard\Stats;
use App\Support\DateRange;
interface BreakdownStat extends Stat
{
public function for(DateRange $range): BreakdownResult;
}

View file

@ -0,0 +1,13 @@
<?php
namespace App\Dashboard\Stats;
interface Stat
{
/**
* Stable identifier, also used as the island name for this stat's panel.
*/
public function key(): string;
public function label(): string;
}

View file

@ -2,6 +2,8 @@
namespace App\Livewire; namespace App\Livewire;
use App\Dashboard\Stats\ArticlesPerFeed;
use App\Dashboard\Stats\BreakdownResult;
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;
@ -26,7 +28,7 @@ public function mount(): void
* *
* @var array<int, string> * @var array<int, string>
*/ */
private const RANGE_DEPENDENT_ISLANDS = ['article-statistics']; private const RANGE_DEPENDENT_ISLANDS = ['article-statistics', 'articles-per-feed'];
public function applyPreset(string $preset): void public function applyPreset(string $preset): void
{ {
@ -110,6 +112,16 @@ public function articleStats(): array
return app(DashboardStatsService::class)->getStats($range); 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<string, int> * @return array<string, int>
*/ */

View file

@ -182,4 +182,37 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
</div> </div>
@endisland @endisland
</div> </div>
<!-- Articles per Feed -->
<div class="mt-8">
<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
</div>
@endisland
</div>
</div> </div>

View file

@ -4,6 +4,7 @@
use App\Livewire\Dashboard; use App\Livewire\Dashboard;
use App\Models\Article; use App\Models\Article;
use App\Models\Feed;
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;
@ -178,6 +179,50 @@ public function test_it_refreshes_only_the_range_dependent_island(): void
$this->assertStringNotContainsString('Active Feeds', $fragments); $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 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,149 @@
<?php
namespace Tests\Unit\Dashboard\Stats;
use App\Dashboard\Stats\ArticlesPerFeed;
use App\Dashboard\Stats\Breakdown;
use App\Models\Article;
use App\Models\Feed;
use App\Support\DateRange;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;
class ArticlesPerFeedTest 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'),
);
}
public function test_it_counts_articles_per_feed_within_the_range(): void
{
$busy = Feed::factory()->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<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;
}
}

View file

@ -0,0 +1,58 @@
<?php
namespace Tests\Unit\Dashboard\Stats;
use App\Dashboard\Stats\Breakdown;
use App\Dashboard\Stats\BreakdownResult;
use Tests\TestCase;
class BreakdownResultTest extends TestCase
{
public function test_it_sums_its_rows(): void
{
$result = new BreakdownResult([
new Breakdown('A', 3),
new Breakdown('B', 4),
]);
$this->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());
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace Tests\Unit\Dashboard\Stats;
use App\Dashboard\Stats\ArticlesPerFeed;
use App\Dashboard\Stats\BreakdownStat;
use App\Dashboard\Stats\Stat;
use Tests\TestCase;
class StatContractTest extends TestCase
{
public function test_the_base_contract_only_promises_identity(): void
{
$methods = array_map(
fn (\ReflectionMethod $method): string => $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);
}
}