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
12 changed files with 430 additions and 28 deletions
Showing only changes of commit cc45b74a63 - Show all commits

View file

@ -0,0 +1,53 @@
<?php
namespace App\Dashboard\Stats;
use App\Enums\ApprovalStatusEnum;
use App\Models\RouteArticle;
use App\Support\DateRange;
class ApprovalRate extends DailySeriesStat
{
public function key(): string
{
return 'approval-rate';
}
public function label(): string
{
return 'Approval Rate';
}
protected function series(DateRange $range, array $days): SeriesResult
{
$decisions = RouteArticle::query()
->toBase()
->whereNotNull('decided_at')
->whereBetween('decided_at', [$range->from, $range->to])
->whereIn('approval_status', ApprovalStatusEnum::decidedValues())
->selectRaw(
'DATE(decided_at) as bucket, COUNT(*) as total, SUM(CASE WHEN approval_status = ? THEN 1 ELSE 0 END) as approved',
[ApprovalStatusEnum::APPROVED->value],
)
->groupBy('bucket')
->get()
->keyBy('bucket');
$values = array_map(
function (string $day) use ($decisions): ?float {
$decision = $decisions->get($day);
if ($decision === null || (int) $decision->total === 0) {
return null;
}
return round(((int) $decision->approved / (int) $decision->total) * 100, 1);
},
$days,
);
return new SeriesResult($days, [
new Series('Approval Rate', $values, zeroIsMeaningful: true),
]);
}
}

View file

@ -6,9 +6,26 @@ class Series
{ {
/** /**
* @param array<int, int|float|null> $values * @param array<int, int|float|null> $values
* @param bool $zeroIsMeaningful A rate of 0 is a real measurement; a count of 0 is an absence.
*/ */
public function __construct( public function __construct(
public readonly string $name, public readonly string $name,
public readonly array $values, public readonly array $values,
public readonly bool $zeroIsMeaningful = false,
) {} ) {}
public function hasData(): bool
{
foreach ($this->values as $value) {
if ($value === null) {
continue;
}
if ($this->zeroIsMeaningful || $value != 0) {
return true;
}
}
return false;
}
} }

View file

@ -44,16 +44,14 @@ public function isEmpty(): bool
return ! $this->tooWide && $this->labels === []; return ! $this->tooWide && $this->labels === [];
} }
/** True when every value is null or zero — an axis exists but nothing happened on it. */ /** True when no series carries a measurement — an axis exists but nothing happened on it. */
public function hasNoData(): bool public function hasNoData(): bool
{ {
foreach ($this->series as $one) { foreach ($this->series as $one) {
foreach ($one->values as $value) { if ($one->hasData()) {
if ($value !== null && $value != 0) {
return false; return false;
} }
} }
}
return true; return true;
} }

View file

@ -7,4 +7,20 @@ enum ApprovalStatusEnum: string
case PENDING = 'pending'; case PENDING = 'pending';
case APPROVED = 'approved'; case APPROVED = 'approved';
case REJECTED = 'rejected'; case REJECTED = 'rejected';
public function isDecided(): bool
{
return $this !== self::PENDING;
}
/**
* @return array<int, string>
*/
public static function decidedValues(): array
{
return array_values(array_map(
fn (self $status): string => $status->value,
array_filter(self::cases(), fn (self $status): bool => $status->isDecided()),
));
}
} }

View file

