docs(07-03): complete mobile engagement page plan summary

- Documents orchestration model (3 fetches on period, 1 on sort, 0 on search)
- Documents deviation: lowercase EngagementSortKey values vs plan template (Wave 2 actual)
- Documents field mapping: existing endpoint 'email' -> component 'userEmail'
- Confirms DRAWER-03 reachability and ENG-09 BottomNav unchanged
- Flags inherited risk T-07-12 (IDOR on /api/engagement/users) for follow-up
This commit is contained in:
lorentz 2026-05-03 22:58:41 -04:00
parent 5daf7f31e5
commit 0af6136864

View file

@ -0,0 +1,165 @@
---
phase: 07-engagement-overview-new
plan: 03
subsystem: mobile-pages
tags: [mobile, engagement, page, orchestration, typescript, tailwind]
dependency_graph:
requires:
- app/api/mobile/engagement/summary/route.ts (Plan 01 — MobileEngagementSummary type + endpoint)
- app/api/mobile/engagement/trend/route.ts (Plan 01 — EngagementTrendResponse + SparklinePoint types)
- app/api/engagement/users/route.ts (existing — reused as-is per D-16)
- components/mobile/EngagementPeriodChips.tsx (Plan 02)
- components/mobile/EngagementSummaryCard.tsx (Plan 02)
- components/mobile/EngagementHoursSparkline.tsx (Plan 02)
- components/mobile/EngagementSortChips.tsx (Plan 02)
- components/mobile/EngagementSearchInput.tsx (Plan 02)
- components/mobile/EngagementUserRow.tsx (Plan 02)
- components/mobile/EngagementUserRowSkeleton.tsx (Plan 02)
- components/mobile/MoreDrawer.tsx (Phase 2 — DRAWER-03 wires the route)
provides:
- app/mobile/engagement/page.tsx
affects:
- Phase 8 user profile page (app/mobile/engagement/[userId]) — tap target wired here
tech_stack:
added: []
patterns:
- "'use client' + useState + useCallback + useEffect + useMemo + useRef (no SWR/react-query)"
- Three independent parallel fetches on mount/period change (summary + trend + users)
- Single users refetch on sort change only
- Client-side filter via useMemo (search never triggers a fetch)
- IntersectionObserver infinite scroll (rootMargin 200px) + Load more fallback button
- toast.error per failing fetch (sonner)
- Skeleton loading states (4 cards + 1 sparkline + 5 rows)
- prop mapping: existing API 'email' field mapped to EngagementUserRowData 'userEmail'
key_files:
created:
- app/mobile/engagement/page.tsx
modified: []
decisions:
- "EngagementSortKey values are lowercase ('hours'|'name'|'utilization') per Wave 2 component — SORT_TO_API map keyed accordingly"
- "EngagementSortChips uses {value, onChange} props (not {activeSort, onSortChange}) — matched actual component export"
- "Existing /api/engagement/users returns 'email' field; EngagementUserRowData expects 'userEmail' — mapped inline at render"
- "Default sort set to 'hours' (lowercase) matching EngagementSortKey type constraint"
- "Period/sort/search state held in component state only (no URL sync) per D-21 note and lower data scale vs Phase 4 tickets"
metrics:
duration: "~15 min"
completed: "2026-05-04"
tasks: 1
files_created: 1
files_modified: 0
---
# Phase 7 Plan 03: Mobile Engagement Page (Wave 3) Summary
Mobile Engagement overview page wiring all Plan 01 endpoints + Plan 02 components into a single `'use client'` page with 3-fetch orchestration, infinite scroll, and complete loading/empty/error states.
## What Was Built
### `app/mobile/engagement/page.tsx` (commit 5daf7f3, 378 lines)
Phone-first refactor of the Engagement overview (ENG-01 — real refactor, not a desktop port).
**Orchestration model:**
| Trigger | Fetches fired |
|---------|--------------|
| Mount (period = D30 default) | summary + trend + users page 1 (parallel) |
| Period chip tap | summary + trend + users page 1 (parallel) |
| Sort chip tap | users page 1 only |
| Search input change | 0 fetches (client-side useMemo filter) |
| IntersectionObserver / Load more | users page N+1 |
**Page layout order (per UI-SPEC):**
1. `<h1 className="text-sm font-semibold">Engagement</h1>` — scrolls away under sticky chips
2. `<EngagementPeriodChips period={period} onPeriodChange={setPeriod} />` — sticky strip (D-05)
3. 4× `<EngagementSummaryCard>` stacked (`space-y-3`): Active users, Total Graph hours, Total Autotask hours, Hours / active user
4. `<EngagementHoursSparkline points={trendPoints} period={period} />` — compact sparkline card
5. Sort + search controls (`space-y-2`): `<EngagementSortChips>` then `<EngagementSearchInput>`
6. User list (`divide-y border rounded-xl overflow-hidden`)
7. Sentinel + Load more button
**States wired:**
| State | Trigger | What renders |
|-------|---------|-------------|
| Initial loading | mount | 4 summary card skeletons + sparkline skeleton + 5 user-row skeletons |
| Not-configured | `summary.configured === false` | Banner replaces sections 37 |
| Empty | `activeUsers === 0 AND users.length === 0` | Empty state card with period chips still interactive |
| No-matches | search filter → 0 results | Inline "No matches for {query}" + Clear search button |
| Load more in-flight | intersection fires | Loader2 spinner; button disabled |
| Load more error | fetch throws | toast.error + button flips to "Retry" |
**Fetch error toasts (D-25, all 4 present):**
- `toast.error('Failed to load engagement summary')`
- `toast.error('Failed to load hours trend')`
- `toast.error('Failed to load engagement users')`
- `toast.error('Failed to load more team members')`
## Reachability Confirmed
- **DRAWER-03:** `components/mobile/MoreDrawer.tsx` routes to `/mobile/engagement` — verified, not modified.
- **ENG-09:** `components/mobile/BottomNav.tsx` has no Engagement entry — verified, not modified.
Engagement is exclusively reachable from the More drawer. Bottom nav unchanged (4 tabs + More cell).
## Inherited Risk: T-07-12 (IDOR on /api/engagement/users)
The existing `/api/engagement/users` endpoint reused by this page (per D-16, D-33) does not call `requireAuth()`. This is an existing-product gap. The new mobile page does NOT introduce new exposure: `middleware.ts` requires session for all `/api/*` routes not in the public list, and `/api/engagement/users` is not in the public list.
**Recommended follow-up:** A future security phase should retrofit `requireAuth()` onto all desktop engagement endpoints (`/api/engagement/*`). Track in STATE.md.
## Key Deviation: Sort Key Casing
The plan's interface templates used capitalized `EngagementSortKey` values (`'Hours'|'Name'|'Utilization'`) but the Wave 2 `EngagementSortChips` component (Plan 02) exports lowercase values (`'hours'|'name'|'utilization'`). This was caught by reading the actual component files before writing the page. Adjustments made:
- `SORT_TO_API` map keyed on lowercase values
- Default `sortKey` state set to `'hours'` (not `'Hours'`)
- `EngagementSortChips` called with `value={sortKey}` and `onChange={setSortKey}` (matching actual prop names, not the plan template's `activeSort`/`onSortChange`)
These are Rule 1 (auto-fix) adjustments — aligning the page to what the actual component exports.
## Key Deviation: userEmail Field Mapping
The existing `/api/engagement/users` endpoint returns `email` field per its response shape. `EngagementUserRowData` (Wave 2 type) expects `userEmail`. The page performs the mapping inline at render:
```ts
user={{
graphUserId: u.graphUserId,
displayName: u.displayName,
userEmail: u.email, // 'email' from existing endpoint → 'userEmail' expected by component
jobTitle: u.jobTitle,
billableHours: u.billableHours,
hoursWorked: u.hoursWorked,
}}
```
This is the correct approach — the existing endpoint is unchanged (D-34) and the component contract is respected.
## No Stubs
The page is fully wired to all 3 data sources. No hardcoded mock data, no placeholder values beyond loading skeletons (which are correct UX, not stubs).
## Threat Flags
None new in this file. All user-supplied strings (search query, displayName, email, jobTitle) are rendered via React JSX text interpolation (auto-escaped). The search query appears in copy via template literal — React escapes the closing tags. No `dangerouslySetInnerHTML`. The `graphUserId` in Link href comes from API response (not direct user input).
T-07-12 (IDOR on /api/engagement/users) is a pre-existing risk documented in Plan 01 SUMMARY; this plan inherits it without introducing new exposure.
## Self-Check: PASSED
All checks passed:
- `app/mobile/engagement/page.tsx` exists (378 lines, above 200 minimum)
- Commit 5daf7f3 confirmed
- `npx tsc --noEmit --pretty` exits 0
- `'use client';` at top of file
- All 7 Wave-2 components imported
- Type imports from both Plan 01 endpoint files
- H1 uses `text-sm font-semibold` (not `text-base`)
- No `font-medium` in file
- `BottomNav.tsx` has no Engagement entry (ENG-09 — verified, unmodified)
- `MoreDrawer.tsx` routes to `/mobile/engagement` (DRAWER-03 — verified, unmodified)
- No Zod, SWR, or react-query imports
- No `useSearchParams` or `useRouter`
- All 4 toast.error messages present verbatim
- All copy strings from UI-SPEC Copywriting Contract present