83 - Add a publish success rate chart to the dashboard
Some checks failed
CI / ci (push) Failing after 12m22s
Some checks failed
CI / ci (push) Failing after 12m22s
This commit is contained in:
parent
cc45b74a63
commit
bf734feb4a
7 changed files with 353 additions and 0 deletions
53
app/Dashboard/Stats/PublishSuccessRate.php
Normal file
53
app/Dashboard/Stats/PublishSuccessRate.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
namespace App\Dashboard\Stats;
|
||||
|
||||
use App\Enums\PublishStatusEnum;
|
||||
use App\Models\RouteArticle;
|
||||
use App\Support\DateRange;
|
||||
|
||||
class PublishSuccessRate extends DailySeriesStat
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'publish-success-rate';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Publish Success Rate';
|
||||
}
|
||||
|
||||
protected function series(DateRange $range, array $days): SeriesResult
|
||||
{
|
||||
// Buckets follow updated_at, so a retry re-dates its article to the retry day.
|
||||
$attempts = RouteArticle::query()
|
||||
->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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<int, string>
|
||||
*/
|
||||
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()),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -213,6 +213,21 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
|
|||
@endisland
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Publish Success Rate</h2>
|
||||
|
||||
@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
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Articles per Feed</h2>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
143
tests/Unit/Dashboard/Stats/PublishSuccessRateTest.php
Normal file
143
tests/Unit/Dashboard/Stats/PublishSuccessRateTest.php
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit\Dashboard\Stats;
|
||||
|
||||
use App\Dashboard\Stats\PublishSuccessRate;
|
||||
use App\Enums\PublishStatusEnum;
|
||||
use App\Models\RouteArticle;
|
||||
use App\Support\DateRange;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PublishSuccessRateTest 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 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());
|
||||
}
|
||||
}
|
||||
44
tests/Unit/Enums/PublishStatusEnumTest.php
Normal file
44
tests/Unit/Enums/PublishStatusEnumTest.php
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit\Enums;
|
||||
|
||||
use App\Enums\PublishStatusEnum;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class PublishStatusEnumTest extends TestCase
|
||||
{
|
||||
public function test_published_and_error_are_settled(): void
|
||||
{
|
||||
$this->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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue