diff --git a/app/Dashboard/Stats/PublishSuccessRate.php b/app/Dashboard/Stats/PublishSuccessRate.php new file mode 100644 index 00000000..9a74b03f --- /dev/null +++ b/app/Dashboard/Stats/PublishSuccessRate.php @@ -0,0 +1,53 @@ +toBase() + ->whereBetween('updated_at', [$range->from, $range->to]) + ->whereIn('publish_status', PublishStatusEnum::settledValues()) + ->selectRaw( + 'DATE(updated_at) as bucket, COUNT(*) as total, SUM(CASE WHEN publish_status = ? THEN 1 ELSE 0 END) as published', + [PublishStatusEnum::PUBLISHED->value], + ) + ->groupBy('bucket') + ->get() + ->keyBy('bucket'); + + $values = array_map( + function (string $day) use ($attempts): ?float { + $attempt = $attempts->get($day); + + if ($attempt === null || (int) $attempt->total === 0) { + return null; + } + + return round(((int) $attempt->published / (int) $attempt->total) * 100, 1); + }, + $days, + ); + + return new SeriesResult($days, [ + new Series('Publish Success Rate', $values, zeroIsMeaningful: true), + ]); + } +} diff --git a/app/Enums/PublishStatusEnum.php b/app/Enums/PublishStatusEnum.php index c4e49b2a..8d9e6f47 100644 --- a/app/Enums/PublishStatusEnum.php +++ b/app/Enums/PublishStatusEnum.php @@ -9,4 +9,21 @@ enum PublishStatusEnum: string case PUBLISHED = 'published'; case SKIPPED = 'skipped'; case ERROR = 'error'; + + /** Skipped articles were never attempted, so they are not a publish outcome. */ + public function isSettled(): bool + { + return $this === self::PUBLISHED || $this === self::ERROR; + } + + /** + * @return array + */ + public static function settledValues(): array + { + return array_values(array_map( + fn (self $status): string => $status->value, + array_filter(self::cases(), fn (self $status): bool => $status->isSettled()), + )); + } } diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index df31437a..2e369d16 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -8,6 +8,7 @@ use App\Dashboard\Stats\BreakdownResult; use App\Dashboard\Stats\BreakdownStat; use App\Dashboard\Stats\PublicationsPerChannel; +use App\Dashboard\Stats\PublishSuccessRate; use App\Dashboard\Stats\SeriesResult; use App\Services\DashboardStatsService; use App\Support\DateRange; @@ -37,6 +38,7 @@ public function mount(): void 'article-statistics', 'articles-trend', 'approval-rate', + 'publish-success-rate', 'articles-per-feed', 'publications-per-channel', ]; @@ -143,6 +145,16 @@ public function approvalRate(): SeriesResult : new SeriesResult([], []); } + #[Computed] + public function publishSuccessRate(): SeriesResult + { + $range = $this->range(); + + return $range instanceof DateRange + ? app(PublishSuccessRate::class)->for($range) + : new SeriesResult([], []); + } + #[Computed] public function articlesPerFeed(): BreakdownResult { diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 7e0a89cb..09ef0747 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -213,6 +213,21 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin @endisland +
+

Publish Success Rate

+ + @island('publish-success-rate') + @include('livewire.partials.trend-panel', [ + 'island' => 'publish-success-rate', + 'result' => $this->publishSuccessRate, + 'emptyMessage' => 'No publish attempts in this range.', + 'tooWideMessage' => 'This range is too wide to chart by day. Pick a range under two years.', + 'suffix' => '%', + 'max' => 100, + ]) + @endisland +
+

Articles per Feed

