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
7 changed files with 353 additions and 0 deletions
Showing only changes of commit bf734feb4a - Show all commits

View 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),
]);
}
}

View file

@ -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()),
));
}
}

View file

@ -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
{

View file

@ -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>

View file

@ -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);
}
}
}

View 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());
}
}

View 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());
}
}