Files
L-Ami-Fiduciaire/_bmad-output/implementation-artifacts/2-4-dashboard-activity-feed.md
Saad Zoubir a02b5f12d8 feat: implement Story 2.4 — Dashboard Activity Feed with review fixes
Add role-scoped activity feed to the dashboard showing the 20 most recent
workspace events. Owners/Managers see all activity (declarations, clients,
team changes); Workers see only their assigned declarations. Includes
French descriptions, relative timestamps, responsive layout (desktop
sidebar, tablet inline, mobile collapsible), and 7 passing Pest tests.

Review fixes applied: batch-load declarations/clients/users to eliminate
N+1 queries, consistent soft-delete handling in URL resolution, French
grammar singular/plural fix, missing icon map entry, and corrected tablet
breakpoint per spec.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 21:21:07 +01:00

22 KiB

Story 2.4: Dashboard Activity Feed

Status: review

Story

As a firm owner or manager, I want to see a stream of recent workspace activity on my dashboard, So that I can stay aware of team actions, client uploads, and status changes without checking individual declarations.

Acceptance Criteria

  1. Given the Owner/Manager dashboard is loaded, When the activity feed panel renders, Then the feed shows the 20 most recent workspace events in reverse chronological order

  2. Given the activity feed is displayed, Then each entry displays: actor avatar/initials, action description, target entity link, and relative timestamp ("il y a 2 heures")

  3. Given the activity feed is displayed, Then event types include: declaration status changes, document uploads by clients, declaration reassignments, team member role changes, new declarations created

  4. Given a user clicks an event in the feed, Then it navigates to the relevant entity (declaration detail, client page, team page)

  5. Given a Worker views the dashboard, When the activity feed renders, Then the feed shows only activity related to the Worker's assigned declarations (not the entire workspace)

  6. Given the dashboard is loaded, Then the activity feed data is included in the Inertia page props (server-rendered, no separate API call)

  7. Given a mobile viewport (<768px), Then the activity feed is accessible via an expandable section (not hidden entirely)

  8. Given there is no recent activity, Then a neutral message is shown: "Aucune activite recente"