@ -2,6 +2,7 @@
namespace App\Livewire; namespace App\Livewire;
use App\Dashboard\Stats\ApprovalRate;
use App\Dashboard\Stats\ArticlesPerFeed; use App\Dashboard\Stats\ArticlesPerFeed;
use App\Dashboard\Stats\ArticlesTrend; use App\Dashboard\Stats\ArticlesTrend;
use App\Dashboard\Stats\BreakdownResult; use App\Dashboard\Stats\BreakdownResult;
@ -35,6 +36,7 @@ public function mount(): void
private const RANGE_DEPENDENT_ISLANDS = [ private const RANGE_DEPENDENT_ISLANDS = [
'article-statistics', 'article-statistics',
'articles-trend', 'articles-trend',
'approval-rate',
'articles-per-feed', 'articles-per-feed',
'publications-per-channel', 'publications-per-channel',
]; ];
@ -131,6 +133,16 @@ public function articlesTrend(): SeriesResult
: new SeriesResult([], []); : new SeriesResult([], []);
} }
#[Computed]
public function approvalRate(): SeriesResult
{
$range = $this->range();
return $range instanceof DateRange
? app(ApprovalRate::class)->for($range)
: new SeriesResult([], []);
}
#[Computed] #[Computed]
public function articlesPerFeed(): BreakdownResult public function articlesPerFeed(): BreakdownResult
{ {

View file

@ -23,7 +23,7 @@ Chart.register(
const palette = ['#3b82f6', '#10b981']; const palette = ['#3b82f6', '#10b981'];
export default function trendChart({ labels = [], series = [] } = {}) { export default function trendChart({ labels = [], series = [], suffix = '', max = null } = {}) {
return { return {
chart: null, chart: null,
@ -46,7 +46,21 @@ export default function trendChart({ labels = [], series = [] } = {}) {
maintainAspectRatio: false, maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false }, interaction: { mode: 'index', intersect: false },
scales: { scales: {
y: { beginAtZero: true, ticks: { precision: 0 } }, y: {
beginAtZero: true,
max,
ticks: {
precision: 0,
callback: (value) => `${value}${suffix}`,
},
},
},
plugins: {
tooltip: {
callbacks: {
label: (ctx) => `${ctx.dataset.label}: ${ctx.formattedValue}${suffix}`,
},
},
}, },
}, },
}); });

View file

@ -184,12 +184,13 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
</div> </div>
<!-- Trend and breakdowns --> <!-- Trend and breakdowns -->
<div class="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-4"> <div class="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-3">
<div class="lg:col-span-2"> <div>
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Fetched vs Published</h2> <h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Fetched vs Published</h2>
@island('articles-trend') @island('articles-trend')
@include('livewire.partials.trend-panel', [ @include('livewire.partials.trend-panel', [
'island' => 'articles-trend',
'result' => $this->articlesTrend, 'result' => $this->articlesTrend,
'emptyMessage' => 'No articles or publications in this range.', 'emptyMessage' => 'No articles or publications in this range.',
'tooWideMessage' => 'This range is too wide to chart by day. Pick a range under two years.', 'tooWideMessage' => 'This range is too wide to chart by day. Pick a range under two years.',
@ -197,6 +198,22 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
@endisland @endisland
</div> </div>
<div>
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Approval Rate</h2>
@island('approval-rate')
@include('livewire.partials.trend-panel', [
'island' => 'approval-rate',
'result' => $this->approvalRate,
'emptyMessage' => 'No approval decisions in this range.',
'tooWideMessage' => 'This range is too wide to chart by day. Pick a range under two years.',
'suffix' => '%',
'max' => 100,
])
@endisland
</div>
<div class="space-y-6">
<div> <div>
<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>
@ -219,4 +236,5 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
@endisland @endisland
</div> </div>
</div> </div>
</div>
</div> </div>

View file

@ -1,4 +1,4 @@
@php($payload = ['labels' => $result->labels, 'series' => collect($result->series)->map(fn ($series) => ['name' => $series->name, 'values' => $series->values])->all()]) @php($payload = ['labels' => $result->labels, 'series' => collect($result->series)->map(fn ($series) => ['name' => $series->name, 'values' => $series->values])->all(), 'suffix' => $suffix ?? '', 'max' => $max ?? null])
<div @class(['bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800', 'opacity-40' => ! $rangeIsValid])> <div @class(['bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800', 'opacity-40' => ! $rangeIsValid])>
@if ($result->isTooWide()) @if ($result->isTooWide())
@ -7,7 +7,7 @@
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $emptyMessage }}</p> <p class="text-sm text-gray-500 dark:text-gray-400">{{ $emptyMessage }}</p>
@else @else
<div <div
wire:key="trend-{{ md5(json_encode($payload)) }}" wire:key="chart-{{ md5($island.json_encode($payload)) }}"
x-data="trendChart({{ Js::from($payload) }})" x-data="trendChart({{ Js::from($payload) }})"
class="relative h-72" class="relative h-72"
> >

View file

@ -2,10 +2,12 @@
namespace Tests\Feature\Livewire; namespace Tests\Feature\Livewire;
use App\Enums\ApprovalStatusEnum;
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 App\Models\PlatformChannel;
use App\Models\RouteArticle;
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;
@ -330,14 +332,79 @@ public function test_it_keys_the_chart_on_its_data_so_a_range_change_replaces_it
Livewire::test(Dashboard::class)->call('applyRange', '2026-08-01', '2026-08-31') Livewire::test(Dashboard::class)->call('applyRange', '2026-08-01', '2026-08-31')
); );
preg_match('/wire:key="(trend-[a-f0-9]+)"/', $july, $julyKey); preg_match('/wire:key="(chart-[a-f0-9]+)"/', $july, $julyKey);
preg_match('/wire:key="(trend-[a-f0-9]+)"/', $august, $augustKey); preg_match('/wire:key="(chart-[a-f0-9]+)"/', $august, $augustKey);
$this->assertNotEmpty($julyKey); $this->assertNotEmpty($julyKey);
$this->assertNotEmpty($augustKey); $this->assertNotEmpty($augustKey);
$this->assertNotSame($julyKey[1], $augustKey[1]); $this->assertNotSame($julyKey[1], $augustKey[1]);
} }
public function test_it_keys_each_chart_separately_when_their_payloads_match(): void
{
$fragments = $this->islandFragments(
Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31')
);
preg_match_all('/wire:key="(chart-[a-f0-9]+)"/', $fragments, $keys);
$this->assertSame($keys[1], array_unique($keys[1]));
}
public function test_it_renders_the_approval_rate_chart_on_mount(): void
{
Carbon::setTestNow('2026-07-15 13:45:00');
RouteArticle::factory()->create([
'approval_status' => ApprovalStatusEnum::APPROVED,
'decided_at' => Carbon::parse('2026-07-15 09:00:00'),
]);
Livewire::test(Dashboard::class)
->assertSee('Approval Rate')
->assertSee('trendChart(', false)
->assertSee('x-ref="canvas"', false);
}
public function test_it_refreshes_the_approval_rate_island_when_the_range_changes(): void
{
RouteArticle::factory()->create([
'approval_status' => ApprovalStatusEnum::APPROVED,
'decided_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->assertStringContainsString('name=approval-rate|', $fragments);
$this->assertStringContainsString('Approval Rate', $fragments);
$this->assertStringContainsString('2026-07-10', $fragments);
}
public function test_it_charts_a_day_where_everything_was_rejected(): void
{
RouteArticle::factory()->create([
'approval_status' => ApprovalStatusEnum::REJECTED,
'decided_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->assertStringNotContainsString('No approval decisions in this range.', $fragments);
}
public function test_it_reports_a_range_with_no_approval_decisions(): void
{
$fragments = $this->islandFragments(
Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31')
);
$this->assertStringContainsString('No approval decisions in this range.', $fragments);
}
public function test_it_renders_an_empty_trend_for_an_invalid_range(): void public function test_it_renders_an_empty_trend_for_an_invalid_range(): void
{ {
Livewire::test(Dashboard::class) Livewire::test(Dashboard::class)

View file

@ -0,0 +1,149 @@
<?php
namespace Tests\Unit\Dashboard\Stats;
use App\Dashboard\Stats\ApprovalRate;
use App\Dashboard\Stats\DailySeriesStat;
use App\Enums\ApprovalStatusEnum;
use App\Models\RouteArticle;
use App\Support\DateRange;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;
class ApprovalRateTest 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'),
);
}
/**
* @return array<string, float|null>
*/
private function values(): array
{
$result = (new ApprovalRate)->for($this->range());
return array_combine($result->labels, $result->series[0]->values);
}
private function decision(ApprovalStatusEnum $status, string $decidedAt): void
{
RouteArticle::factory()->create([
'approval_status' => $status,
'decided_at' => Carbon::parse($decidedAt),
]);
}
public function test_it_computes_the_approval_share_of_daily_decisions(): void
{
$this->decision(ApprovalStatusEnum::APPROVED, '2026-07-10 09:00:00');
$this->decision(ApprovalStatusEnum::APPROVED, '2026-07-10 10:00:00');
$this->decision(ApprovalStatusEnum::APPROVED, '2026-07-10 11:00:00');
$this->decision(ApprovalStatusEnum::REJECTED, '2026-07-10 12:00:00');
$this->assertSame(75.0, $this->values()['2026-07-10']);
}
public function test_it_excludes_pending_articles_from_the_denominator(): void
{
$this->decision(ApprovalStatusEnum::APPROVED, '2026-07-10 09:00:00');
RouteArticle::factory()->create([
'approval_status' => ApprovalStatusEnum::PENDING,
'decided_at' => null,
'created_at' => Carbon::parse('2026-07-10 09:00:00'),
]);
$this->assertSame(100.0, $this->values()['2026-07-10']);
}
public function test_it_reports_no_rate_for_a_day_with_no_decisions(): void
{
$this->decision(ApprovalStatusEnum::APPROVED, '2026-07-10 09:00:00');
$this->assertNull($this->values()['2026-07-11']);
}
public function test_it_reports_a_zero_rate_when_everything_was_rejected(): void
{
$this->decision(ApprovalStatusEnum::REJECTED, '2026-07-10 09:00:00');
$this->decision(ApprovalStatusEnum::REJECTED, '2026-07-10 10:00:00');
$this->assertSame(0.0, $this->values()['2026-07-10']);
}
public function test_it_buckets_by_decision_time_not_creation_time(): void
{
RouteArticle::factory()->create([
'approval_status' => ApprovalStatusEnum::APPROVED,
'created_at' => Carbon::parse('2026-07-05 09:00:00'),
'decided_at' => Carbon::parse('2026-07-20 09:00:00'),
]);
$values = $this->values();
$this->assertNull($values['2026-07-05']);
$this->assertSame(100.0, $values['2026-07-20']);
}
public function test_it_ignores_articles_decided_before_the_column_existed(): void
{
RouteArticle::factory()->create([
'approval_status' => ApprovalStatusEnum::APPROVED,
'created_at' => Carbon::parse('2026-07-10 09:00:00'),
'decided_at' => null,
]);
$this->assertNull($this->values()['2026-07-10']);
}
public function test_it_excludes_decisions_outside_the_range(): void
{
$this->decision(ApprovalStatusEnum::APPROVED, '2026-06-30 23:59:59');
$this->decision(ApprovalStatusEnum::APPROVED, '2026-08-01 00:00:00');
$this->assertSame(
[],
array_filter($this->values(), fn (?float $value): bool => $value !== null)
);
}
public function test_it_reports_a_meaningful_zero_as_data(): void
{
$this->decision(ApprovalStatusEnum::REJECTED, '2026-07-10 09:00:00');
$this->assertFalse((new ApprovalRate)->for($this->range())->hasNoData());
}
public function test_it_counts_every_decided_status_in_the_denominator(): void
{
$decided = array_filter(
ApprovalStatusEnum::cases(),
fn (ApprovalStatusEnum $status): bool => $status->isDecided(),
);
foreach ($decided as $status) {
$this->decision($status, '2026-07-10 09:00:00');
}
$this->assertSame(
round(100 / count($decided), 1),
$this->values()['2026-07-10'],
);
}
public function test_it_is_a_daily_series_stat(): void
{
$stat = new ApprovalRate;
$this->assertInstanceOf(DailySeriesStat::class, $stat);
$this->assertTrue($stat->for(DateRange::preset('all'))->isTooWide());
}
}

View file

@ -91,11 +91,31 @@ public function test_a_result_of_only_nulls_has_no_data(): void
$this->assertTrue($result->hasNoData()); $this->assertTrue($result->hasNoData());
} }
public function test_zero_values_are_indistinguishable_from_absent_ones(): void public function test_a_zero_count_is_an_absence_of_data(): void
{ {
$result = new SeriesResult( $result = new SeriesResult(
['2026-07-01', '2026-07-02'], ['2026-07-01', '2026-07-02'],
[new Series('Approval Rate', [null, 0.0])], [new Series('Fetched', [null, 0])],
);
$this->assertTrue($result->hasNoData());
}
public function test_a_zero_is_data_when_the_series_says_zero_is_meaningful(): void
{
$result = new SeriesResult(
['2026-07-01', '2026-07-02'],
[new Series('Approval Rate', [null, 0.0], zeroIsMeaningful: true)],
);
$this->assertFalse($result->hasNoData());
}
public function test_a_meaningful_zero_series_of_only_nulls_still_has_no_data(): void
{
$result = new SeriesResult(
['2026-07-01', '2026-07-02'],
[new Series('Approval Rate', [null, null], zeroIsMeaningful: true)],
); );
$this->assertTrue($result->hasNoData()); $this->assertTrue($result->hasNoData());

View file

@ -0,0 +1,38 @@
<?php
namespace Tests\Unit\Enums;
use App\Enums\ApprovalStatusEnum;
use PHPUnit\Framework\TestCase;
class ApprovalStatusEnumTest extends TestCase
{
public function test_pending_is_not_a_decision(): void
{
$this->assertFalse(ApprovalStatusEnum::PENDING->isDecided());
}
public function test_approved_and_rejected_are_decisions(): void
{
$this->assertTrue(ApprovalStatusEnum::APPROVED->isDecided());
$this->assertTrue(ApprovalStatusEnum::REJECTED->isDecided());
}
public function test_decided_values_lists_every_decided_case(): void
{
$this->assertSame(['approved', 'rejected'], ApprovalStatusEnum::decidedValues());
}
public function test_decided_values_stays_in_step_with_the_cases(): void
{
$expected = array_values(array_map(
fn (ApprovalStatusEnum $status): string => $status->value,
array_filter(
ApprovalStatusEnum::cases(),
fn (ApprovalStatusEnum $status): bool => $status->isDecided(),
),
));
$this->assertSame($expected, ApprovalStatusEnum::decidedValues());
}
}