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
10 changed files with 82 additions and 39 deletions
Showing only changes of commit a8d96554a7 - Show all commits

View file

@ -22,9 +22,4 @@ public function shareOf(Breakdown $row): float
return $total > 0 ? round(($row->count / $total) * 100, 1) : 0.0; return $total > 0 ? round(($row->count / $total) * 100, 1) : 0.0;
} }
public function isEmpty(): bool
{
return $this->total() === 0;
}
} }

View file

@ -5,7 +5,7 @@
interface Stat interface Stat
{ {
/** /**
* Stable identifier, also used as the island name for this stat's panel. * Stable identifier. Island names in dashboard.blade.php are written to match by hand, not derived from this.
*/ */
public function key(): string; public function key(): string;

View file

@ -20,8 +20,8 @@ public function __construct(
public function stats(Request $request): JsonResponse public function stats(Request $request): JsonResponse
{ {
$validated = $request->validate([ $validated = $request->validate([
'from' => ['nullable', 'date'], 'from' => ['nullable', 'date', 'required_with:to'],
'to' => ['nullable', 'date', 'after_or_equal:from'], 'to' => ['nullable', 'date', 'after_or_equal:from', 'required_with:from'],
]); ]);
$range = isset($validated['from'], $validated['to']) $range = isset($validated['from'], $validated['to'])

View file

@ -4,12 +4,9 @@
use App\Enums\ActivityTypeEnum; use App\Enums\ActivityTypeEnum;
use App\Models\ActivityLog; use App\Models\ActivityLog;
use App\Models\Article;
use App\Services\Activity\ActivitySummary; use App\Services\Activity\ActivitySummary;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\Relation;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
@ -37,11 +34,7 @@ public function updatedDays(): void
private function query(): Builder private function query(): Builder
{ {
return ActivityLog::query() return ActivityLog::query()
->with(['subject' => function (Relation $morphTo): void { ->withSubjectDetails()
if ($morphTo instanceof MorphTo) {
$morphTo->morphWith([Article::class => ['feed']]);
}
}])
->since(now()->subDays($this->days)) ->since(now()->subDays($this->days))
->when( ->when(
$this->type !== '', $this->type !== '',

View file

@ -11,13 +11,10 @@
use App\Dashboard\Stats\PublishSuccessRate; use App\Dashboard\Stats\PublishSuccessRate;
use App\Dashboard\Stats\SeriesResult; use App\Dashboard\Stats\SeriesResult;
use App\Models\ActivityLog; use App\Models\ActivityLog;
use App\Models\Article;
use App\Services\DashboardStatsService; use App\Services\DashboardStatsService;
use App\Support\DateRange; use App\Support\DateRange;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use InvalidArgumentException; use InvalidArgumentException;
use Livewire\Attributes\Computed; use Livewire\Attributes\Computed;
@ -195,11 +192,7 @@ private function breakdown(string $stat): BreakdownResult
public function recentActivity(): Collection public function recentActivity(): Collection
{ {
return ActivityLog::query() return ActivityLog::query()
->with(['subject' => function (Relation $morphTo): void { ->withSubjectDetails()
if ($morphTo instanceof MorphTo) {
$morphTo->morphWith([Article::class => ['feed']]);
}
}])
->latestFirst() ->latestFirst()
->limit(self::RECENT_ACTIVITY_LIMIT) ->limit(self::RECENT_ACTIVITY_LIMIT)
->get(); ->get();

View file

@ -8,6 +8,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
/** /**
@ -77,4 +78,17 @@ public function scopeLatestFirst(Builder $query): Builder
{ {
return $query->orderByDesc('logged_at')->orderByDesc('id'); return $query->orderByDesc('logged_at')->orderByDesc('id');
} }
/**
* @param Builder<ActivityLog> $query
* @return Builder<ActivityLog>
*/
public function scopeWithSubjectDetails(Builder $query): Builder
{
return $query->with(['subject' => function (Relation $morphTo): void {
if ($morphTo instanceof MorphTo) {
$morphTo->morphWith([Article::class => ['feed']]);
}
}]);
}
} }

View file

@ -99,6 +99,22 @@ public function test_stats_rejects_a_malformed_date(): void
->assertStatus(422); ->assertStatus(422);
} }
public function test_stats_rejects_a_from_without_a_to(): void
{
$this
->getJson('/api/v1/dashboard/stats?from=2026-07-01')
->assertStatus(422)
->assertJsonValidationErrors('to');
}
public function test_stats_rejects_a_to_without_a_from(): void
{
$this
->getJson('/api/v1/dashboard/stats?to=2026-07-31')
->assertStatus(422)
->assertJsonValidationErrors('from');
}
public function test_stats_with_sample_data(): void public function test_stats_with_sample_data(): void
{ {
$feed = Feed::factory()->create(['is_active' => true]); $feed = Feed::factory()->create(['is_active' => true]);

View file

@ -13,6 +13,7 @@
use App\Models\RouteArticle; use App\Models\RouteArticle;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Livewire\Features\SupportTesting\Testable; use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire; use Livewire\Livewire;
use Tests\TestCase; use Tests\TestCase;
@ -552,6 +553,32 @@ public function test_it_renders_an_activity_entry_whose_subject_was_deleted(): v
Livewire::test(Dashboard::class)->assertSee('Orphaned entry'); Livewire::test(Dashboard::class)->assertSee('Orphaned entry');
} }
private function dashboardQueryCount(int $activityEntries): int
{
$feed = Feed::factory()->create();
foreach (range(1, $activityEntries) as $index) {
$article = Article::factory()->create(['feed_id' => $feed->id]);
ActivityLog::factory()->forSubject($article)->create(['message' => "Entry {$index}"]);
}
DB::flushQueryLog();
DB::enableQueryLog();
Livewire::test(Dashboard::class);
$queries = count(DB::getQueryLog());
DB::disableQueryLog();
return $queries;
}
public function test_it_does_not_fan_out_queries_per_activity_entry(): void
{
$this->assertSame(
$this->dashboardQueryCount(1),
$this->dashboardQueryCount(20),
);
}
public function test_every_range_dependent_island_re_renders_on_a_range_change(): void public function test_every_range_dependent_island_re_renders_on_a_range_change(): void
{ {
$fragments = $this->islandFragments( $fragments = $this->islandFragments(

View file

@ -40,19 +40,4 @@ public function test_it_reports_a_zero_share_when_nothing_was_counted(): void
$this->assertSame(0.0, $result->shareOf($result->rows[0])); $this->assertSame(0.0, $result->shareOf($result->rows[0]));
} }
public function test_it_has_no_data_without_rows(): void
{
$this->assertTrue((new BreakdownResult([]))->isEmpty());
}
public function test_it_has_no_data_when_every_row_is_zero(): void
{
$this->assertTrue((new BreakdownResult([new Breakdown('A', 0)]))->isEmpty());
}
public function test_it_has_data_when_any_row_is_counted(): void
{
$this->assertFalse((new BreakdownResult([new Breakdown('A', 1)]))->isEmpty());
}
} }

View file

@ -67,6 +67,26 @@ public function test_re_approving_does_not_move_the_decision_time(): void
); );
} }
public function test_re_rejecting_does_not_move_the_decision_time(): void
{
/** @var RouteArticle $routeArticle */
$routeArticle = RouteArticle::factory()->create([
'approval_status' => ApprovalStatusEnum::PENDING,
'decided_at' => null,
]);
$routeArticle->reject();
$first = $routeArticle->fresh()->decided_at;
$this->travel(1)->hours();
$routeArticle->fresh()->reject();
$this->assertSame(
$first->format('Y-m-d H:i:s'),
$routeArticle->fresh()->decided_at->format('Y-m-d H:i:s')
);
}
public function test_rejecting_an_approved_article_moves_the_decision_time(): void public function test_rejecting_an_approved_article_moves_the_decision_time(): void
{ {
/** @var RouteArticle $routeArticle */ /** @var RouteArticle $routeArticle */