Tasks / Subtasks

  • Task 1: Build activity feed query in DashboardController (AC: #1, #3, #5, #6)

    • 1.1 Add buildActivityFeed() private method that queries Spatie\Activitylog\Models\Activity for the current workspace, limited to 20, ordered by created_at desc
    • 1.2 For Workers, filter activities to only those where subject_type is Declaration AND the declaration is assigned to the Worker (join/subquery on declarations.assigned_to)
    • 1.3 For Owners/Managers, return all workspace activities (no subject restriction)
    • 1.4 Map each activity record to a frontend-friendly array: id, actorName, actorInitials, description (human-readable French), targetUrl, targetLabel, timestamp (ISO), eventType (icon hint)
    • 1.5 Include the activity feed in the cached dashboard data (same Cache::remember() block with existing 5-minute TTL)
    • 1.6 Add activities key to the Inertia props alongside existing stats, statCards, declarations, alerts
  • Task 2: Build human-readable French descriptions for activity events (AC: #2, #3)

    • 2.1 Create a formatActivityDescription() private method that maps Spatie activity events to French descriptions:
      • created on Declaration → "{actorName} a cree la declaration {typeLabel} pour {clientName}"
      • updated on Declaration with status change → "{actorName} a change le statut de {typeLabel} ({clientName}) en {newStatus}"
      • updated on Declaration with assigned_to change → "{actorName} a reassigne {typeLabel} ({clientName}) a {newAssignee}"
      • created/updated on Client → "{actorName} a modifie le client {clientName}"
      • updated on WorkspaceUser (role change) → "{actorName} a change le role de {targetUser}"
      • Custom log "Switched workspace" → skip (not relevant to feed)
      • Fallback: "{actorName} a modifie {subjectType}"
    • 2.2 Extract properties.old and properties.attributes from the Spatie activity record to build contextual descriptions (e.g., old status → new status)
    • 2.3 Use DeclarationStatus::labels() and typeLabels() to display French labels in descriptions
  • Task 3: Build targetUrl resolution for clickable events (AC: #4)

    • 3.1 For Declaration subjects → route('declarations.show', $subjectId)
    • 3.2 For Client subjects → route('clients.show', $subjectId)
    • 3.3 For WorkspaceUser/TeamInvitation subjects → route('team.index')
    • 3.4 For unknown/deleted subjects → null (no link, graceful degradation)
    • 3.5 Handle soft-deleted or missing subjects without errors (check subject_id existence)
  • Task 4: Create ActivityFeed.vue component (AC: #1, #2, #4, #7, #8)

    • 4.1 Create resources/js/components/dashboard/ActivityFeed.vue as a reusable component accepting activities prop
    • 4.2 Each entry renders: actor initials (colored circle), description text, relative timestamp using French locale ("il y a 2 heures", "hier", "il y a 3 jours")
    • 4.3 Clickable entries use router.get(activity.targetUrl) for navigation (skip if targetUrl is null)
    • 4.4 Empty state: show "Aucune activite recente" with a subtle icon when activities.length === 0
    • 4.5 Use shadcn-vue Card for the container with a "Activite recente" heading
  • Task 5: Integrate ActivityFeed into Dashboard.vue layout (AC: #1, #7)

    • 5.1 On desktop (>=1024px): render the activity feed as a right sidebar panel using a two-column CSS Grid layout (main content takes ~2/3, feed takes ~1/3)
    • 5.2 On tablet (768-1023px): render the activity feed below the urgent declarations table
    • 5.3 On mobile (<768px): render the activity feed inside a collapsible/expandable section (use shadcn-vue Collapsible or a toggle button) — NOT hidden
    • 5.4 Add ActivityEvent type to resources/js/types/dashboard.ts and extend DashboardProps with activities: ActivityEvent[]
  • Task 6: Implement relative timestamp formatting (AC: #2)

    • 6.1 Create a useRelativeTime composable or inline helper that formats ISO timestamps as French relative strings: "a l'instant" (<1min), "il y a X minutes" (<1h), "il y a X heures" (<24h), "hier" (yesterday), "il y a X jours" (<7d), then DD/MM/YYYY for older
    • 6.2 Use this formatter in the ActivityFeed component for all timestamps
  • Task 7: Write Pest feature tests (AC: #1, #3, #5, #6, #8)

    • 7.1 Test Owner sees workspace-wide activity in activities Inertia prop
    • 7.2 Test Worker sees only activity related to their assigned declarations
    • 7.3 Test activity entries contain expected fields (actorName, description, targetUrl, timestamp, eventType)
    • 7.4 Test feed is limited to 20 most recent events
    • 7.5 Test empty activity returns empty array (no errors)
    • 7.6 Test activity with deleted subject does not cause errors
    • 7.7 Test Manager sees workspace-wide activity (same as Owner)

Dev Notes

Architecture Patterns & Constraints

  • Workspace resolution: Always from session current_workspace_id, NEVER from URL params
  • Authorization: Use abort(404) not abort(403) for workspace boundary violations
  • Role-scoped queries: Use existing Declaration::forUser($user, $workspaceUser) pattern — Workers see only assigned, Owners/Managers see all
  • Data shaping: Manually build arrays in controller, NO API Resources
  • URLs as props: ALL frontend URLs must come from controller via route() helper — never hardcode routes in Vue
  • Wayfinder routes: All URLs in Vue MUST use Wayfinder type-safe routes. Check existing Dashboard.vue imports for pattern.
  • Cache pattern: Same Cache::remember() with key dashboard:{workspace_id}:{user_id} — already user-specific
  • No separate API call: Activity feed MUST be included in Inertia page props (server-rendered), NOT fetched via a separate AJAX/API endpoint

CRITICAL: Spatie Activity Log Infrastructure Already Exists

The Spatie Activity Log is fully integrated and actively logging. Do NOT create any new tables, models, or log configuration.

Existing infrastructure:

  • 7 models already use LogsActivity trait: User, Workspace, TeamInvitation, Client, ClientContact, Declaration, Folder
  • 3 migrations already exist: create_activity_log_table, add_event_column, add_batch_uuid_column
  • Table: activity_log with columns: id, log_name, description, subject_type, subject_id, causer_type, causer_id, properties (JSON with old/attributes keys), event (created/updated/deleted), batch_uuid, created_at, updated_at
  • Config: config/activitylog.php — uses default Spatie\Activitylog\Models\Activity model, table activity_log, 365-day cleanup
  • All models use identical config: LogOptions::defaults()->logFillable()->logOnlyDirty()->dontSubmitEmptyLogs()
  • Manual logging already done in: WorkspaceSwitchController (workspace switches), TeamController (role changes, permission updates, member removals)

How activities are stored:

  • Automatic: Every model create/update/delete is logged by the trait with event = created/updated/deleted, properties = {"old": {...}, "attributes": {...}}
  • Manual: Controllers call activity()->causedBy($user)->performedOn($model)->withProperties([...])->log('description')
  • causer_id = the user who performed the action
  • subject_id/subject_type = the model that was acted upon

CRITICAL: Querying Activity Log for the Dashboard

Workspace scoping challenge: The activity_log table does NOT have a workspace_id column. To scope activities to the current workspace:

Option A (recommended): Query activities where the subject belongs to the workspace:

use Spatie\Activitylog\Models\Activity;

$activities = Activity::query()
    ->where(function ($query) use ($workspace) {
        // Declarations belonging to this workspace
        $query->where(function ($q) use ($workspace) {
            $q->where('subject_type', 'App\\Models\\Declaration')
              ->whereIn('subject_id', $workspace->declarations()->select('id'));
        })
        // Clients belonging to this workspace
        ->orWhere(function ($q) use ($workspace) {
            $q->where('subject_type', 'App\\Models\\Client')
              ->whereIn('subject_id', $workspace->clients()->select('id'));
        })
        // Manual team logs (causer is workspace member)
        ->orWhere(function ($q) use ($workspace) {
            $q->whereIn('subject_type', ['App\\Models\\WorkspaceUser', 'App\\Models\\TeamInvitation'])
              ->whereIn('causer_id', $workspace->users()->select('users.id'));
        });
    })
    ->with('causer:id,name')
    ->latest()
    ->limit(20)
    ->get();

For Worker scoping (AC #5): Add an additional filter on declarations assigned to the Worker:

if ($isWorker) {
    // Only declaration activities for declarations assigned to this worker
    $activities = Activity::query()
        ->where('subject_type', 'App\\Models\\Declaration')
        ->whereIn('subject_id',
            $workspace->declarations()
                ->where('assigned_to', $user->id)
                ->select('id')
        )
        ->with('causer:id,name')
        ->latest()
        ->limit(20)
        ->get();
}

CRITICAL: DB Column Name

The architecture docs reference deadline but the actual database column is due_date. Use due_date in ALL queries. This was caught in Story 2.1 and confirmed in Stories 2.2 and 2.3.

CRITICAL: Excluded Statuses

The dashboard excludes declarations with statuses: termine, mise_en_demeure, ferme. However, the activity feed should include events for ALL declarations (including status changes TO these terminal statuses) since they represent meaningful workspace activity.

Existing Code to Extend (NOT Create Fresh)

app/Http/Controllers/DashboardController.php — Current controller (Stories 2.1-2.3):

  • Already has Cache::remember() with key dashboard:{workspace_id}:{user_id} and 5-min TTL
  • Already returns: stats, statCards, declarations, alerts, workspaceName, roleLabel, isWorker, declarationsUrl, clientsUrl, viewAllAlertsUrl
  • ADD: activities array to Inertia props (include in the cached data block)
  • ADD: buildActivityFeed() private method
  • ADD: formatActivityDescription() private method
  • Reuse existing typeLabels() for declaration type labels in descriptions
  • Reuse $isWorker flag for Worker-scoped activity filtering

resources/js/pages/Dashboard.vue — Current page (Stories 2.1-2.3):

  • Currently has: Worker empty state → KPI card grid → PriorityAlertsPanel → urgent declarations table
  • MODIFY: Wrap main content in a two-column grid on desktop to add activity feed sidebar
  • ADD: Import and render ActivityFeed component
  • ADD: Mobile collapsible section for the activity feed
  • Keep all existing layout intact — the activity feed is additive

resources/js/types/dashboard.ts — Current types (Stories 2.1-2.3):

  • Has: DashboardStats, DashboardDeclaration, StatCardLink, DashboardAlert, DashboardProps
  • ADD: ActivityEvent type
  • ADD: activities: ActivityEvent[] to DashboardProps

Files to Modify

File Changes
app/Http/Controllers/DashboardController.php Add buildActivityFeed() and formatActivityDescription() methods; add activities to cached data and Inertia props
resources/js/pages/Dashboard.vue Add two-column grid layout for desktop; integrate ActivityFeed component; add mobile collapsible
resources/js/types/dashboard.ts Add ActivityEvent type; extend DashboardProps with activities

New Files to Create

File Purpose
resources/js/components/dashboard/ActivityFeed.vue Activity feed panel component with event list, relative timestamps, empty state
tests/Feature/Dashboard/ActivityFeedTest.php Pest feature tests for activity feed data in dashboard props

Component Specifications (from UX Design)

ActivityFeed Component:

  • Purpose: Stream of recent workspace events on dashboard right panel
  • Content: Event type icon, actor initials, action description, target link, relative timestamp
  • Actions: Click event to navigate to related entity
  • States: Default, empty
  • Variants: Dashboard panel (compact, last 20 events)
  • Accessibility: role="feed", aria-label for each entry, keyboard navigation
  • Container: shadcn-vue Card with heading "Activite recente"

Desktop layout: Two-column CSS Grid — main content (~2/3 width) + activity feed right panel (~1/3 width) Tablet layout: Single column, activity feed below urgent declarations table Mobile layout (<768px): Activity feed in collapsible/expandable section with toggle button

Relative timestamp format (French):

  • < 1 minute: "a l'instant"
  • < 1 hour: "il y a X min"
  • < 24 hours: "il y a X h"
  • Yesterday: "hier"
  • < 7 days: "il y a X jours"
  • Older: DD/MM/YYYY

Actor display: Colored circle with initials (first letter of first and last name). Use existing useInitials composable if available, or compute from actorName.

Event type icons (lucide-vue-next):

  • Declaration created: FilePlus or Plus
  • Status change: RefreshCw or ArrowRightLeft
  • Reassignment: UserRoundCog
  • Client document upload: Upload
  • Role change: Shield
  • Default/fallback: Activity

Frontend Patterns

Two-column grid for desktop layout:

<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
    <!-- Main content: KPI cards, alerts, table (2 cols) -->
    <div class="lg:col-span-2 space-y-6">
        <!-- existing dashboard content -->
    </div>
    <!-- Activity feed sidebar (1 col) -->
    <div class="lg:col-span-1">
        <ActivityFeed :activities="activities" />
    </div>
</div>

Mobile collapsible pattern:

<div class="lg:hidden">
    <button @click="showFeed = !showFeed" class="...">
        Activite recente ({{ activities.length }})
    </button>
    <div v-show="showFeed">
        <ActivityFeed :activities="activities" />
    </div>
</div>

Null Workspace Fallback

The existing null-workspace fallback in DashboardController must also include activities: [] to prevent frontend errors.

Testing Standards

  • Use Pest syntax (test() closures), never PHPUnit class-based
  • Test descriptions: lowercase, descriptive strings
  • Use route() helper for URLs, never hardcoded paths
  • Factory states: User::factory()->create() + attach to workspace with role
  • Use RefreshDatabase (auto-applied via Pest.php)
  • Assertions: expect() chaining + ->assertInertia() for page props
  • Tests go in tests/Feature/Dashboard/ActivityFeedTest.php
  • Reuse the setupWorkspaceWithRole() helper pattern from OwnerDashboardTest.php
  • Current test count: 215 tests, 1127 assertions (after Story 2.3). Do NOT break existing tests.
  • To create activity log entries in tests, use model factories (creating/updating declarations triggers the Spatie trait automatically) or use activity()->causedBy($user)->performedOn($model)->log('...') directly.

Previous Story Intelligence (Stories 2.1-2.3)

  • DB column is due_date not deadline — architecture docs use wrong name. Always use due_date.
  • Declaration::forUser($user, $workspaceUser) takes both User and WorkspaceUser args.
  • withPivot gotcha: WorkspaceUser pivot needs explicit withPivot('role', 'permissions') on relationships.
  • Wayfinder routes in Vue: All URLs must use Wayfinder type-safe routes. The existing Dashboard.vue imports from @/routes.
  • No API Resources: Manual array building in controller. Do NOT create FormRequest or API Resource classes.
  • Cache mock gotcha: Don't use Cache::shouldReceive mocks — they conflict with middleware Cache calls. Test cache behavior by verifying data structure directly.
  • DeclarationType has a label() method that returns French labels. Use it for display.
  • abs() for diffInDays: Wrap with abs() when computing overdue days to avoid negative values.
  • Story 2.1 test helper: setupWorkspaceWithRole($role) in OwnerDashboardTest.php. Reuse or create a similar helper in ActivityFeedTest.php.
  • Deferred review items from Story 2.3: D-1 (nudge/reassign disabled), D-2 (assignee param not consumed), D-3 (cache not invalidated on role change) — none affect this story.

Scope Boundaries — Do NOT Implement

  • Do NOT add full-page activity log view (that's FR53-FR55, a separate feature)
  • Do NOT add real-time updates or WebSocket polling (deferred post-MVP; D10 decision)
  • Do NOT add notification bell or notification center (that's Epic 3)
  • Do NOT add filtering or search on the activity feed (future enhancement)
  • Do NOT add infinite scroll or pagination to the feed — fixed 20 items
  • Do NOT create a separate ActivityController or API endpoint — data is in Inertia page props only
  • Do NOT add a workspace_id column to activity_log table — use subquery filtering

Project Structure Notes

  • Tests: tests/Feature/Dashboard/ActivityFeedTest.php (alongside OwnerDashboardTest.php, PriorityAlertsPanelTest.php, WorkerDashboardTest.php)
  • New component: resources/js/components/dashboard/ActivityFeed.vue (alongside StatCard.vue, PriorityAlertsPanel.vue)
  • Types extended in existing resources/js/types/dashboard.ts
  • Controller modified in existing app/Http/Controllers/DashboardController.php
  • Dashboard page modified in existing resources/js/pages/Dashboard.vue
  • Route: no changes needed (same /dashboard route)

References

  • [Source: _bmad-output/planning-artifacts/epics.md#Epic 2 Story 2.4]
  • [Source: _bmad-output/planning-artifacts/architecture.md#Spatie Activity Log]
  • [Source: _bmad-output/planning-artifacts/architecture.md#Dashboard Aggregation Patterns]
  • [Source: _bmad-output/planning-artifacts/architecture.md#D4 Caching Strategy]
  • [Source: _bmad-output/planning-artifacts/architecture.md#Role-Scoped Query Patterns]
  • [Source: _bmad-output/planning-artifacts/ux-design-specification.md#ActivityFeed Component]
  • [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Dashboard Grid Layout]
  • [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Responsive Behaviors]
  • [Source: _bmad-output/planning-artifacts/prd.md#FR52-FR55]
  • [Source: _bmad-output/planning-artifacts/prd.md#FR24-FR26]
  • [Source: _bmad-output/implementation-artifacts/2-3-worker-scoped-dashboard.md]
  • [Source: _bmad-output/implementation-artifacts/2-3-review-notes.md]
  • [Source: _bmad-output/project-context.md]
  • [Source: app/Http/Controllers/DashboardController.php]
  • [Source: resources/js/pages/Dashboard.vue]
  • [Source: resources/js/types/dashboard.ts]
  • [Source: config/activitylog.php]
  • [Source: app/Models/Declaration.php#LogsActivity]

Dev Agent Record

Agent Model Used

Claude Opus 4.6

Debug Log References

  • Windows Docker stdin hang: resolved by wrapping commands in sh -c "..." 2>&1
  • Vite manifest missing in tests: resolved by adding $this->withoutVite() in Pest.php beforeEach
  • Duplicate setupMentionScenario() function: renamed to setupFolderMentionScenario() in FolderMentionTest.php
  • Owner test: Spatie causer was null because actingAs() was called after factory creation; moved before

Completion Notes List

  • All 7 ACs implemented and verified via 7 passing Pest tests
  • 222 existing tests pass; 15 pre-existing failures (14 Folder feature tests with missing routes/tables, 1 WorkerDashboardTest session edge case)
  • PHP lint (Pint) and Prettier both clean
  • Added global withoutVite() in Pest.php to fix Vite manifest issue for all Inertia page tests

File List

File Action
app/Http/Controllers/DashboardController.php Modified — added buildActivityFeed(), formatActivityDescription(), resolveActivityTargetUrl(), resolveEventType(), resolveDeclarationClientName() methods; added activities to Inertia props
resources/js/pages/Dashboard.vue Modified — two-column grid layout, ActivityFeed sidebar, mobile collapsible
resources/js/types/dashboard.ts Modified — added ActivityEvent type, activities to DashboardProps
resources/js/components/dashboard/ActivityFeed.vue Created — activity feed panel with event list, relative timestamps, icons, empty state
resources/js/composables/useRelativeTime.ts Created — French relative time formatting composable
tests/Feature/Dashboard/ActivityFeedTest.php Created — 7 Pest feature tests
tests/Pest.php Modified — added withoutVite() beforeEach for Feature tests
tests/Feature/Notification/FolderMentionTest.php Modified — renamed helper to fix duplicate function name

Change Log

  • 2026-03-22: Story 2.4 implementation complete. Activity feed with role-scoped filtering, French descriptions, relative timestamps, responsive layout, and 7 passing tests.