diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index 2e369d16..778fc2c8 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -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 + */ + #[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 */ diff --git a/resources/views/livewire/activity.blade.php b/resources/views/livewire/activity.blade.php index 185f281c..db0c94b4 100644 --- a/resources/views/livewire/activity.blade.php +++ b/resources/views/livewire/activity.blade.php @@ -37,29 +37,7 @@ class="w-44 shrink-0 pl-3 pr-10 py-2 border border-gray-300 rounded-md text-sm f
@forelse ($entries as $entry) -
- - {{ $entry->type->label() }} - - -
-

{{ $entry->message }}

- @if ($entry->subject instanceof App\Models\Article) -

{{ $entry->subject->feed?->name }}

- @endif - @if (($entry->context['reason'] ?? null) !== null) -

{{ $entry->context['reason'] }}

- @endif -
- - -
+ @include('livewire.partials.activity-entry', ['entry' => $entry]) @empty

No activity in this period.

diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 09ef0747..5e2b9946 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -251,5 +251,17 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin @endisland
+ +
+
+

Recent Activity

+ + + View all + +
+ + @include('livewire.partials.activity-panel', ['entries' => $this->recentActivity]) +
diff --git a/resources/views/livewire/partials/activity-entry.blade.php b/resources/views/livewire/partials/activity-entry.blade.php new file mode 100644 index 00000000..b8b97404 --- /dev/null +++ b/resources/views/livewire/partials/activity-entry.blade.php @@ -0,0 +1,23 @@ +
+ + {{ $entry->type->label() }} + + +
+

{{ $entry->message }}

+ @if ($entry->subject instanceof App\Models\Article) +

{{ $entry->subject->feed?->name }}

+ @endif + @if (($entry->context['reason'] ?? null) !== null) +

{{ $entry->context['reason'] }}

+ @endif +
+ + +
diff --git a/resources/views/livewire/partials/activity-panel.blade.php b/resources/views/livewire/partials/activity-panel.blade.php new file mode 100644 index 00000000..20c5956b --- /dev/null +++ b/resources/views/livewire/partials/activity-panel.blade.php @@ -0,0 +1,9 @@ +
+ @forelse ($entries as $entry) + @include('livewire.partials.activity-entry', ['entry' => $entry]) + @empty +
+

Nothing has happened yet.

+
+ @endforelse +
diff --git a/tests/Feature/Livewire/DashboardTest.php b/tests/Feature/Livewire/DashboardTest.php index f781dd40..539c48b8 100644 --- a/tests/Feature/Livewire/DashboardTest.php +++ b/tests/Feature/Livewire/DashboardTest.php @@ -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(