# 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

- [x] Task 1: Build activity feed query in `DashboardController` (AC: #1, #3, #5, #6)
  - [x]1.1 Add `buildActivityFeed()` private method that queries `Spatie\Activitylog\Models\Activity` for the current workspace, limited to 20, ordered by `created_at` desc
  - [x]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`)
  - [x]1.3 For Owners/Managers, return all workspace activities (no subject restriction)
  - [x]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)
  - [x]1.5 Include the activity feed in the cached dashboard data (same `Cache::remember()` block with existing 5-minute TTL)
  - [x]1.6 Add `activities` key to the Inertia props alongside existing `stats`, `statCards`, `declarations`, `alerts`

- [x]Task 2: Build human-readable French descriptions for activity events (AC: #2, #3)
  - [x]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}"
  - [x]2.2 Extract `properties.old` and `properties.attributes` from the Spatie activity record to build contextual descriptions (e.g., old status → new status)
  - [x]2.3 Use `DeclarationStatus::labels()` and `typeLabels()` to display French labels in descriptions

- [x]Task 3: Build `targetUrl` resolution for clickable events (AC: #4)
  - [x]3.1 For Declaration subjects → `route('declarations.show', $subjectId)`
  - [x]3.2 For Client subjects → `route('clients.show', $subjectId)`
  - [x]3.3 For WorkspaceUser/TeamInvitation subjects → `route('team.index')`
  - [x]3.4 For unknown/deleted subjects → `null` (no link, graceful degradation)
  - [x]3.5 Handle soft-deleted or missing subjects without errors (check `subject_id` existence)

- [x]Task 4: Create `ActivityFeed.vue` component (AC: #1, #2, #4, #7, #8)
  - [x]4.1 Create `resources/js/components/dashboard/ActivityFeed.vue` as a reusable component accepting `activities` prop
  - [x]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")
  - [x]4.3 Clickable entries use `router.get(activity.targetUrl)` for navigation (skip if `targetUrl` is null)
  - [x]4.4 Empty state: show "Aucune activite recente" with a subtle icon when `activities.length === 0`
  - [x]4.5 Use shadcn-vue `Card` for the container with a "Activite recente" heading

- [x]Task 5: Integrate ActivityFeed into `Dashboard.vue` layout (AC: #1, #7)
  - [x]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)
  - [x]5.2 On tablet (768-1023px): render the activity feed below the urgent declarations table
  - [x]5.3 On mobile (<768px): render the activity feed inside a collapsible/expandable section (use shadcn-vue `Collapsible` or a toggle button) — NOT hidden
  - [x]5.4 Add `ActivityEvent` type to `resources/js/types/dashboard.ts` and extend `DashboardProps` with `activities: ActivityEvent[]`

- [x]Task 6: Implement relative timestamp formatting (AC: #2)
  - [x]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
  - [x]6.2 Use this formatter in the ActivityFeed component for all timestamps

- [x]Task 7: Write Pest feature tests (AC: #1, #3, #5, #6, #8)
  - [x]7.1 Test Owner sees workspace-wide activity in `activities` Inertia prop
  - [x]7.2 Test Worker sees only activity related to their assigned declarations
  - [x]7.3 Test activity entries contain expected fields (actorName, description, targetUrl, timestamp, eventType)
  - [x]7.4 Test feed is limited to 20 most recent events
  - [x]7.5 Test empty activity returns empty array (no errors)
  - [x]7.6 Test activity with deleted subject does not cause errors
  - [x]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:
```php
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:
```php
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:**
```vue
<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:**
```vue
<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.
