From f12639fb595cfc2734122b73110ea911070719bb Mon Sep 17 00:00:00 2001 From: Daan Meijer Date: Mon, 15 Jun 2026 23:37:44 +0200 Subject: [PATCH] feat: Implement read cursor activity tracking and recent activity dashboard with context --- app/Http/Controllers/DashboardController.php | 20 ++ app/Http/Controllers/DynamicController.php | 6 +- app/Http/Controllers/LedgerController.php | 5 +- app/Http/Middleware/HandleInertiaRequests.php | 8 + app/Models/ReadCursor.php | 31 ++ app/Models/User.php | 5 + app/Services/ActivityService.php | 233 +++++++++++++++ ...06_15_213346_create_read_cursors_table.php | 32 +++ database/seeders/DatabaseSeeder.php | 2 +- resources/js/components/AppHeader.vue | 28 ++ resources/js/pages/Dashboard.vue | 268 ++++++++++++++++-- routes/web.php | 3 +- tests/Feature/DashboardTest.php | 108 +++++++ 13 files changed, 721 insertions(+), 28 deletions(-) create mode 100644 app/Http/Controllers/DashboardController.php create mode 100644 app/Models/ReadCursor.php create mode 100644 app/Services/ActivityService.php create mode 100644 database/migrations/2026_06_15_213346_create_read_cursors_table.php diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php new file mode 100644 index 0000000..0138c0d --- /dev/null +++ b/app/Http/Controllers/DashboardController.php @@ -0,0 +1,20 @@ +user(); + $unreadEntities = $activityService->getUnreadEntitiesGrouped($user); + + return Inertia::render('Dashboard', [ + 'unreadEntities' => $unreadEntities, + ]); + } +} diff --git a/app/Http/Controllers/DynamicController.php b/app/Http/Controllers/DynamicController.php index 42bc2db..d3098eb 100644 --- a/app/Http/Controllers/DynamicController.php +++ b/app/Http/Controllers/DynamicController.php @@ -8,6 +8,8 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\Request; use Inertia\Inertia; +use App\Services\ActivityService; + class DynamicController extends Controller { use AuthorizesRequests; @@ -44,10 +46,12 @@ class DynamicController extends Controller /** * Display the specified resource. */ - public function show(Dynamic $dynamic) + public function show(Dynamic $dynamic, ActivityService $activityService) { $this->authorize('view', $dynamic); + $activityService->updateCursor(auth()->user(), $dynamic); + $dynamic->load([ 'ledgers.media', 'participants', diff --git a/app/Http/Controllers/LedgerController.php b/app/Http/Controllers/LedgerController.php index 60e8940..795e6ba 100644 --- a/app/Http/Controllers/LedgerController.php +++ b/app/Http/Controllers/LedgerController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use App\Http\Requests\StoreLedgerRequest; use App\Models\Dynamic; use App\Models\Ledger; +use App\Services\ActivityService; use Illuminate\Http\Request; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Inertia\Inertia; @@ -53,10 +54,12 @@ class LedgerController extends Controller /** * Display the specified resource. */ - public function show(Request $request, Dynamic $dynamic, Ledger $ledger) + public function show(Request $request, Dynamic $dynamic, Ledger $ledger, ActivityService $activityService) { $this->authorize('view', $ledger); + $activityService->updateCursor($request->user(), $ledger); + $dynamic->load('chat', 'participants'); $ledger->load([ diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index f4cc770..987e593 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -42,6 +42,14 @@ class HandleInertiaRequests extends Middleware 'user' => $request->user(), ], 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', + 'unreadNotificationsCount' => function () use ($request) { + if (! $request->user()) { + return 0; + } + + $service = app(\App\Services\ActivityService::class); + return count($service->getUnreadEntitiesGrouped($request->user())); + }, ]; } } diff --git a/app/Models/ReadCursor.php b/app/Models/ReadCursor.php new file mode 100644 index 0000000..3be25e2 --- /dev/null +++ b/app/Models/ReadCursor.php @@ -0,0 +1,31 @@ + 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function cursorable(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 8985431..b507c99 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -44,6 +44,11 @@ class User extends Authenticatable implements PasskeyUser return $this->hasMany(Mutation::class); } + public function readCursors() + { + return $this->hasMany(ReadCursor::class); + } + /** * Get the attributes that should be cast. * diff --git a/app/Services/ActivityService.php b/app/Services/ActivityService.php new file mode 100644 index 0000000..8ecbea1 --- /dev/null +++ b/app/Services/ActivityService.php @@ -0,0 +1,233 @@ + $user->id, + 'cursorable_id' => $entity->id, + 'cursorable_type' => get_class($entity), + ], + [ + 'read_at' => Carbon::now(), + ] + ); + } + + /** + * Get the read cursor timestamp for a user on a specific entity. + */ + public function getCursorReadAt(User $user, $entity): \Carbon\CarbonInterface + { + $cursor = ReadCursor::where([ + 'user_id' => $user->id, + 'cursorable_id' => $entity->id, + 'cursorable_type' => get_class($entity), + ])->first(); + + // If no cursor exists, default to epoch (all activities are unread) + return $cursor ? $cursor->read_at : Carbon::parse('1970-01-01'); + } + + /** + * Retrieve all activities for a Dynamic. + */ + public function getDynamicActivities(Dynamic $dynamic): array + { + $activities = []; + + // 1. Chat Messages + if ($dynamic->chat) { + $messages = Message::where('chat_id', $dynamic->chat->id) + ->with('user') + ->get(); + + foreach ($messages as $msg) { + $activities[] = [ + 'id' => "dynamic_msg_{$msg->id}", + 'type' => 'message', + 'description' => $msg->content, + 'user' => [ + 'name' => $msg->user ? $msg->user->name : 'Unknown User', + ], + 'created_at' => $msg->created_at, + ]; + } + } + + // 2. Ledgers Created + $ledgers = Ledger::where('dynamic_id', $dynamic->id)->get(); + foreach ($ledgers as $ledger) { + $activities[] = [ + 'id' => "ledger_created_{$ledger->id}", + 'type' => 'ledger_created', + 'description' => "New Ledger '{$ledger->name}' was created.", + 'user' => [ + 'name' => 'System', + ], + 'created_at' => $ledger->created_at, + ]; + } + + // Sort activities chronologically ascending + usort($activities, fn($a, $b) => $a['created_at']->timestamp <=> $b['created_at']->timestamp); + + return $activities; + } + + /** + * Retrieve all activities for a Ledger. + */ + public function getLedgerActivities(Ledger $ledger): array + { + $activities = []; + + // 1. Mutations (Creation and Status updates) + $mutations = Mutation::where('ledger_id', $ledger->id) + ->with('user') + ->get(); + + foreach ($mutations as $mutation) { + // Log creation of mutation + $verb = $mutation->type === 'penalty' ? 'issued demerit' : ($mutation->type === 'reward' ? 'credited points' : 'submitted entry'); + $activities[] = [ + 'id' => "mutation_created_{$mutation->id}", + 'type' => 'mutation_created', + 'description' => "{$verb} ({$mutation->amount}): \"{$mutation->description}\"", + 'user' => [ + 'name' => $mutation->user ? $mutation->user->name : 'Unknown User', + ], + 'created_at' => $mutation->created_at, + ]; + + // Log status approval/rejection update if different from creation + if ($mutation->status !== 'pending' && $mutation->updated_at->gt($mutation->created_at->addSeconds(2))) { + $activities[] = [ + 'id' => "mutation_updated_{$mutation->id}", + 'type' => 'mutation_updated', + 'description' => "Entry '{$mutation->description}' was " . strtoupper($mutation->status), + 'user' => [ + 'name' => 'System', + ], + 'created_at' => $mutation->updated_at, + ]; + } + } + + // 2. Mutation Comments + if ($mutations->isNotEmpty()) { + $comments = Message::whereHas('chat', function ($q) use ($mutations) { + $q->where('chatable_type', Mutation::class) + ->whereIn('chatable_id', $mutations->pluck('id')); + }) + ->with(['user', 'chat.chatable']) + ->get(); + + foreach ($comments as $comment) { + /** @var Mutation|null $mutationEntity */ + $mutationEntity = $comment->chat->chatable; + $context = $mutationEntity ? " on \"{$mutationEntity->description}\"" : ""; + + $activities[] = [ + 'id' => "comment_{$comment->id}", + 'type' => 'comment', + 'description' => "Commented{$context}: \"{$comment->content}\"", + 'user' => [ + 'name' => $comment->user ? $comment->user->name : 'Unknown User', + ], + 'created_at' => $comment->created_at, + ]; + } + } + + // Sort activities chronologically ascending + usort($activities, fn($a, $b) => $a['created_at']->timestamp <=> $b['created_at']->timestamp); + + return $activities; + } + + /** + * Get unread activities grouped by active entities (Dynamics, Ledgers) for the given user. + */ + public function getUnreadEntitiesGrouped(User $user): array + { + $groupedEntities = []; + + // 1. Get all participating Dynamics + $dynamics = $user->dynamics()->get(); + + foreach ($dynamics as $dynamic) { + $readAt = $this->getCursorReadAt($user, $dynamic); + $activities = $this->getDynamicActivities($dynamic); + + $this->partitionActivities($activities, $readAt, $dynamic, 'Dynamic', route('dynamics.show', $dynamic->id), $groupedEntities); + } + + // 2. Get all Ledgers under those Dynamics + if ($dynamics->isNotEmpty()) { + $ledgers = Ledger::whereIn('dynamic_id', $dynamics->pluck('id'))->get(); + + foreach ($ledgers as $ledger) { + $readAt = $this->getCursorReadAt($user, $ledger); + $activities = $this->getLedgerActivities($ledger); + + $this->partitionActivities($activities, $readAt, $ledger, 'Ledger', route('dynamics.ledgers.show', [$ledger->dynamic_id, $ledger->id]), $groupedEntities); + } + } + + return $groupedEntities; + } + + /** + * Partition activities into read and unread, and construct the grouped entity metadata. + */ + private function partitionActivities(array $activities, \Carbon\CarbonInterface $readAt, $entity, string $type, string $url, array &$groupedEntities): void + { + $alreadyRead = []; + $unread = []; + + foreach ($activities as $act) { + if ($act['created_at']->gt($readAt)) { + $unread[] = $act; + } else { + $alreadyRead[] = $act; + } + } + + if (!empty($unread)) { + // We have unread activity! Let's pull the last two read items as context + $context = array_slice($alreadyRead, -2); + + // Format timestamps for serializing to frontend + $formatActivity = function ($act) { + $act['created_at'] = $act['created_at']->toIso8601String(); + return $act; + }; + + $groupedEntities[] = [ + 'id' => $entity->id, + 'name' => $entity->name, + 'type' => $type, + 'url' => $url, + 'unread_count' => count($unread), + 'context_activities' => array_map($formatActivity, $context), + 'new_activities' => array_map($formatActivity, $unread), + ]; + } + } +} \ No newline at end of file diff --git a/database/migrations/2026_06_15_213346_create_read_cursors_table.php b/database/migrations/2026_06_15_213346_create_read_cursors_table.php new file mode 100644 index 0000000..a058243 --- /dev/null +++ b/database/migrations/2026_06_15_213346_create_read_cursors_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->morphs('cursorable'); + $table->timestamp('read_at'); + $table->timestamps(); + + $table->unique(['user_id', 'cursorable_id', 'cursorable_type']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('read_cursors'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 7de9997..2683bea 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -108,7 +108,7 @@ class DatabaseSeeder extends Seeder $etiquetteLedger = Ledger::create([ 'dynamic_id' => $velvetSanctuary->id, 'name' => 'Protocol & Etiquette', - 'rules' => 'Address others by correct titles. -5 per infraction.', + 'rules' => 'Address others by correct titles. +5 per infraction.', 'score' => 15, 'alignment' => 'negative', ]); diff --git a/resources/js/components/AppHeader.vue b/resources/js/components/AppHeader.vue index ea0df0e..10cdf62 100644 --- a/resources/js/components/AppHeader.vue +++ b/resources/js/components/AppHeader.vue @@ -238,6 +238,34 @@ const rightNavItems: NavItem[] = [ +
+ + + + + + {{ page.props.unreadNotificationsCount }} + + +
+