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
6 changed files with 152 additions and 23 deletions
Showing only changes of commit 42fab63eaf - Show all commits

View file

@ -10,9 +10,14 @@
use App\Dashboard\Stats\PublicationsPerChannel;
use App\Dashboard\Stats\PublishSuccessRate;
use App\Dashboard\Stats\SeriesResult;
use App\Models\ActivityLog;
use App\Models\Article;
use App\Services\DashboardStatsService;
use App\Support\DateRange;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Carbon;
use InvalidArgumentException;
use Livewire\Attributes\Computed;
@ -43,6 +48,8 @@ public function mount(): void
'publications-per-channel',
];
private const RECENT_ACTIVITY_LIMIT = 5;
public function applyPreset(string $preset): void
{
try {
@ -179,6 +186,25 @@ private function breakdown(string $stat): BreakdownResult
: new BreakdownResult([]);
}
/**
* Deliberately range-independent: "recent" means latest, not latest within the filter.
*
* @return Collection<int, ActivityLog>
*/
#[Computed]
public function recentActivity(): Collection
{
return ActivityLog::query()
->with(['subject' => function (Relation $morphTo): void {
if ($morphTo instanceof MorphTo) {
$morphTo->morphWith([Article::class => ['feed']]);
}
}])
->latestFirst()
->limit(self::RECENT_ACTIVITY_LIMIT)
->get();
}
/**
* @return array<string, int>
*/

View file

@ -37,29 +37,7 @@ class="w-44 shrink-0 pl-3 pr-10 py-2 border border-gray-300 rounded-md text-sm f
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden">
@forelse ($entries as $entry)
<div class="flex items-start gap-x-3 px-5 py-4 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
<span class="mt-0.5 inline-flex shrink-0 items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $entry->type === App\Enums\ActivityTypeEnum::ERROR ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300' : 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' }}">
{{ $entry->type->label() }}
</span>
<div class="min-w-0 flex-1">
<p class="text-sm text-gray-900 dark:text-gray-100">{{ $entry->message }}</p>
@if ($entry->subject instanceof App\Models\Article)
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{{ $entry->subject->feed?->name }}</p>
@endif
@if (($entry->context['reason'] ?? null) !== null)
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{{ $entry->context['reason'] }}</p>
@endif
</div>
<time
datetime="{{ $entry->logged_at->toIso8601String() }}"
title="{{ $entry->logged_at->toDayDateTimeString() }}"
class="shrink-0 text-xs text-gray-500 dark:text-gray-400"
>
{{ $entry->logged_at->diffForHumans() }}
</time>
</div>
@include('livewire.partials.activity-entry', ['entry' => $entry])
@empty
<div class="px-5 py-12 text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">No activity in this period.</p>

View file

@ -251,5 +251,17 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
@endisland
</div>
</div>
<div>
<div class="mb-4 flex items-baseline justify-between gap-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Recent Activity</h2>
<a href="{{ route('activity') }}" wire:navigate class="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400">
View all
</a>
</div>
@include('livewire.partials.activity-panel', ['entries' => $this->recentActivity])
</div>
</div>
</div>

View file

@ -0,0 +1,23 @@
<div class="flex items-start gap-x-3 px-5 py-4 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
<span class="mt-0.5 inline-flex shrink-0 items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $entry->type === App\Enums\ActivityTypeEnum::ERROR ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300' : 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' }}">
{{ $entry->type->label() }}
</span>
<div class="min-w-0 flex-1">
<p class="text-sm text-gray-900 dark:text-gray-100">{{ $entry->message }}</p>
@if ($entry->subject instanceof App\Models\Article)
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{{ $entry->subject->feed?->name }}</p>
@endif
@if (($entry->context['reason'] ?? null) !== null)
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{{ $entry->context['reason'] }}</p>
@endif
</div>
<time
datetime="{{ $entry->logged_at->toIso8601String() }}"
title="{{ $entry->logged_at->toDayDateTimeString() }}"
class="shrink-0 text-xs text-gray-500 dark:text-gray-400"
>
{{ $entry->logged_at->diffForHumans() }}
</time>
</div>

View file

@ -0,0 +1,9 @@
<div class="bg-white rounded-lg shadow-sm overflow-hidden dark:bg-gray-800">
@forelse ($entries as $entry)
@include('livewire.partials.activity-entry', ['entry' => $entry])
@empty
<div class="px-5 py-12 text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Nothing has happened yet.</p>
</div>
@endforelse
</div>

View file

@ -2,9 +2,11 @@
namespace Tests\Feature\Livewire;
use App\Enums\ActivityTypeEnum;
use App\Enums\ApprovalStatusEnum;
use App\Enums\PublishStatusEnum;
use App\Livewire\Dashboard;
use App\Models\ActivityLog;
use App\Models\Article;
use App\Models\Feed;
use App\Models\PlatformChannel;
@ -471,6 +473,85 @@ public function test_it_reports_a_range_with_no_publish_attempts(): void
$this->assertStringContainsString('No publish attempts in this range.', $fragments);
}
public function test_it_lists_the_most_recent_activity(): void
{
ActivityLog::factory()->type(ActivityTypeEnum::PUBLISH)->loggedAt(Carbon::parse('2026-07-10 09:00:00'))->create(['message' => 'Older entry']);
ActivityLog::factory()->type(ActivityTypeEnum::ERROR)->loggedAt(Carbon::parse('2026-07-12 09:00:00'))->create(['message' => 'Newer entry']);
Livewire::test(Dashboard::class)
->assertSee('Recent Activity')
->assertSee('Newer entry')
->assertSee('Older entry')
->assertSeeInOrder(['Newer entry', 'Older entry']);
}
public function test_it_caps_the_activity_panel_at_five_entries(): void
{
foreach (range(1, 8) as $index) {
ActivityLog::factory()
->loggedAt(Carbon::parse('2026-07-10 09:00:00')->addMinutes($index))
->create(['message' => "Entry {$index}"]);
}
$component = Livewire::test(Dashboard::class);
$component->assertSee('Entry 8')->assertSee('Entry 4');
$component->assertDontSee('Entry 3');
}
public function test_the_activity_panel_ignores_the_global_range(): void
{
ActivityLog::factory()
->loggedAt(Carbon::parse('2026-07-10 09:00:00'))
->create(['message' => 'Activity outside the range']);
Livewire::test(Dashboard::class)
->call('applyRange', '2026-08-01', '2026-08-31')
->assertSee('Activity outside the range');
}
public function test_it_reports_an_empty_activity_panel(): void
{
Livewire::test(Dashboard::class)->assertSee('Nothing has happened yet.');
}
public function test_it_links_the_activity_panel_to_the_activity_page(): void
{
Livewire::test(Dashboard::class)->assertSee(route('activity'), false);
}
public function test_it_shows_the_feed_name_for_an_article_subject(): void
{
$feed = Feed::factory()->create(['name' => 'Belga News']);
$article = Article::factory()->create(['feed_id' => $feed->id]);
ActivityLog::factory()
->forSubject($article)
->create(['message' => 'Fetched something']);
Livewire::test(Dashboard::class)
->assertSee('Fetched something')
->assertSee('Belga News');
}
public function test_it_renders_an_activity_entry_without_a_subject(): void
{
ActivityLog::factory()->create(['message' => 'Subjectless entry']);
Livewire::test(Dashboard::class)->assertSee('Subjectless entry');
}
public function test_it_renders_an_activity_entry_whose_subject_was_deleted(): void
{
$article = Article::factory()->create();
ActivityLog::factory()->forSubject($article)->create(['message' => 'Orphaned entry']);
$article->delete();
Livewire::test(Dashboard::class)->assertSee('Orphaned entry');
}
public function test_every_range_dependent_island_re_renders_on_a_range_change(): void
{
$fragments = $this->islandFragments(