diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 3e4c24f..54bc405 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -164,7 +164,9 @@ Decimal phases appear between their surrounding integers in numeric order.
1. Tapping a row in the per-employee list navigates to `/mobile/engagement/[userId]` (segment form, shareable URL)
2. The profile is a real page (not a modal) — the device/browser back gesture returns to the overview at the same scroll position
3. The profile renders single-column: identity header → period selector → key metrics (compact) → activity breakdown list → recent items, sourced from the existing engagement profile data endpoints (no new data)
-**Plans**: TBD
+**Plans**: 2 plans
+- [ ] 08-01-PLAN.md — MS Graph user-photo proxy at /api/mobile/engagement/user/[userId]/photo (ENG-06; D-25, D-26)
+- [ ] 08-02-PLAN.md — Mobile profile page at /mobile/engagement/[userId] + 6 EngagementProfile* components (ENG-06, ENG-07, ENG-08)
**UI hint**: yes
### Phase 9: User Profile & Preferences (NEW)
@@ -193,7 +195,7 @@ Phases execute in numeric order. Phase 2 unblocks Phases 3–7 (any order, paral
| 6. Analyzer Feed | 0/3 | Not started | - |
| 7. Engagement Overview | 0/3 | Not started | - |
| 7.1. User Timezone Fix | 0/5 | Not started | - |
-| 8. Engagement User Profile | 0/TBD | Not started | - |
+| 8. Engagement User Profile | 0/2 | Not started | - |
| 9. User Profile & Preferences | 0/TBD | Not started | - |
---
diff --git a/.planning/STATE.md b/.planning/STATE.md
index 2821647..2f8a6b4 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -3,15 +3,15 @@ gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
status: executing
-stopped_at: Phase 8 context gathered
-last_updated: "2026-05-07T21:39:08.598Z"
-last_activity: 2026-05-07
+stopped_at: Phase 8 UI-SPEC approved
+last_updated: "2026-05-07T23:45:22.929Z"
+last_activity: 2026-05-07 -- Phase 08 planning complete
progress:
total_phases: 10
completed_phases: 8
- total_plans: 22
+ total_plans: 24
completed_plans: 22
- percent: 100
+ percent: 92
---
# Project State
@@ -28,7 +28,7 @@ See: .planning/PROJECT.md (updated 2026-05-03)
Phase: 8
Plan: Not started
Status: Ready to execute
-Last activity: 2026-05-07
+Last activity: 2026-05-07 -- Phase 08 planning complete
Progress: [░░░░░░░░░░] 0%
@@ -89,6 +89,6 @@ None yet.
## Session Continuity
-Last session: 2026-05-07T21:39:08.595Z
-Stopped at: Phase 8 context gathered
-Resume file: .planning/phases/08-engagement-user-profile-new/08-CONTEXT.md
+Last session: 2026-05-07T21:50:23.287Z
+Stopped at: Phase 8 UI-SPEC approved
+Resume file: .planning/phases/08-engagement-user-profile-new/08-UI-SPEC.md
diff --git a/.planning/phases/08-engagement-user-profile-new/08-01-PLAN.md b/.planning/phases/08-engagement-user-profile-new/08-01-PLAN.md
new file mode 100644
index 0000000..44ba2dd
--- /dev/null
+++ b/.planning/phases/08-engagement-user-profile-new/08-01-PLAN.md
@@ -0,0 +1,412 @@
+---
+phase: 08-engagement-user-profile-new
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - lib/services/msgraph-client.ts
+ - app/api/mobile/engagement/user/[userId]/photo/route.ts
+autonomous: true
+requirements: [ENG-06]
+requirements_addressed: [ENG-06]
+user_setup: []
+
+must_haves:
+ truths:
+ - "GET /api/mobile/engagement/user/{validGraphUserId}/photo returns 200 with image/jpeg (or image/png) bytes when MSGRAPH_* env is configured AND the user has a photo in Microsoft Graph"
+ - "GET /api/mobile/engagement/user/{validGraphUserId}/photo returns 404 when the user exists in Graph but has no photo"
+ - "GET /api/mobile/engagement/user/{userId}/photo returns 503 when MSGRAPH_* env is not configured (D-26)"
+ - "GET /api/mobile/engagement/user/{userId}/photo without a session cookie returns 401 before any Microsoft Graph call is issued (verified by `requireAuth()` being the first call inside the `GET` handler)"
+ - "Successful 200 responses include header `Cache-Control: private, max-age=3600` (D-25)"
+ - "404 from upstream Graph never reveals whether the userId is valid in our DB (timing/error-message neutrality)"
+ artifacts:
+ - path: "lib/services/msgraph-client.ts"
+ provides: "Public method getUserPhotoBytes(userId) returning { bytes: ArrayBuffer; contentType: string } | null"
+ contains: "getUserPhotoBytes"
+ - path: "app/api/mobile/engagement/user/[userId]/photo/route.ts"
+ provides: "Photo proxy GET handler"
+ exports: ["GET"]
+ contains: "requireAuth"
+ key_links:
+ - from: "app/api/mobile/engagement/user/[userId]/photo/route.ts"
+ to: "lib/services/msgraph-factory.ts"
+ via: "import { getMsgraphClient, isMsgraphConfigured }"
+ pattern: "isMsgraphConfigured\\(\\)"
+ - from: "app/api/mobile/engagement/user/[userId]/photo/route.ts"
+ to: "lib/auth-utils.ts"
+ via: "import { requireAuth }"
+ pattern: "requireAuth\\(\\)"
+ - from: "app/api/mobile/engagement/user/[userId]/photo/route.ts"
+ to: "MsGraphClient.getUserPhotoBytes"
+ via: "method call"
+ pattern: "getUserPhotoBytes"
+---
+
+
+Add a thin server-side photo proxy at `/api/mobile/engagement/user/[userId]/photo` that
+calls Microsoft Graph `/users/{id}/photo/$value` via `getMsgraphClient()` and returns
+the JPEG/PNG bytes (or 404 / 503), gated by `requireAuth()`.
+
+This endpoint is a hard dependency of the Phase 8 profile header avatar (D-05, D-25,
+D-26). The client fallback to initials happens in Plan 02 by treating any non-200 as
+"use initials".
+
+Purpose: Establish the photo-fetch foundation in Wave 1 so Plan 02 can reference the
+URL directly in the `` tag without further coordination.
+
+Output:
+- New public method on `MsGraphClient`: `getUserPhotoBytes(userId)`
+- New route handler at `app/api/mobile/engagement/user/[userId]/photo/route.ts`
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/phases/08-engagement-user-profile-new/08-CONTEXT.md
+@.planning/phases/08-engagement-user-profile-new/08-UI-SPEC.md
+@CLAUDE.md
+
+
+
+
+# From lib/services/msgraph-factory.ts (existing, unchanged):
+```ts
+export function isMsgraphConfigured(): boolean;
+export function getMsgraphClient(): MsGraphClient; // throws if env missing
+```
+
+# From lib/services/msgraph-client.ts (existing — class structure, NOT all members):
+```ts
+export class MsGraphClient {
+ private config: MsGraphClientConfig;
+ private accessToken: string | null = null;
+ private tokenExpiry: number = 0;
+
+ // Existing — keep untouched:
+ private async getToken(): Promise; // OAuth2 client_credentials
+ private async fetchJson(path: string, retryCount?: number): Promise;
+ async getUsers(): Promise;
+ // ... other existing public methods (getTeamsActivity, getEmailActivity, …)
+
+ // NEW (this plan adds this — see Task 1):
+ // async getUserPhotoBytes(userId: string): Promise<{ bytes: ArrayBuffer; contentType: string } | null>;
+}
+```
+
+The existing `fetchJson` is JSON-only (calls `res.json()` internally) and so cannot
+be reused for binary photo bytes. The new method must do its own `fetch` against
+`https://graph.microsoft.com/v1.0/users/{id}/photo/$value` with the bearer token
+from `await this.getToken()` and call `res.arrayBuffer()`.
+
+Microsoft Graph contract for photo endpoint (verified against current docs):
+- 200 OK + `Content-Type: image/jpeg` (most common) on success
+- 404 Not Found when the user exists but has no photo
+- Other 4xx/5xx on upstream errors
+
+# From lib/auth-utils.ts (existing, unchanged):
+```ts
+export async function requireAuth(): Promise<{
+ session: Session | null;
+ error: NextResponse | null;
+}>;
+```
+Pattern (from app/api/mobile/engagement/summary/route.ts and others):
+```ts
+const { session, error: authError } = await requireAuth();
+if (authError) return authError;
+```
+
+# Existing /api/mobile route precedent (from app/api/mobile/engagement/summary/route.ts):
+- File at `app/api/mobile//route.ts`
+- Exports `async function GET(...)` (or POST etc.)
+- First call inside try is `requireAuth()`
+- Errors return `NextResponse.json({ error, message }, { status })` per CLAUDE.md
+
+# graph_users.id schema verified at planning time (migration 041 line 4):
+# `id VARCHAR(255) PRIMARY KEY -- Azure AD object ID`
+# The column is TEXT/VARCHAR (NOT UUID). It typically holds GUID-like strings
+# (Azure AD object IDs, e.g. "abc12345-de67-89ab-cdef-1234567890ab"), but the
+# schema permits any string up to 255 chars (e.g. UPN-style identifiers).
+# Therefore the route handler's userId regex MUST remain permissive (bounded by
+# length + denylist of dangerous characters), NOT a strict GUID-only check.
+
+
+@lib/services/msgraph-client.ts
+@lib/services/msgraph-factory.ts
+@lib/auth-utils.ts
+@app/api/mobile/engagement/summary/route.ts
+@middleware.ts
+
+
+
+
+
+ Task 1: Add getUserPhotoBytes() to MsGraphClient
+ lib/services/msgraph-client.ts
+
+ - lib/services/msgraph-client.ts (read in full — understand existing class shape, getToken() and fetchJson() signatures, where to insert the new method)
+ - lib/services/msgraph-factory.ts (confirm how the singleton is constructed — no changes needed here)
+
+
+Add a new public async method `getUserPhotoBytes(userId: string)` to the
+`MsGraphClient` class in `lib/services/msgraph-client.ts`. Insert it as a sibling
+of the existing public methods (anywhere after `fetchJson` is fine — group with
+other `users/`-scoped methods like `getUserMessages` for code locality).
+
+The method MUST:
+
+1. Call `await this.getToken()` to reuse the existing OAuth2 token cache.
+2. Issue a `fetch` to `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(userId)}/photo/$value`
+ with header `Authorization: Bearer ${token}` (no `Accept: application/json` header — let Graph return image bytes).
+3. On `res.status === 404`, return `null` (user has no photo). This is a normal
+ outcome, not an error.
+4. On `res.status === 401` or `403`, throw `Error(\`Graph photo auth failed: ${res.status}\`)`
+ so the upstream caller can map to 503.
+5. On any other non-2xx, throw `Error(\`Graph photo error ${res.status} for user ${userId}\`)`.
+6. On 2xx, read `res.arrayBuffer()` and return
+ `{ bytes, contentType: res.headers.get('content-type') ?? 'image/jpeg' }`.
+7. Do NOT add retry logic for this method (photo fetches are best-effort per D-26;
+ the analyzer-style 429 retry in `fetchJson` is overkill here).
+
+Exact TypeScript signature to add:
+```ts
+async getUserPhotoBytes(userId: string): Promise<{ bytes: ArrayBuffer; contentType: string } | null> {
+ const token = await this.getToken();
+ const url = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(userId)}/photo/$value`;
+ const res = await fetch(url, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+
+ if (res.status === 404) {
+ return null;
+ }
+
+ if (!res.ok) {
+ const text = await res.text().catch(() => '');
+ throw new Error(`Graph photo error ${res.status} for user ${userId}: ${text}`);
+ }
+
+ const bytes = await res.arrayBuffer();
+ const contentType = res.headers.get('content-type') ?? 'image/jpeg';
+ return { bytes, contentType };
+}
+```
+
+Do NOT modify `getToken`, `fetchJson`, or any other existing method. Do NOT
+change the class's exports beyond adding this one method. Do NOT add new
+module-level interfaces — the inline return type is sufficient.
+
+
+ grep -c "async getUserPhotoBytes" /opt/stacks/pulse/lib/services/msgraph-client.ts
+
+
+ - File `lib/services/msgraph-client.ts` exists (was modified, not created).
+ - `grep -c "async getUserPhotoBytes" lib/services/msgraph-client.ts` returns exactly 1.
+ - `grep -c "users/\${encodeURIComponent(userId)}/photo/\\\$value" lib/services/msgraph-client.ts` returns at least 1.
+ - `grep -c "res.status === 404" lib/services/msgraph-client.ts` returns at least 1 (the no-photo branch).
+ - `grep -c "res.arrayBuffer()" lib/services/msgraph-client.ts` returns at least 1.
+ - `grep -c "private async getToken" lib/services/msgraph-client.ts` still returns 1 (existing method untouched).
+ - `grep -c "async getUsers" lib/services/msgraph-client.ts` still returns 1 (existing method untouched).
+ - `npx tsc --noEmit --pretty` exits 0.
+
+
+ `MsGraphClient.getUserPhotoBytes(userId)` is callable, returns
+ `{ bytes, contentType }` on 200, `null` on 404, and throws on other non-2xx.
+ Existing methods unchanged. Type-check passes.
+
+
+
+
+ Task 2: Add /api/mobile/engagement/user/[userId]/photo route handler
+ app/api/mobile/engagement/user/[userId]/photo/route.ts
+
+ - app/api/mobile/engagement/summary/route.ts (existing /api/mobile route — copy the exact `requireAuth()` pattern, the error-response shape `{ error, message }`, and the import style)
+ - app/api/mobile/engagement/trend/route.ts (second reference for the same pattern)
+ - lib/auth-utils.ts (confirm requireAuth's destructured return shape)
+ - lib/services/msgraph-factory.ts (confirm `isMsgraphConfigured()` and `getMsgraphClient()` signatures)
+ - middleware.ts (confirm `/api/mobile` is in publicRoutes — middleware does NOT pre-gate, so requireAuth() inside the handler is mandatory)
+ - migrations/041_create_engagement_tables.sql (confirm `graph_users.id` column type — verified at planning time as `VARCHAR(255) PRIMARY KEY` per line 4; Azure AD object IDs are typically GUID-like but the column accepts arbitrary 1–255-char strings, so the route's userId validation must be permissive — bounded by length + a denylist of dangerous characters — and MUST NOT be a strict GUID-only regex such as `/^[0-9a-f-]{36}$/i`)
+
+
+ Run `grep -n 'graph_users' migrations/041_create_engagement_tables.sql` to re-confirm the column shape before writing the handler. Verified at planning time: line 4 declares `id VARCHAR(255) PRIMARY KEY`. The handler retains the permissive validation below.
+
+
+Create the file `app/api/mobile/engagement/user/[userId]/photo/route.ts`. The
+parent directory does not exist — create it.
+
+This handler proxies a Microsoft Graph user-photo fetch and is gated by
+`requireAuth()`. Behaviour matches CONTEXT.md D-25 / D-26.
+
+File contents (this is the complete file — do not add a POST handler, do not
+add a config export, do not add Zod):
+
+```ts
+import { NextRequest, NextResponse } from 'next/server';
+import { requireAuth } from '@/lib/auth-utils';
+import { getMsgraphClient, isMsgraphConfigured } from '@/lib/services/msgraph-factory';
+
+export async function GET(
+ _request: NextRequest,
+ { params }: { params: Promise<{ userId: string }> }
+) {
+ // 1. Auth gate (middleware whitelists /api/mobile/* — handler MUST gate itself)
+ const { error: authError } = await requireAuth();
+ if (authError) return authError;
+
+ // 2. MS Graph configuration gate (D-26)
+ if (!isMsgraphConfigured()) {
+ return NextResponse.json(
+ { error: 'msgraph_not_configured', message: 'Microsoft Graph credentials are not configured' },
+ { status: 503 }
+ );
+ }
+
+ const { userId } = await params;
+
+ // 3. Defensive userId shape check — prevents path traversal and malformed
+ // requests from reaching MS Graph. graph_users.id is VARCHAR(255)
+ // (verified in migration 041 line 4) — it typically holds Azure AD GUID
+ // object IDs but the column also permits UPN-style identifiers, so this
+ // check is permissive: reject anything containing '/', '?', '#', '..',
+ // or whitespace, or that is empty / longer than 128 chars. Do NOT
+ // tighten to a strict GUID regex — that would lock out valid UPN-form
+ // rows the schema explicitly allows.
+ if (!userId || userId.length > 128 || /[\s/?#]|\.\./.test(userId)) {
+ return NextResponse.json(
+ { error: 'invalid_user_id', message: 'Invalid user id' },
+ { status: 400 }
+ );
+ }
+
+ try {
+ const client = getMsgraphClient();
+ const photo = await client.getUserPhotoBytes(userId);
+
+ if (!photo) {
+ // No photo on Graph (whether the user exists or not — neutral 404)
+ return NextResponse.json(
+ { error: 'no_photo', message: 'No photo available' },
+ { status: 404 }
+ );
+ }
+
+ return new NextResponse(photo.bytes, {
+ status: 200,
+ headers: {
+ 'Content-Type': photo.contentType,
+ 'Cache-Control': 'private, max-age=3600',
+ },
+ });
+ } catch (error) {
+ console.error('[ENGAGEMENT-USER-PHOTO] Error:', error);
+ // Neutral error response — do not leak whether the user exists or whether
+ // the failure was auth/network/upstream. Always 502 for "couldn't reach
+ // Graph for any reason other than no-photo".
+ return NextResponse.json(
+ { error: 'photo_fetch_failed', message: 'Failed to fetch photo' },
+ { status: 502 }
+ );
+ }
+}
+```
+
+Notes:
+- Cache-Control is `private, max-age=3600` per D-25. `private` is correct here
+ because the response is per-authenticated-user (the photo is keyed on the
+ Graph user id but the request itself is authenticated, so shared caches must
+ not store it).
+- The `_request` parameter prefix tells ESLint it is intentionally unused.
+- Do NOT add CORS headers — same-origin only.
+- Do NOT add a logger import; use `console.error` per CLAUDE.md convention.
+- Do NOT echo the userId in error messages (timing/info-disclosure neutrality).
+
+
+ test -f /opt/stacks/pulse/app/api/mobile/engagement/user/\[userId\]/photo/route.ts && grep -c "requireAuth" /opt/stacks/pulse/app/api/mobile/engagement/user/\[userId\]/photo/route.ts
+
+
+ - File `app/api/mobile/engagement/user/[userId]/photo/route.ts` exists.
+ - `grep -c "export async function GET" app/api/mobile/engagement/user/[userId]/photo/route.ts` returns 1.
+ - `grep -c "requireAuth" app/api/mobile/engagement/user/[userId]/photo/route.ts` returns at least 2 (import + call).
+ - `grep -c "isMsgraphConfigured" app/api/mobile/engagement/user/[userId]/photo/route.ts` returns at least 2 (import + call).
+ - `grep -c "getMsgraphClient" app/api/mobile/engagement/user/[userId]/photo/route.ts` returns at least 2 (import + call).
+ - `grep -c "getUserPhotoBytes" app/api/mobile/engagement/user/[userId]/photo/route.ts` returns at least 1.
+ - `grep -c "Cache-Control" app/api/mobile/engagement/user/[userId]/photo/route.ts` returns at least 1, AND that line contains the literal string `private, max-age=3600`.
+ - `grep -c "status: 503" app/api/mobile/engagement/user/[userId]/photo/route.ts` returns at least 1 (the unconfigured branch).
+ - `grep -c "status: 404" app/api/mobile/engagement/user/[userId]/photo/route.ts` returns at least 1 (the no-photo branch).
+ - `grep -c "status: 400" app/api/mobile/engagement/user/[userId]/photo/route.ts` returns at least 1 (the invalid-id branch).
+ - `npx tsc --noEmit --pretty` exits 0.
+ - `npm run build` exits 0.
+
+
+ Hitting GET /api/mobile/engagement/user/{validId}/photo as an authenticated
+ user returns either binary image bytes (200) or 404. As an unauthenticated
+ user it returns 401. With MSGRAPH_* env unset it returns 503. With a
+ malformed userId it returns 400. Type-check and build both pass.
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| browser → /api/mobile/engagement/user/[userId]/photo | Authenticated user requests an arbitrary Graph user id (path param). Untrusted input crosses here. |
+| /api/mobile/.../photo → Microsoft Graph | Server-side outbound to https://graph.microsoft.com using the MSGRAPH_* client_credentials token. Outbound trust boundary. |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-08-01 | Information Disclosure (IDOR) | photo route handler | low | accept | Any authenticated Pulse user can request any tenant user's photo. This matches the existing `/api/engagement/user/[userId]` endpoint behavior (which already exposes name, job title, hours, recent meetings to any authed user) — Engagement is an internal admin-overview surface, not a per-user-isolation surface. Consistent with sibling endpoints. Documented as accepted residual risk; revisit if Pulse adds an external-user role. |
+| T-08-02 | Denial of Service (rate amplification) | photo route handler | medium | mitigate | Endpoint sets `Cache-Control: private, max-age=3600`, so each photo is fetched at most once per hour per browser. The handler also early-rejects malformed userIds (400) before spending a Graph token call, preventing trivial path-fuzzing amplification. Mitigation implemented in `app/api/mobile/engagement/user/[userId]/photo/route.ts`. |
+| T-08-03 | Tampering (path traversal via userId) | photo route handler | medium | mitigate | The userId param is interpolated into a Graph URL via `encodeURIComponent`. Additionally, the handler rejects any userId containing `/`, `?`, `#`, `..`, whitespace, or longer than 128 chars before the Graph call. Implemented in route handler. |
+| T-08-04 | Information Disclosure (oracle on bad userId) | photo route handler / data endpoint | low | mitigate | Both 404 (Graph returns no photo) and 502 (Graph error) responses use neutral copy that does not echo the userId or distinguish "user does not exist in Graph" from "user exists but has no photo". The Graph endpoint returns 404 in both cases at the upstream level. Verified by reading the action: error responses contain only `{ error: 'no_photo' \| 'photo_fetch_failed', message: '...' }`. |
+| T-08-05 | Information Disclosure (token leak via logs) | MsGraphClient.getUserPhotoBytes | low | mitigate | The new method uses the existing `this.getToken()` and never logs the token. The handler `console.error`s the caught Error object, which contains the upstream status text but NOT the bearer token. CLAUDE.md "no echoing secrets" rule respected. |
+| T-08-06 | Spoofing (request from unauthenticated user) | photo route handler | high | mitigate | `requireAuth()` is the FIRST call inside `GET`, before `getMsgraphClient()` is even invoked. Verified by acceptance criterion: `grep -c "requireAuth"` returns ≥2. Middleware whitelists `/api/mobile/*`, so this in-handler gate is mandatory. |
+| T-08-07 | Repudiation | photo route handler | low | accept | No mutation occurs; read-only photo proxy. No audit log needed. |
+| T-08-08 | Elevation of Privilege | photo route handler | low | accept | Any authenticated user (`user`/`admin`/`super-admin`) may call this endpoint. No role gate required because the underlying `/api/engagement/user/[userId]` data endpoint has the same permission level. Consistent posture. |
+
+**Block-on-high check:** T-08-06 is the only `high` severity threat and it is `mitigated`
+by `requireAuth()` at the top of the handler. No unmitigated highs remain.
+
+
+
+## Phase Plan 01 Verification
+
+Wave-1 complete when:
+
+- [ ] `lib/services/msgraph-client.ts` contains `async getUserPhotoBytes(userId: string)` (grep)
+- [ ] `app/api/mobile/engagement/user/[userId]/photo/route.ts` exists with all required imports and the GET handler
+- [ ] `requireAuth` is the first call inside GET (positional grep + visual verification)
+- [ ] `Cache-Control: private, max-age=3600` is set on 200 responses (grep)
+- [ ] 503 returned when `isMsgraphConfigured()` is false (grep)
+- [ ] 404 returned when `getUserPhotoBytes` returns null (grep)
+- [ ] 400 returned for malformed userId (grep)
+- [ ] No new public exports in `msgraph-client.ts` beyond `getUserPhotoBytes` (the `MsGraphClient` class is the only export already)
+- [ ] `npx tsc --noEmit --pretty` exits 0
+- [ ] `npm run build` exits 0
+
+
+
+After this plan:
+1. The Phase 8 profile page (Plan 02) can reference
+ `` without further coordination
+2. Authenticated browsers receive cached photo bytes on success, neutral 404/503 on
+ absence/missing-config, and `` falls back to initials in Plan 02
+3. The MS Graph token cache is reused (`getToken()`) — no per-request token churn
+
+
+
+
+
\ No newline at end of file
diff --git a/.planning/phases/08-engagement-user-profile-new/08-02-PLAN.md b/.planning/phases/08-engagement-user-profile-new/08-02-PLAN.md
new file mode 100644
index 0000000..3bcf162
--- /dev/null
+++ b/.planning/phases/08-engagement-user-profile-new/08-02-PLAN.md
@@ -0,0 +1,1446 @@
+---
+phase: 08-engagement-user-profile-new
+plan: 02
+type: execute
+wave: 2
+depends_on: [01]
+files_modified:
+ - app/mobile/engagement/[userId]/page.tsx
+ - components/mobile/EngagementProfileSkeleton.tsx
+ - components/mobile/EngagementProfileHeader.tsx
+ - components/mobile/EngagementProfileMetricGrid.tsx
+ - components/mobile/EngagementProfileBreakdown.tsx
+ - components/mobile/EngagementRecentEntries.tsx
+ - components/mobile/EngagementRecentMeetings.tsx
+autonomous: false
+requirements: [ENG-06, ENG-07, ENG-08]
+requirements_addressed: [ENG-06, ENG-07, ENG-08]
+user_setup: []
+
+must_haves:
+ truths:
+ - "Tapping a row in /mobile/engagement navigates to /mobile/engagement/{graphUserId} and renders a real page (ENG-06, ENG-07; SC#1, SC#2)"
+ - "The browser back gesture from the profile returns to the overview at the prior scroll position (ENG-07; SC#2) — verified manually via the Wave-2 checkpoint task"
+ - "The profile renders single-column in this order: H1 → period chips (sticky) → identity header card → 2×2 metric grid → activity breakdown card → recent time entries section → recent meetings section (ENG-06; SC#3)"
+ - "Identity header shows the avatar (Graph photo via /api/mobile/engagement/user/[userId]/photo, or initials fallback on img onError), display name, jobTitle (when present), department (when present, omitted otherwise), email as mailto: link, and last-active row when a signal exists (D-05, D-06, D-07)"
+ - "The 4 hero metric cards (Hours worked / Billable hours / Days worked / Meetings attended) render in a 2-column grid with gap-3 and the values come from the existing /api/engagement/user/[userId]?period={D7|D30|D90} response (D-11, D-12, D-22)"
+ - "Selecting 7d/30d/90d on the chip strip refetches /api/engagement/user/[userId]?period={D7|D30|D90} and recomputes metrics + breakdown; recent-items lists remain bound to 10 each regardless of period (D-19, interaction-contracts §period-chip-selection)"
+ - "Activity breakdown renders three labeled subsections (Time / Communication / Meetings) inside one Card with the rows, after-hours line, and presence-row hide rules from D-14..D-17"
+ - "Recent time entries and Recent meetings sections render up to 10 collapsed rows; tapping a row expands it inline using shadcn Collapsible; collapse state lives in component-local Set; period changes do NOT collapse expanded rows (D-18, D-19, D-20)"
+ - "404 from the data endpoint renders an inline 'User not found' page with a Back-to-Engagement link (D-24)"
+ - "500 / network failure renders a sonner toast and an inline Retry button that re-runs the fetch via a `retryNonce` state increment (D-24)"
+ - "Photo endpoint returning non-200 silently falls back to initials — no toast, no error UI (D-25, D-26)"
+ - "EngagementUserRow.tsx is NOT modified by this plan (D-01)"
+ - "/api/engagement/user/[userId]/route.ts is NOT modified by this plan (CONTEXT.md 'no new data', D-22)"
+ artifacts:
+ - path: "app/mobile/engagement/[userId]/page.tsx"
+ provides: "Mobile profile page (real Next.js App Router page, not a modal)"
+ contains: "'use client'"
+ min_lines: 120
+ - path: "components/mobile/EngagementProfileSkeleton.tsx"
+ provides: "Full-page skeleton (header + 4 metric cards + breakdown + 2 list skeletons)"
+ contains: "Skeleton"
+ - path: "components/mobile/EngagementProfileHeader.tsx"
+ provides: "Identity header card (avatar/initials, name, jobTitle, department, email, last active)"
+ contains: "EngagementProfileHeader"
+ - path: "components/mobile/EngagementProfileMetricGrid.tsx"
+ provides: "2×2 grid of 4 hero metric cards"
+ contains: "grid-cols-2"
+ - path: "components/mobile/EngagementProfileBreakdown.tsx"
+ provides: "Single Card with Time / Communication / Meetings subsections"
+ contains: "EngagementProfileBreakdown"
+ - path: "components/mobile/EngagementRecentEntries.tsx"
+ provides: "Collapsible list (up to 10) of recent time entries"
+ contains: "Collapsible"
+ - path: "components/mobile/EngagementRecentMeetings.tsx"
+ provides: "Collapsible list (up to 10) of recent Teams meetings"
+ contains: "Collapsible"
+ key_links:
+ - from: "app/mobile/engagement/[userId]/page.tsx"
+ to: "/api/engagement/user/[userId]"
+ via: "fetch in useEffect on mount + on period change + on retryNonce change"
+ pattern: "fetch\\(`/api/engagement/user/\\$\\{userId\\}\\?period="
+ - from: "app/mobile/engagement/[userId]/page.tsx"
+ to: "components/mobile/EngagementPeriodChips.tsx"
+ via: "import EngagementPeriodChips"
+ pattern: "EngagementPeriodChips"
+ - from: "components/mobile/EngagementProfileHeader.tsx"
+ to: "/api/mobile/engagement/user/[userId]/photo"
+ via: "
+Build the mobile Engagement user profile at `/mobile/engagement/[userId]` — a real,
+shareable page (not a modal) — and the six new components it composes:
+`EngagementProfileSkeleton`, `EngagementProfileHeader`, `EngagementProfileMetricGrid`,
+`EngagementProfileBreakdown`, `EngagementRecentEntries`, `EngagementRecentMeetings`.
+
+Per ENG-06/07/08 (REQUIREMENTS.md), this page replaces the desktop user-detail
+modal pattern on mobile so the device back gesture restores scroll on the overview.
+It reuses the existing `/api/engagement/user/[userId]` endpoint (no new data, no
+endpoint modifications) and the photo proxy from Plan 01.
+
+Purpose: Deliver Phase 8's user-facing surface — the page Phase 7's
+`EngagementUserRow.tsx` already links to (``).
+
+Output:
+- 1 new page at `app/mobile/engagement/[userId]/page.tsx`
+- 6 new components under `components/mobile/Engagement*`
+- No modifications to existing files except the page (which is new) and the new
+ components (which are new). Explicitly do NOT touch `EngagementUserRow.tsx` (D-01)
+ or `app/api/engagement/user/[userId]/route.ts` (D-22).
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/phases/08-engagement-user-profile-new/08-CONTEXT.md
+@.planning/phases/08-engagement-user-profile-new/08-UI-SPEC.md
+@CLAUDE.md
+
+
+
+
+# /api/engagement/user/[userId] response shape (the EXISTING endpoint, NOT modified):
+# Source: app/api/engagement/user/[userId]/route.ts lines 483–576 (verified 2026-05-07)
+# Snapshot rows in `snapshots[]` are returned as raw DB rows (line 499:
+# `snapshots: snapshotsResult.rows`). The columns come from migration 041 — they
+# include the snake_case column `period_end` (verified at planning time:
+# migrations/041_create_engagement_tables.sql line 18 declares `period_end DATE NOT NULL`).
+```ts
+type EngagementUserDetailResponse = {
+ user: {
+ id: string;
+ displayName: string;
+ email: string;
+ jobTitle: string | null;
+ department: string | null;
+ accountEnabled: boolean | null;
+ autotaskResourceId: number | null;
+ };
+ afterHours: {
+ messages: number;
+ meetings: number;
+ messagesPct: number; // 0–100, rounded
+ meetingsPct: number; // 0–100, rounded
+ };
+ snapshots: Array<{
+ period_type: 'D7' | 'D30' | 'D90' | string; // raw DB rows, snake_case
+ period_end: string; // ISO date 'YYYY-MM-DD' — verified present (migration 041)
+ teams_chat_messages: number;
+ teams_private_messages: number;
+ emails_sent: number;
+ teams_meetings_attended: number;
+ teams_meetings_organized: number;
+ after_hours_messages: number;
+ [key: string]: unknown;
+ }>;
+ hours: {
+ d7: { total: number; billable: number };
+ d30: { total: number; billable: number };
+ d90: { total: number; billable: number };
+ } | null;
+ recentEntries: Array<{
+ entry_date: string; // ISO date or 'YYYY-MM-DD'
+ hours_worked: number;
+ billable: boolean | null;
+ notes: string | null;
+ title: string | null;
+ start_date_time: string | null;
+ end_date_time: string | null;
+ company_name: string | null;
+ }>;
+ recentTeamsMeetings: Array<{
+ subject: string | null;
+ startTime: string; // ISO
+ durationMinutes: number | null;
+ attendeeCount: number;
+ clientAttendeeCount: number;
+ hasClientAttendees: boolean;
+ clientCompanies: string[];
+ participantNames: string[];
+ matchedEntries: Array<{
+ hours_worked: number;
+ billable: boolean | null;
+ notes: string | null;
+ title: string | null;
+ company_name: string | null;
+ start_date_time: string | null;
+ end_date_time: string | null;
+ }>;
+ }>;
+ meetingCounts: { total: number; withClients: number };
+ dailyActivity: Array<{ date: string; meetings: number; zoomCalls: number; hours: number; meetingMins: number }>;
+ zoom: {
+ calls: { d7: ZoomCallBucket; d30: ZoomCallBucket; d90: ZoomCallBucket };
+ meetings: { d7: ZoomMeetBucket; d30: ZoomMeetBucket; d90: ZoomMeetBucket };
+ topClients: Array<{ companyName: string; callCount: number; meetingCount: number }>;
+ recentCalls: unknown[];
+ recentMeetings: unknown[];
+ } | null; // null when isZoomConfigured() is false OR tables missing
+ peerMax: { ... } | null; // Phase 8 IGNORES this (D-22)
+ trend: { hours: number; billable: number; meetings: number; calls: number };
+};
+```
+
+# Period mapping for accessing nested per-period buckets:
+# - period prop value 'D7' → access `.hours.d7`, `.zoom.calls.d7`, `.zoom.meetings.d7`
+# - period prop value 'D30' → access `.hours.d30`, `.zoom.calls.d30`, `.zoom.meetings.d30`
+# - period prop value 'D90' → access `.hours.d90`, `.zoom.calls.d90`, `.zoom.meetings.d90`
+
+# Period-scoped fields:
+# - hours: from response.hours[periodKey] where periodKey = period.toLowerCase()
+# - meetings attended: snapshot row matching period_type === period (response.snapshots)
+# - days worked: COUNT distinct entry_date in recentEntries (already filtered server-side
+# to the period's window because the endpoint passes periodDays to the recentEntries
+# query)
+# - after-hours: response.afterHours (already period-scoped server-side)
+
+# From components/mobile/EngagementPeriodChips.tsx (existing, unchanged):
+```ts
+export type EngagementPeriod = 'D7' | 'D30' | 'D90';
+export interface EngagementPeriodChipsProps {
+ period: EngagementPeriod;
+ onPeriodChange: (next: EngagementPeriod) => void;
+}
+export function EngagementPeriodChips(props: EngagementPeriodChipsProps): JSX.Element;
+```
+
+# From components/mobile/EngagementUserRow.tsx (existing, unchanged):
+```ts
+export function getInitials(displayName: string): string; // "Jordan Walsh" → "JW"
+```
+
+# From lib/hooks/use-user-timezone.ts (existing, unchanged):
+```ts
+export function useUserTimezone(): string; // returns IANA tz like 'America/New_York'
+export function formatInUserTimezone(
+ input: string | number | Date,
+ tz: string,
+ options?: Intl.DateTimeFormatOptions,
+ locale?: string, // defaults 'en-US'
+): string;
+```
+
+# shadcn primitives (existing in components/ui/):
+- Card, CardContent (from '@/components/ui/card')
+- Skeleton (from '@/components/ui/skeleton')
+- Collapsible, CollapsibleContent, CollapsibleTrigger (from '@/components/ui/collapsible')
+- Badge (from '@/components/ui/badge')
+
+
+@app/api/engagement/user/[userId]/route.ts
+@components/mobile/EngagementUserRow.tsx
+@components/mobile/EngagementPeriodChips.tsx
+@components/mobile/EngagementSummaryCard.tsx
+@app/mobile/engagement/page.tsx
+@lib/hooks/use-user-timezone.ts
+@components/ui/card.tsx
+@components/ui/skeleton.tsx
+@components/ui/collapsible.tsx
+@components/ui/badge.tsx
+
+
+
+## Out of scope for this plan (documentation only)
+
+UI-SPEC §Typography revision notes (r1) calls out updating `text-[10px]` in
+`components/mobile/EngagementUserRow.tsx` (the avatar-initials non-standard
+size) to the standard `text-xs` token. **D-01 forbids modifying that file in
+this phase.** That update is deferred to a future Phase 7 patch or a Phase 11
+polish phase. Phase 8 will not touch `EngagementUserRow.tsx`. No task or
+acceptance criterion in this plan should attempt to apply that change. The
+acceptance criteria below explicitly assert via `git diff --name-only` that
+the file is untouched (D-01 guard rail).
+
+
+
+
+
+ Task 1a: Page shell + Skeleton + period/fetch wiring (no Header/MetricGrid yet)
+
+ app/mobile/engagement/[userId]/page.tsx,
+ components/mobile/EngagementProfileSkeleton.tsx
+
+
+ - .planning/phases/08-engagement-user-profile-new/08-CONTEXT.md (D-01..D-13, D-22..D-26)
+ - .planning/phases/08-engagement-user-profile-new/08-UI-SPEC.md (Layout Structure, Typography, Color, Component Inventory, Interaction Contracts, Copywriting Contract, Date/Time Formatting)
+ - app/api/engagement/user/[userId]/route.ts (THE response shape — read the JSON object built at lines 483–576 to confirm field names: `displayName`, `jobTitle`, `department`, `email`, `hours.d7/d30/d90.{total,billable}`, `afterHours`, `snapshots`. Note: `snapshots` is `snapshotsResult.rows` (line 499) — raw DB rows from `engagement_snapshots`)
+ - migrations/041_create_engagement_tables.sql (CONFIRM `engagement_snapshots.period_end` exists — verified at planning time at line 18: `period_end DATE NOT NULL`. The page relies on this column via the snapshot rows for the last-active fallback.)
+ - components/mobile/EngagementPeriodChips.tsx (the chip component, props, sticky classes)
+ - app/mobile/engagement/page.tsx (Phase 7 overview page — copy the fetch / error / loading state wiring style verbatim)
+ - CLAUDE.md (Frontend section: 'use client' + useState + fetch; no SWR; sonner for toasts)
+
+
+ Re-confirm the snapshot field name BEFORE writing the page. Run:
+ `grep -nE 'period_end|snapshot.*end' app/api/engagement/user/[userId]/route.ts`
+ Verified at planning time:
+ - The endpoint does NOT remap snapshot rows; it returns `snapshots: snapshotsResult.rows` (line 499). Therefore the API response includes the raw DB column name `period_end`.
+ - Migration 041 line 18: `period_end DATE NOT NULL`. Confirmed.
+ Therefore: the inline `ApiResponse` type below uses `period_end: string` on snapshot rows. If at execution time the executor finds the field is absent (unlikely — but in case the endpoint changes), they MUST fall back to deriving last-active from `recentEntries[0].entry_date` only and remove the snapshot branch from the `lastActiveAt` computation. The action below documents both code paths so the executor can choose.
+
+
+Create TWO files in this task.
+
+### File 1: `components/mobile/EngagementProfileSkeleton.tsx`
+
+```tsx
+'use client';
+
+/* EngagementProfileSkeleton — phase 08 (D-23).
+ * Purpose: Full-page loading skeleton matching the final layout —
+ * header skeleton + 4 metric-card skeletons (2×2) + breakdown card skeleton +
+ * 2 list-section skeletons. Period chips render OUTSIDE this skeleton (they
+ * drive the fetch). */
+
+import { Card, CardContent } from '@/components/ui/card';
+import { Skeleton } from '@/components/ui/skeleton';
+
+export function EngagementProfileSkeleton() {
+ return (
+
+ Loaded: {data.user.displayName}. Header and metric grid wired in Task 1b.
+
+ >
+ )}
+
+
+ );
+}
+```
+
+Notes:
+- `'use client'` at the very top.
+- `params` is a Promise in Next.js 16; unwrap with `React.use(params)` (named import `use`). This matches the existing endpoint at `app/api/engagement/user/[userId]/route.ts` line 7 (`{ params }: { params: Promise<{ userId: string }> }`).
+- D-04 (scroll restoration): no custom code in this task. Next.js App Router default `scrollRestoration: true` handles the device back gesture. Do NOT add `sessionStorage` workarounds. The `// D-04: rely on App Router default scrollRestoration` comment near the top of the function makes the decision visible to the checker.
+- D-13: `hoursForPeriod`, `billableForPeriod`, etc. all default to 0 — the page never renders `—` for these.
+- The placeholder `
...Loaded: …
` is removed in Task 1b when the real Header + MetricGrid are mounted.
+- `retryNonce` increment forces the `useEffect` to run again because it is part of the dependency array — this is a deterministic, non-magic refetch trigger (issue-7 fix).
+
+
+ test -f /opt/stacks/pulse/app/mobile/engagement/\[userId\]/page.tsx && test -f /opt/stacks/pulse/components/mobile/EngagementProfileSkeleton.tsx && grep -q "EngagementProfileSkeleton" "/opt/stacks/pulse/app/mobile/engagement/[userId]/page.tsx" && grep -q "EngagementPeriodChips" "/opt/stacks/pulse/app/mobile/engagement/[userId]/page.tsx" && grep -q "retryNonce" "/opt/stacks/pulse/app/mobile/engagement/[userId]/page.tsx" && grep -q "EngagementProfileSkeleton" "/opt/stacks/pulse/components/mobile/EngagementProfileSkeleton.tsx" && npx tsc --noEmit --pretty && npm run build
+
+
+ - File `app/mobile/engagement/[userId]/page.tsx` exists.
+ - File `components/mobile/EngagementProfileSkeleton.tsx` exists.
+ - `grep -c "'use client'" app/mobile/engagement/[userId]/page.tsx` returns 1.
+ - `grep -c "'use client'" components/mobile/EngagementProfileSkeleton.tsx` returns 1.
+ - `grep -c "EngagementPeriodChips" app/mobile/engagement/[userId]/page.tsx` returns at least 2 (import + JSX).
+ - `grep -c "EngagementProfileSkeleton" app/mobile/engagement/[userId]/page.tsx` returns at least 2.
+ - `grep -c "useState('D30')" app/mobile/engagement/[userId]/page.tsx` returns 1 (D-09 default).
+ - `grep -c "fetch(\`/api/engagement/user/" app/mobile/engagement/[userId]/page.tsx` returns at least 1, AND the line includes `?period=`.
+ - `grep -c "retryNonce" app/mobile/engagement/[userId]/page.tsx` returns at least 3 (state declaration, deps array, onClick handler — issue-7 fix).
+ - `grep -c "setRetryNonce((n) => n + 1)" app/mobile/engagement/[userId]/page.tsx` returns at least 1 (the Retry click handler — issue-7 fix).
+ - `grep -c "User not found" app/mobile/engagement/[userId]/page.tsx` returns at least 1 (D-24 404 copy).
+ - `grep -c "Back to Engagement" app/mobile/engagement/[userId]/page.tsx` returns at least 1 (D-24 404 link).
+ - `grep -c "toast.error" app/mobile/engagement/[userId]/page.tsx` returns at least 1 (D-24 500 toast).
+ - `grep -c "Retry" app/mobile/engagement/[userId]/page.tsx` returns at least 1 (D-24 retry button).
+ - `grep -c "scrollRestoration" app/mobile/engagement/[userId]/page.tsx` returns at least 1 (D-04 comment).
+ - `grep -c "period_end" app/mobile/engagement/[userId]/page.tsx` returns at least 1 (snapshot last-active derivation).
+ - `git diff --name-only -- components/mobile/EngagementUserRow.tsx` produces no output (D-01 guard rail).
+ - `git diff --name-only -- app/api/engagement/user/[userId]/route.ts` produces no output (D-22 guard rail).
+ - `npx tsc --noEmit --pretty` exits 0.
+ - `npm run build` exits 0.
+
+
+ Visiting `/mobile/engagement/{validId}` after login renders: H1 (display name) →
+ sticky period chips → full skeleton during load. After load, the placeholder
+ div confirms the data fetch round-trip. 404 → not-found page + back link.
+ 500 → skeleton + toast + Retry button that increments `retryNonce` and
+ re-triggers the fetch effect. EngagementUserRow.tsx and the data endpoint
+ are untouched. Type-check and build both pass.
+
+
+
+
+ Task 1b: Identity header + 2×2 metric grid + page wiring
+
+ components/mobile/EngagementProfileHeader.tsx,
+ components/mobile/EngagementProfileMetricGrid.tsx,
+ app/mobile/engagement/[userId]/page.tsx
+
+
+ - app/mobile/engagement/[userId]/page.tsx (the page from Task 1a — read it AS IT EXISTS so you know which state/data is already available before mounting Header + MetricGrid)
+ - .planning/phases/08-engagement-user-profile-new/08-CONTEXT.md (D-05..D-13)
+ - .planning/phases/08-engagement-user-profile-new/08-UI-SPEC.md (Layout Structure §identity-card, §Typography r1, §Color §accent reservation, §Date/Time Formatting, §Copywriting Contract rows for Hero metric labels, mailto, Last active)
+ - components/mobile/EngagementUserRow.tsx (named export `getInitials`)
+ - components/mobile/EngagementSummaryCard.tsx (visual reference for big-number-over-small-label pattern)
+ - lib/hooks/use-user-timezone.ts (signature + the helper `formatInUserTimezone`)
+
+
+Create TWO components and modify the page to mount them.
+
+### File 1: `components/mobile/EngagementProfileHeader.tsx`
+
+Identity header card per UI-SPEC §Layout (identity-card section), §Color (mailto
+uses `text-primary`), §Typography (display name = `text-xl font-semibold`,
+secondary rows = `text-xs text-muted-foreground` for department/jobTitle/last
+active, mailto = `text-sm text-primary`), §Date/Time Formatting (last active
+relative ≤7d / absolute >7d via `formatInUserTimezone`).
+
+Props:
+```ts
+export interface EngagementProfileHeaderProps {
+ displayName: string;
+ email: string;
+ jobTitle: string | null;
+ department: string | null;
+ // Last-active source: caller derives from response (most recent of recentEntries[0].entry_date,
+ // or the most-recent snapshots[].period_end). null if no signal at all.
+ lastActiveAt: string | null; // ISO date string or null (D-06)
+ // Used to build the photo URL — userId is the same value as the route segment
+ userId: string;
+}
+```
+
+Implementation requirements:
+
+1. Avatar block (left, `h-14 w-14 rounded-full`):
+ - State: `const [photoFailed, setPhotoFailed] = useState(false);`
+ - When `!photoFailed`: render ` setPhotoFailed(true)} />`
+ - When `photoFailed === true`: render the initials span:
+ ```tsx
+
+ {getInitials(displayName)}
+
+ ```
+ - Import `getInitials` from `@/components/mobile/EngagementUserRow`.
+2. Identity stack (right, flex-1 min-w-0 space-y-1):
+ - `
{displayName}
` (UI-SPEC r1: text-xl, not text-lg)
+ - If `jobTitle`: `
{jobTitle}
` (D-06 row 1)
+ - If `department`: `
{department}
` (D-06 row 2; raw value, no prefix label per copywriting contract)
+ - `{email}` (UI-SPEC mailto styling; 44px touch target floor)
+ - If `lastActiveAt`: render last-active row using `useUserTimezone()` and the rule:
+ - Compute `const ms = Date.now() - new Date(lastActiveAt).getTime();`
+ - If `ms <= 7 * 24 * 60 * 60 * 1000`: relative — use `date-fns` `formatDistanceToNow(new Date(lastActiveAt), { addSuffix: true })` and prefix with "Active " → e.g. "Active 2 hours ago"
+ - Else: absolute — `formatInUserTimezone(lastActiveAt, tz, { month: 'short', day: 'numeric', year: 'numeric' })` and prefix with "Last active " → e.g. "Last active May 5, 2026"
+ - Render: `
{label}
`
+3. Card layout: ` ... `
+4. Mark `'use client'` at top.
+5. Imports: `Card, CardContent` from `@/components/ui/card`; `getInitials` from `@/components/mobile/EngagementUserRow`; `useUserTimezone, formatInUserTimezone` from `@/lib/hooks/use-user-timezone`; `formatDistanceToNow` from `date-fns`; `useState` from `react`.
+
+### File 2: `components/mobile/EngagementProfileMetricGrid.tsx`
+
+2×2 grid of 4 hero metric cards per D-11/D-12, copywriting contract row 3.
+
+Props:
+```ts
+export interface EngagementProfileMetricGridProps {
+ hoursWorked: number; // already period-scoped by caller
+ billableHours: number;
+ daysWorked: number;
+ meetingsAttended: number;
+}
+```
+
+Implementation:
+- Outer: `
+ )}
+
+
+ );
+}
+```
+
+Notes (mirroring Recent entries):
+- D-19: `meetings.slice(0, 10)` hard bound.
+- D-20: local `Set` state, no URL state.
+- D-21: empty state copy "No meetings recorded" (UI-SPEC copywriting contract — hardcoded).
+- The expanded "Attendees" and "Matched time entries" rows reuse the existing
+ `participantNames` and `matchedEntries` arrays from the response — no new
+ endpoint fields are introduced.
+- Zoom call linkage is NOT rendered in this iteration: the existing endpoint
+ populates `meeting.matchedEntries` (Teams meeting → time entry overlap)
+ but not Zoom-call linkage on Teams meetings. That cross-reference is a
+ Phase-9+ enhancement.
+
+### File 4 (modify): `app/mobile/engagement/[userId]/page.tsx`
+
+Add three imports at the top:
+```tsx
+import { EngagementProfileBreakdown } from '@/components/mobile/EngagementProfileBreakdown';
+import { EngagementRecentEntries } from '@/components/mobile/EngagementRecentEntries';
+import { EngagementRecentMeetings } from '@/components/mobile/EngagementRecentMeetings';
+```
+
+Inside the `<>...>` block in the loaded-data branch (where Task 1b's comment
+says `Task 2 will mount EngagementProfileBreakdown ...`), replace the comment
+with the three sections, in this exact order, between
+`` and the closing fragment:
+
+```tsx
+
+
+
+```
+
+Rules:
+- Do NOT change the page's existing imports list other than adding the three new component imports.
+- Do NOT change the period state, fetch, retryNonce, or skeleton wiring.
+- Do NOT add any new endpoints or modify the existing data endpoint (D-22 guard rail).
+- Do NOT modify `EngagementUserRow.tsx` (D-01 guard rail).
+
+
+ test -f /opt/stacks/pulse/components/mobile/EngagementProfileBreakdown.tsx && test -f /opt/stacks/pulse/components/mobile/EngagementRecentEntries.tsx && test -f /opt/stacks/pulse/components/mobile/EngagementRecentMeetings.tsx && grep -q "EngagementProfileBreakdown" "/opt/stacks/pulse/app/mobile/engagement/[userId]/page.tsx" && grep -q "EngagementRecentEntries" "/opt/stacks/pulse/app/mobile/engagement/[userId]/page.tsx" && grep -q "EngagementRecentMeetings" "/opt/stacks/pulse/app/mobile/engagement/[userId]/page.tsx" && grep -q "EngagementProfileBreakdown" "/opt/stacks/pulse/components/mobile/EngagementProfileBreakdown.tsx" && grep -q "EngagementRecentEntries" "/opt/stacks/pulse/components/mobile/EngagementRecentEntries.tsx" && grep -q "EngagementRecentMeetings" "/opt/stacks/pulse/components/mobile/EngagementRecentMeetings.tsx" && npx tsc --noEmit --pretty && npm run build
+
+
+ - File `components/mobile/EngagementProfileBreakdown.tsx` exists.
+ - File `components/mobile/EngagementRecentEntries.tsx` exists.
+ - File `components/mobile/EngagementRecentMeetings.tsx` exists.
+ - `grep -c "'use client'" components/mobile/EngagementProfileBreakdown.tsx` returns 1.
+ - `grep -c "'use client'" components/mobile/EngagementRecentEntries.tsx` returns 1.
+ - `grep -c "'use client'" components/mobile/EngagementRecentMeetings.tsx` returns 1.
+ - `grep -c ">Time<" components/mobile/EngagementProfileBreakdown.tsx` returns at least 1, AND `grep -c ">Communication<" components/mobile/EngagementProfileBreakdown.tsx` returns at least 1, AND `grep -c ">Meetings<" components/mobile/EngagementProfileBreakdown.tsx` returns at least 1 (the three subsection headers).
+ - `grep -c "After-hours" components/mobile/EngagementProfileBreakdown.tsx` returns at least 1.
+ - `grep -c "py-2" components/mobile/EngagementProfileBreakdown.tsx` returns at least 1 (UI-SPEC override of D-16).
+ - `grep -c "border-t border-border" components/mobile/EngagementProfileBreakdown.tsx` returns at least 2 (the two inter-section dividers).
+ - `grep -c "Collapsible" components/mobile/EngagementRecentEntries.tsx` returns at least 2 (import + JSX).
+ - `grep -c "Collapsible" components/mobile/EngagementRecentMeetings.tsx` returns at least 2.
+ - `grep -c "Set" components/mobile/EngagementRecentEntries.tsx` returns at least 1 (D-20 expand-state).
+ - `grep -c "Set" components/mobile/EngagementRecentMeetings.tsx` returns at least 1.
+ - `grep -c "slice(0, 10)" components/mobile/EngagementRecentEntries.tsx` returns at least 1 (D-19 bound).
+ - `grep -c "slice(0, 10)" components/mobile/EngagementRecentMeetings.tsx` returns at least 1.
+ - `grep -c "Recent time entries" components/mobile/EngagementRecentEntries.tsx` returns at least 1.
+ - `grep -c "Recent meetings" components/mobile/EngagementRecentMeetings.tsx` returns at least 1.
+ - `grep -c "(no subject)" components/mobile/EngagementRecentMeetings.tsx` returns at least 1 (subject fallback per issue-5 spec).
+ - `grep -c "Matched time entries" components/mobile/EngagementRecentMeetings.tsx` returns at least 1 (expanded matchedEntries section per issue-5 spec).
+ - `grep -c "Attendees" components/mobile/EngagementRecentMeetings.tsx` returns at least 1 (expanded participants section per issue-5 spec).
+ - `grep -c "No time entries in the last 30 days" components/mobile/EngagementRecentEntries.tsx` returns at least 1 (D-21 empty copy).
+ - `grep -c "No meetings recorded" components/mobile/EngagementRecentMeetings.tsx` returns at least 1.
+ - `grep -c "Billable" components/mobile/EngagementRecentEntries.tsx` returns at least 1 (Badge usage).
+ - `grep -c "useUserTimezone" components/mobile/EngagementRecentEntries.tsx` returns at least 2 (import + call).
+ - `grep -c "useUserTimezone" components/mobile/EngagementRecentMeetings.tsx` returns at least 2 (import + call).
+ - `grep -c "EngagementProfileBreakdown" app/mobile/engagement/[userId]/page.tsx` returns at least 2 (import + JSX).
+ - `grep -c "EngagementRecentEntries" app/mobile/engagement/[userId]/page.tsx` returns at least 2.
+ - `grep -c "EngagementRecentMeetings" app/mobile/engagement/[userId]/page.tsx` returns at least 2.
+ - `git diff --name-only -- components/mobile/EngagementUserRow.tsx` produces no output (D-01 guard rail).
+ - `git diff --name-only -- app/api/engagement/user/[userId]/route.ts` produces no output (D-22, CONTEXT.md "no new data" guard rail).
+ - `npx tsc --noEmit --pretty` exits 0.
+ - `npm run build` exits 0.
+
+
+ The full Phase 8 profile page renders. Below the 2×2 metric grid the page now
+ shows: an activity-breakdown Card with three subsections (Time / Communication
+ / Meetings) including the after-hours row inside Communication and the
+ optional Zoom-calls row in Meetings; a Recent time entries Card with up to
+ 10 collapsible rows (Billable badge, date, hours, one-line preview; tap to
+ reveal title/company/notes/start time); and a Recent meetings Card with up to
+ 10 collapsible rows (subject or '(no subject)', start datetime, duration,
+ attendee count; tap to reveal matched entries and attendees). Period chip
+ changes recompute breakdown values; recent sections stay 10/10.
+ EngagementUserRow.tsx and the data endpoint are untouched. Build and
+ type-check pass.
+
+
+
+
+ Task 3: Verify scroll restoration on back gesture (D-04 / SC#2)
+ (no files modified — manual verification of behaviour delivered by Tasks 1a/1b/2)
+
+ A real Next.js App Router page at `/mobile/engagement/[userId]` that replaces
+ the modal pattern. Per D-04 the plan relies on Next.js's default
+ `scrollRestoration: true` to restore scroll position on the overview when
+ the user navigates back. SC#2 ("device back gesture returns to overview at
+ the prior scroll position") is a load-bearing phase Success Criterion and
+ the only way to verify it is hands-on.
+
+
+ Manual verification only — no code changes in this task. The executor
+ pauses here and asks the user to perform the steps in ``
+ below in a phone-width browser, then resumes based on the user's reply
+ per ``.
+
+ If the user reports `OK`, SC#2 is satisfied and the phase can ship.
+
+ If the user reports `BROKEN: scroll resets`, the executor MUST stop and
+ return control to the planner so a follow-up plan can add the
+ `sessionStorage`-based scroll-restoration shim allowed by CONTEXT.md
+ D-04's fallback clause. Do NOT attempt to fix it inline in this task.
+
+
+ 1. Run `npm run dev` (Pulse runs on http://localhost:3100).
+ 2. Sign in as any authenticated user.
+ 3. Open `/mobile/engagement` in a phone-width browser (Chrome DevTools
+ device emulator on iPhone 15 Pro is fine).
+ 4. Scroll halfway down the user list (verify multiple rows are off the top
+ of the viewport).
+ 5. Tap any user row → land on the new `/mobile/engagement/[userId]`
+ profile page. Confirm the page renders with header → period chips →
+ identity card → 2×2 metric grid → breakdown card → recent entries →
+ recent meetings.
+ 6. Press the browser back button (or use the OS back gesture if testing on
+ a real phone).
+ 7. Confirm the overview list restored at the same scroll position you left
+ it at — NOT scrolled back to the top.
+
+
+ User confirms scroll position restored on back navigation per the steps in ``.
+
+
+ Reply with one of:
+ - `OK` — scroll restoration works as expected, SC#2 satisfied.
+ - `BROKEN: scroll resets` — the overview scrolled back to the top. The
+ planner will spawn a follow-up plan to add a `sessionStorage`-based
+ scroll-restoration shim (per CONTEXT.md D-04 fallback clause) before the
+ phase ships.
+ - `BROKEN: ` — describe what you observed; planner will
+ triage.
+
+
+ User has replied with `OK` (SC#2 satisfied — phase ready to ship) OR with
+ `BROKEN: ...` (executor returns control to the planner for a follow-up plan
+ that adds the sessionStorage scroll-restoration shim before shipping).
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| browser → /mobile/engagement/[userId] (page render) | Authenticated browser session; userId from URL is untrusted input rendered into JSX and used in client-side fetches |
+| browser client → /api/engagement/user/[userId] (existing endpoint) | Already-authenticated existing endpoint; gated by Better Auth middleware (page route is NOT in /api/mobile public list — middleware enforces session) |
+| browser client → /api/mobile/engagement/user/[userId]/photo | Auth gate enforced by Plan 01's handler |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-08-09 | Information Disclosure (PII in URL/referer) | profile page | medium | mitigate | The URL contains the Graph user id (an opaque GUID-like string), NOT the email or display name — so referer leakage to outbound links exposes only the opaque id. The page DOES render the email as visible text inside a `mailto:` anchor; this is intentional for the manager workflow but means the email is in the rendered DOM. No additional logging of email is introduced. Mitigation: do not put email or displayName into query strings or document.title beyond the H1. |
+| T-08-10 | Information Disclosure (DOM logging) | profile page | low | mitigate | The page uses `console.error('[mobile/engagement/profile] fetch failed', err)` only on fetch failure — `err` is an Error object that does NOT contain response body or PII (HTTP status only via the thrown message). The full data response is never `console.log`-ed. Verified by acceptance: no `console.log` appears in the new page. |
+| T-08-11 | Cross-site Scripting (notes rendering) | EngagementRecentEntries | low | mitigate | Time-entry `notes` may contain operator-typed text. Rendered as React text content inside `
` (auto-escaped by React) and inside a `whitespace-pre-wrap` paragraph — never via `dangerouslySetInnerHTML`. Verified by acceptance: no `dangerouslySetInnerHTML` in any new component. |
+| T-08-12 | Tampering (userId path param) | profile page → /api/engagement/user/[userId] | low | accept | Browser passes `userId` from URL via `encodeURIComponent` into the fetch. The existing endpoint already exists, ships in production, and uses parameterized SQL via `postgresClient.query(... [userId])` — no SQL injection surface to introduce. No change. |
+| T-08-13 | Information Disclosure (404 oracle) | profile page | low | mitigate | A 404 from `/api/engagement/user/[userId]` (user does not exist) is rendered as a user-friendly "User not found" page with a back link — NOT an error message that distinguishes 404 from other states. The page does not differentiate "this id is malformed" vs "this id was deleted" vs "this id never existed". |
+| T-08-14 | Spoofing (page reachable without auth) | profile page route | high | mitigate | The page lives at `/mobile/engagement/[userId]/page.tsx`. The `middleware.ts` whitelists `/api/mobile/*` (NOT `/mobile/*`), so the existing middleware redirects unauthenticated browsers to `/auth/sign-in?callbackUrl=...` BEFORE the page renders. Verified by reading middleware.ts lines 6–43 (publicRoutes) — `/mobile` is NOT in the list, only `/api/mobile`. No new auth surface needed. |
+| T-08-15 | Repudiation | profile page | low | accept | Read-only page; no mutations. No audit log needed. |
+| T-08-16 | Denial of Service (large recentEntries arrays) | profile page render | low | mitigate | The endpoint returns up to 500 recent entries server-side (LIMIT 500). Phase 8 hard-bounds rendering with `entries.slice(0, 10)` in both Recent components. Memory cost ~10 collapsible nodes — bounded constant. |
+| T-08-17 | Photo endpoint cache key cross-tenant leakage | photo `` rendering | low | accept | `Cache-Control: private, max-age=3600` (set in Plan 01) prevents shared cache pollution. On a kiosk/shared device, the next user could see the previous user's cached photo if they navigate to the same userId — but that scenario already exposes the page content itself, so the photo is not an additional leak. Documented as accepted. |
+
+**Block-on-high check:** T-08-14 (spoofing the page) is the only `high` severity threat
+and is `mitigated` by the existing `middleware.ts` redirect (no new code needed in this
+plan; verified by reading middleware.ts which gates everything not in publicRoutes).
+No unmitigated highs remain.
+
+
+
+## Phase Plan 02 Verification
+
+Wave-2 complete when:
+
+- [ ] All 6 new component files exist under `components/mobile/Engagement*`
+- [ ] `app/mobile/engagement/[userId]/page.tsx` exists, imports all 6 components, and renders them in the order: Header → MetricGrid → Breakdown → RecentEntries → RecentMeetings (with Skeleton during load)
+- [ ] All 7 files (page + 6 components) start with `'use client'`
+- [ ] Period chip changes refetch via `useEffect` dependency on `period`
+- [ ] Retry button increments `retryNonce` which is in the fetch effect's deps array (issue-7 fix)
+- [ ] Recent items hard-bounded to 10 each (`slice(0, 10)`); period changes do NOT clear expanded state (they DO refetch — but the Sets persist because they're on different components from the data that drives metrics)
+- [ ] 404 from data endpoint renders inline "User not found" + Back to Engagement link (D-24)
+- [ ] 500 / network failure renders sonner `toast.error` + Retry button (D-24)
+- [ ] Photo `` has `onError` handler that swaps to initials (D-25)
+- [ ] No modifications to `components/mobile/EngagementUserRow.tsx` (D-01)
+- [ ] No modifications to `app/api/engagement/user/[userId]/route.ts` (D-22)
+- [ ] No `dangerouslySetInnerHTML` introduced
+- [ ] `npx tsc --noEmit --pretty` exits 0
+- [ ] `npm run build` exits 0
+- [ ] Task 3 checkpoint: human confirms scroll restoration works on back gesture (or reports BROKEN so planner can add a sessionStorage shim before ship)
+
+
+
+After this plan:
+
+1. (SC#1) Tapping any row in `/mobile/engagement` (Phase 7's `EngagementUserRow`'s
+ `Link href="/mobile/engagement/{graphUserId}"`) navigates to
+ `/mobile/engagement/{graphUserId}` and renders the new profile page.
+2. (SC#2) The profile is a real Next.js page (not a modal). Pressing the device
+ back gesture / browser back button returns to the overview at the prior scroll
+ position. No `sessionStorage` shim is added — App Router default
+ `scrollRestoration: true` is sufficient (D-04). Verified by Task 3 checkpoint.
+ If the checkpoint reports BROKEN, the planner spawns a follow-up plan to add
+ the sessionStorage workaround before shipping.
+3. (SC#3) The profile renders single-column in this exact order:
+ identity header → period selector (sticky) → 2×2 metric grid → activity
+ breakdown card (3 subsections) → recent time entries → recent meetings.
+ All data sourced from the existing `/api/engagement/user/[userId]?period={D7|D30|D90}`
+ endpoint plus the photo proxy (Plan 01) — no new data endpoints.
+4. ENG-06: route is `/mobile/engagement/[userId]` (segment form, shareable URL); single-column layout matches the prescribed order
+5. ENG-07: real page, not a modal — replaces desktop user-detail modal pattern on mobile so back gesture works
+6. ENG-08: profile reuses existing engagement profile data endpoints; no new data
+
+
+