From cc45b74a63518eba7507a86966145182d5814537 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Thu, 13 Aug 2026 00:54:29 +0200 Subject: [PATCH] 83 - Add an approval rate chart and rework the dashboard grid --- app/Dashboard/Stats/ApprovalRate.php | 53 +++++++ app/Dashboard/Stats/Series.php | 17 ++ app/Dashboard/Stats/SeriesResult.php | 8 +- app/Enums/ApprovalStatusEnum.php | 16 ++ app/Livewire/Dashboard.php | 12 ++ resources/js/chart.js | 18 ++- resources/views/livewire/dashboard.blade.php | 48 ++++-- .../livewire/partials/trend-panel.blade.php | 4 +- tests/Feature/Livewire/DashboardTest.php | 71 ++++++++- .../Unit/Dashboard/Stats/ApprovalRateTest.php | 149 ++++++++++++++++++ .../Unit/Dashboard/Stats/SeriesResultTest.php | 24 ++- tests/Unit/Enums/ApprovalStatusEnumTest.php | 38 +++++ 12 files changed, 430 insertions(+), 28 deletions(-) create mode 100644 app/Dashboard/Stats/ApprovalRate.php create mode 100644 tests/Unit/Dashboard/Stats/ApprovalRateTest.php create mode 100644 tests/Unit/Enums/ApprovalStatusEnumTest.php diff --git a/app/Dashboard/Stats/ApprovalRate.php b/app/Dashboard/Stats/ApprovalRate.php new file mode 100644 index 00000000..cb832947 --- /dev/null +++ b/app/Dashboard/Stats/ApprovalRate.php @@ -0,0 +1,53 @@ +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), + ]); + } +} diff --git a/app/Dashboard/Stats/Series.php b/app/Dashboard/Stats/Series.php index 554ab801..98415b6d 100644 --- a/app/Dashboard/Stats/Series.php +++ b/app/Dashboard/Stats/Series.php @@ -6,9 +6,26 @@ class Series { /** * @param array $values + * @param bool $zeroIsMeaningful A rate of 0 is a real measurement; a count of 0 is an absence. */ public function __construct( public readonly string $name, 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; + } } diff --git a/app/Dashboard/Stats/SeriesResult.php b/app/Dashboard/Stats/SeriesResult.php index 41777c1d..89f99f72 100644 --- a/app/Dashboard/Stats/SeriesResult.php +++ b/app/Dashboard/Stats/SeriesResult.php @@ -44,14 +44,12 @@ public function isEmpty(): bool 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 { foreach ($this->series as $one) { - foreach ($one->values as $value) { - if ($value !== null && $value != 0) { - return false; - } + if ($one->hasData()) { + return false; } } diff --git a/app/Enums/ApprovalStatusEnum.php b/app/Enums/ApprovalStatusEnum.php index 5095c5d7..d75d3432 100644 --- a/app/Enums/ApprovalStatusEnum.php +++ b/app/Enums/ApprovalStatusEnum.php @@ -7,4 +7,20 @@ enum ApprovalStatusEnum: string case PENDING = 'pending'; case APPROVED = 'approved'; case REJECTED = 'rejected'; + + public function isDecided(): bool + { + return $this !== self::PENDING; + } + + /** + * @return array + */ + 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()), + )); + } } diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index eb520405..df31437a 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -2,6 +2,7 @@ namespace App\Livewire; +use App\Dashboard\Stats\ApprovalRate; use App\Dashboard\Stats\ArticlesPerFeed; use App\Dashboard\Stats\ArticlesTrend; use App\Dashboard\Stats\BreakdownResult; @@ -35,6 +36,7 @@ public function mount(): void private const RANGE_DEPENDENT_ISLANDS = [ 'article-statistics', 'articles-trend', + 'approval-rate', 'articles-per-feed', 'publications-per-channel', ]; @@ -131,6 +133,16 @@ public function articlesTrend(): SeriesResult : new SeriesResult([], []); } + #[Computed] + public function approvalRate(): SeriesResult + { + $range = $this->range(); + + return $range instanceof DateRange + ? app(ApprovalRate::class)->for($range) + : new SeriesResult([], []); + } + #[Computed] public function articlesPerFeed(): BreakdownResult { diff --git a/resources/js/chart.js b/resources/js/chart.js index aff02bb8..ca359e6b 100644 --- a/resources/js/chart.js +++ b/resources/js/chart.js @@ -23,7 +23,7 @@ Chart.register( const palette = ['#3b82f6', '#10b981']; -export default function trendChart({ labels = [], series = [] } = {}) { +export default function trendChart({ labels = [], series = [], suffix = '', max = null } = {}) { return { chart: null, @@ -46,7 +46,21 @@ export default function trendChart({ labels = [], series = [] } = {}) { maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, 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}`, + }, + }, }, }, }); diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 96e8b52e..7e0a89cb 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -184,12 +184,13 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin -
-
+
+

