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

378 lines
22 KiB
Markdown

# 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.