diff --git a/tests/Feature/Livewire/DashboardTest.php b/tests/Feature/Livewire/DashboardTest.php index 68c3393f..f781dd40 100644 --- a/tests/Feature/Livewire/DashboardTest.php +++ b/tests/Feature/Livewire/DashboardTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\Livewire; use App\Enums\ApprovalStatusEnum; +use App\Enums\PublishStatusEnum; use App\Livewire\Dashboard; use App\Models\Article; use App\Models\Feed; @@ -412,4 +413,72 @@ public function test_it_renders_an_empty_trend_for_an_invalid_range(): void ->assertSee('Fetched vs Published') ->assertDontSee('x-ref="canvas"', false); } + + private function publishAttempt(PublishStatusEnum $status, string $updatedAt): void + { + $routeArticle = RouteArticle::factory()->create(['publish_status' => $status]); + + // Model::update() would re-stamp updated_at to now(); bypass via the query builder. + RouteArticle::query() + ->whereKey($routeArticle->getKey()) + ->update(['updated_at' => Carbon::parse($updatedAt)]); + } + + public function test_it_renders_the_publish_success_rate_chart_on_mount(): void + { + Carbon::setTestNow('2026-07-15 13:45:00'); + + $this->publishAttempt(PublishStatusEnum::PUBLISHED, '2026-07-15 09:00:00'); + + Livewire::test(Dashboard::class) + ->assertSee('Publish Success Rate') + ->assertSee('trendChart(', false) + ->assertSee('x-ref="canvas"', false); + } + + public function test_it_refreshes_the_publish_success_rate_island_when_the_range_changes(): void + { + $this->publishAttempt(PublishStatusEnum::PUBLISHED, '2026-07-10 12:00:00'); + + $fragments = $this->islandFragments( + Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31') + ); + + $this->assertStringContainsString('name=publish-success-rate|', $fragments); + $this->assertStringContainsString('Publish Success Rate', $fragments); + $this->assertStringContainsString('2026-07-10', $fragments); + } + + public function test_it_charts_a_day_where_every_publish_failed(): void + { + $this->publishAttempt(PublishStatusEnum::ERROR, '2026-07-10 12:00:00'); + + $fragments = $this->islandFragments( + Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31') + ); + + $this->assertStringNotContainsString('No publish attempts in this range.', $fragments); + } + + public function test_it_reports_a_range_with_no_publish_attempts(): void + { + $this->publishAttempt(PublishStatusEnum::SKIPPED, '2026-07-10 12:00:00'); + + $fragments = $this->islandFragments( + Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31') + ); + + $this->assertStringContainsString('No publish attempts in this range.', $fragments); + } + + public function test_every_range_dependent_island_re_renders_on_a_range_change(): void + { + $fragments = $this->islandFragments( + Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31') + ); + + foreach (['articles-trend', 'approval-rate', 'publish-success-rate', 'articles-per-feed', 'publications-per-channel'] as $island) { + $this->assertStringContainsString("name={$island}|", $fragments); + } + } } diff --git a/tests/Unit/Dashboard/Stats/PublishSuccessRateTest.php b/tests/Unit/Dashboard/Stats/PublishSuccessRateTest.php new file mode 100644 index 00000000..cb6c594e --- /dev/null +++ b/tests/Unit/Dashboard/Stats/PublishSuccessRateTest.php @@ -0,0 +1,143 @@ + + */ + private function values(): array + { + $result = (new PublishSuccessRate)->for($this->range()); + + return array_combine($result->labels, $result->series[0]->values); + } + + private function attempt(PublishStatusEnum $status, string $updatedAt): RouteArticle + { + /** @var RouteArticle $article */ + $article = RouteArticle::factory()->create(['publish_status' => $status]); + + // Model::update() would re-stamp updated_at to now(); bypass via the query builder. + RouteArticle::query() + ->whereKey($article->getKey()) + ->update(['updated_at' => Carbon::parse($updatedAt)]); + + return $article; + } + + public function test_it_computes_the_published_share_of_daily_attempts(): void + { + $this->attempt(PublishStatusEnum::PUBLISHED, '2026-07-10 09:00:00'); + $this->attempt(PublishStatusEnum::PUBLISHED, '2026-07-10 10:00:00'); + $this->attempt(PublishStatusEnum::PUBLISHED, '2026-07-10 11:00:00'); + $this->attempt(PublishStatusEnum::ERROR, '2026-07-10 12:00:00'); + + $this->assertSame(75.0, $this->values()['2026-07-10']); + } + + public function test_it_excludes_skipped_articles_from_the_denominator(): void + { + $this->attempt(PublishStatusEnum::PUBLISHED, '2026-07-10 09:00:00'); + $this->attempt(PublishStatusEnum::SKIPPED, '2026-07-10 10:00:00'); + + $this->assertSame(100.0, $this->values()['2026-07-10']); + } + + public function test_it_excludes_articles_still_in_flight(): void + { + $this->attempt(PublishStatusEnum::PUBLISHED, '2026-07-10 09:00:00'); + $this->attempt(PublishStatusEnum::PUBLISHING, '2026-07-10 10:00:00'); + $this->attempt(PublishStatusEnum::UNPUBLISHED, '2026-07-10 11:00:00'); + + $this->assertSame(100.0, $this->values()['2026-07-10']); + } + + public function test_it_reports_no_rate_for_a_day_with_no_attempts(): void + { + $this->attempt(PublishStatusEnum::PUBLISHED, '2026-07-10 09:00:00'); + + $this->assertNull($this->values()['2026-07-11']); + } + + public function test_it_reports_a_zero_rate_when_every_attempt_failed(): void + { + $this->attempt(PublishStatusEnum::ERROR, '2026-07-10 09:00:00'); + + $this->assertSame(0.0, $this->values()['2026-07-10']); + } + + public function test_it_counts_an_article_on_the_day_it_was_last_touched(): void + { + $this->attempt(PublishStatusEnum::ERROR, '2026-07-10 09:00:00'); + $this->attempt(PublishStatusEnum::PUBLISHED, '2026-07-12 09:00:00'); + + $values = $this->values(); + + $this->assertSame(0.0, $values['2026-07-10']); + $this->assertSame(100.0, $values['2026-07-12']); + } + + public function test_a_retry_moves_the_article_to_the_retry_day(): void + { + $article = $this->attempt(PublishStatusEnum::ERROR, '2026-07-10 09:00:00'); + + RouteArticle::query() + ->whereKey($article->getKey()) + ->update([ + 'publish_status' => PublishStatusEnum::PUBLISHED, + 'updated_at' => Carbon::parse('2026-07-12 09:00:00'), + ]); + + $values = $this->values(); + + $this->assertNull($values['2026-07-10']); + $this->assertSame(100.0, $values['2026-07-12']); + } + + public function test_it_ignores_attempts_outside_the_range(): void + { + $this->attempt(PublishStatusEnum::PUBLISHED, '2026-06-30 23:00:00'); + $this->attempt(PublishStatusEnum::ERROR, '2026-08-01 01:00:00'); + + $this->assertSame([], array_filter($this->values(), fn (?float $value): bool => $value !== null)); + } + + public function test_it_rounds_the_rate_to_one_decimal(): void + { + $this->attempt(PublishStatusEnum::PUBLISHED, '2026-07-10 09:00:00'); + $this->attempt(PublishStatusEnum::PUBLISHED, '2026-07-10 10:00:00'); + $this->attempt(PublishStatusEnum::ERROR, '2026-07-10 11:00:00'); + + $this->assertSame(66.7, $this->values()['2026-07-10']); + } + + public function test_it_refuses_to_bucket_a_range_wider_than_the_day_limit(): void + { + $result = (new PublishSuccessRate)->for(new DateRange( + Carbon::parse('2020-01-01 00:00:00'), + Carbon::parse('2026-12-31 23:59:59'), + )); + + $this->assertTrue($result->isTooWide()); + } +} diff --git a/tests/Unit/Enums/PublishStatusEnumTest.php b/tests/Unit/Enums/PublishStatusEnumTest.php new file mode 100644 index 00000000..f5799240 --- /dev/null +++ b/tests/Unit/Enums/PublishStatusEnumTest.php @@ -0,0 +1,44 @@ +assertTrue(PublishStatusEnum::PUBLISHED->isSettled()); + $this->assertTrue(PublishStatusEnum::ERROR->isSettled()); + } + + public function test_skipped_is_not_a_publish_outcome(): void + { + $this->assertFalse(PublishStatusEnum::SKIPPED->isSettled()); + } + + public function test_in_flight_statuses_are_not_settled(): void + { + $this->assertFalse(PublishStatusEnum::UNPUBLISHED->isSettled()); + $this->assertFalse(PublishStatusEnum::PUBLISHING->isSettled()); + } + + public function test_settled_values_lists_every_settled_case(): void + { + $this->assertSame(['published', 'error'], PublishStatusEnum::settledValues()); + } + + public function test_settled_values_stays_in_step_with_the_cases(): void + { + $expected = array_values(array_map( + fn (PublishStatusEnum $status): string => $status->value, + array_filter( + PublishStatusEnum::cases(), + fn (PublishStatusEnum $status): bool => $status->isSettled(), + ), + )); + + $this->assertSame($expected, PublishStatusEnum::settledValues()); + } +}