Fetched vs Published

@island('articles-trend') @include('livewire.partials.trend-panel', [ + 'island' => 'articles-trend', 'result' => $this->articlesTrend, 'emptyMessage' => 'No articles or publications in this range.', 'tooWideMessage' => 'This range is too wide to chart by day. Pick a range under two years.', @@ -198,25 +199,42 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
-

Articles per Feed

+

Approval Rate

- @island('articles-per-feed') - @include('livewire.partials.breakdown-panel', [ - 'result' => $this->articlesPerFeed, - 'emptyMessage' => 'No feeds are configured yet.', + @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
-
-

Publications per Channel

+
+
+

Articles per Feed

- @island('publications-per-channel') - @include('livewire.partials.breakdown-panel', [ - 'result' => $this->publicationsPerChannel, - 'emptyMessage' => 'No channels are configured yet.', - ]) - @endisland + @island('articles-per-feed') + @include('livewire.partials.breakdown-panel', [ + 'result' => $this->articlesPerFeed, + 'emptyMessage' => 'No feeds are configured yet.', + ]) + @endisland +
+ +
+

Publications per Channel

+ + @island('publications-per-channel') + @include('livewire.partials.breakdown-panel', [ + 'result' => $this->publicationsPerChannel, + 'emptyMessage' => 'No channels are configured yet.', + ]) + @endisland +
diff --git a/resources/views/livewire/partials/trend-panel.blade.php b/resources/views/livewire/partials/trend-panel.blade.php index 483a5906..5f9d65f7 100644 --- a/resources/views/livewire/partials/trend-panel.blade.php +++ b/resources/views/livewire/partials/trend-panel.blade.php @@ -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])
! $rangeIsValid])> @if ($result->isTooWide()) @@ -7,7 +7,7 @@

{{ $emptyMessage }}

@else
diff --git a/tests/Feature/Livewire/DashboardTest.php b/tests/Feature/Livewire/DashboardTest.php index 83044f18..68c3393f 100644 --- a/tests/Feature/Livewire/DashboardTest.php +++ b/tests/Feature/Livewire/DashboardTest.php @@ -2,10 +2,12 @@ namespace Tests\Feature\Livewire; +use App\Enums\ApprovalStatusEnum; use App\Livewire\Dashboard; use App\Models\Article; use App\Models\Feed; use App\Models\PlatformChannel; +use App\Models\RouteArticle; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Carbon; 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') ); - preg_match('/wire:key="(trend-[a-f0-9]+)"/', $july, $julyKey); - preg_match('/wire:key="(trend-[a-f0-9]+)"/', $august, $augustKey); + preg_match('/wire:key="(chart-[a-f0-9]+)"/', $july, $julyKey); + preg_match('/wire:key="(chart-[a-f0-9]+)"/', $august, $augustKey); $this->assertNotEmpty($julyKey); $this->assertNotEmpty($augustKey); $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 { Livewire::test(Dashboard::class) diff --git a/tests/Unit/Dashboard/Stats/ApprovalRateTest.php b/tests/Unit/Dashboard/Stats/ApprovalRateTest.php new file mode 100644 index 00000000..d377b78d --- /dev/null +++ b/tests/Unit/Dashboard/Stats/ApprovalRateTest.php @@ -0,0 +1,149 @@ + + */ + 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()); + } +} diff --git a/tests/Unit/Dashboard/Stats/SeriesResultTest.php b/tests/Unit/Dashboard/Stats/SeriesResultTest.php index 42a898fc..f655f082 100644 --- a/tests/Unit/Dashboard/Stats/SeriesResultTest.php +++ b/tests/Unit/Dashboard/Stats/SeriesResultTest.php @@ -91,11 +91,31 @@ public function test_a_result_of_only_nulls_has_no_data(): void $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( ['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()); diff --git a/tests/Unit/Enums/ApprovalStatusEnumTest.php b/tests/Unit/Enums/ApprovalStatusEnumTest.php new file mode 100644 index 00000000..33722925 --- /dev/null +++ b/tests/Unit/Enums/ApprovalStatusEnumTest.php @@ -0,0 +1,38 @@ +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()); + } +}