15 KiB
Phase 8: Engagement User Profile (NEW) - Context
Gathered: 2026-05-07 Status: Ready for planning
## Phase BoundaryA real, shareable per-employee profile page at /mobile/engagement/[userId] rendering identity → period selector → 4 hero metrics (2×2 grid) → categorized activity breakdown → two recent-items sections (time entries + meetings). Sourced from existing engagement endpoints (no new data). Replaces the desktop user-detail modal pattern on mobile only — desktop stays as-is.
Scope anchor (from ROADMAP.md): tapping an Engagement overview row navigates to /mobile/engagement/[userId]; the profile is a real page so the device back gesture returns to the overview at the same scroll position; layout is single-column per ENG-06; reuses existing endpoints per ENG-08.
Route, segment, and shell integration
- D-01: New page at
app/mobile/engagement/[userId]/page.tsx. TheEngagementUserRowcomponent (Phase 7,components/mobile/EngagementUserRow.tsx) already renders an entire-rowLinkto/mobile/engagement/[graphUserId]per Phase 7 D-19 — Phase 8 owns the destination. Do NOT modifyEngagementUserRow.tsx. - D-02:
'use client'+useState+useEffect+fetch(CLAUDE.md: no SWR/react-query, match Phase 7 pattern). - D-03:
[userId]in the route segment is thegraph_users.id(UUID-like string from Microsoft Graph), matching the existing/api/engagement/user/[userId]endpoint contract. Not the Better Authuser.id. Same convention used by Phase 7'sEngagementUserRow.graphUserId. - D-04: Scroll restoration to overview: rely on Next.js App Router's default
scrollRestoration: true—Linkprefetch + browser back/forward restores scroll position automatically. NosessionStorageworkaround needed unless the planner discovers the default doesn't hold. Treat as "verify in execution; if broken, then mitigate."
Identity header
- D-05: Avatar source — Microsoft Graph photo with initials fallback when no photo is available. Use the existing
getMsgraphClient()factory and a server-side photo fetch through a new thin endpoint (e.g./api/mobile/engagement/user/[userId]/photoreturning a small JPEG or 404). Initials computed via Phase 7's exportedgetInitials(displayName)helper fromEngagementUserRow.tsx. - D-06: Fields under name (in this stacked order):
- Job title —
graph_users.job_title - Department —
graph_users.department(omit row if NULL) - Email —
graph_users.email, rendered asmailto:link - Last active — most recent of (
time_entries.entry_date, lastengagement_snapshotsactivity timestamp), formatted viauseUserTimezone(). Display as relative if ≤7 days ("2 hours ago"), absolute if older ("2026-04-15"). Omit row if no signal.
- Job title —
- D-07: Header card uses
Card+CardContentwith horizontal layout: avatar (left, ~56px) + identity stack (right). No subtitle, no role badge — the four rows above carry sufficient identity weight on a phone.
Period selector
- D-08: Reuse Phase 7's
EngagementPeriodChipscomponent as-is. 3 chips:7d/30d/90dmapping toD7/D30/D90. - D-09: Default period =
D30(matches Phase 7 overview default, preserves user's mental model carried from the previous screen). - D-10: Sticky behavior: same as Phase 7 —
sticky top-0 z-10 bg-background pt-2 pb-3 -mx-4 px-4so chips run edge-to-edge while metrics scroll above. Header card scrolls under the chips.
Key metrics — 2×2 grid (4 hero metrics)
- D-11: Layout: 2×2 grid of
Card+CardContent, mirroring Phase 3 dashboard KPI grid. Each card: big number (text-2xl font-semibold), small label (text-xs text-muted-foreground), optional inline qualifier.gap-3between cards, no shadows (matches Phase 7 D-10). - D-12: Hero metrics (in reading order: top-left, top-right, bottom-left, bottom-right):
- Hours worked — sum of
time_entries.hours_workedover the selected period - Billable hours — sum where
billable = true - Days worked — distinct
entry_datecount - Meetings attended —
teams_meetings_attendedsummed over the period fromengagement_snapshots
- Hours worked — sum of
- D-13: When the value is
0orNULL, render0(not—). When the user has no activity at all, render the cards with zeros — empty-state messaging belongs at the recent-items section, not the metric grid.
Activity breakdown — categorized rows
- D-14: Below the metric grid, a single
Cardwith three labeled subsections in this order:- Time — Hours worked / Billable hours / Days worked / utilization% (billable ÷ hours, if applicable)
- Communication — Teams messages (chat + private summed) / Emails sent / After-hours: X% of messages, Y% of meetings (the after-hours signal lives here, per D-15)
- Meetings — Meetings attended / Meetings organized / Total meeting duration (hours, from
meeting_duration_seconds) / Zoom calls (only ifzoomblock in the response is non-null)
- D-15: After-hours signal — single row inside Communication:
After-hours · {messagesPct}% messages, {meetingsPct}% meetings. Tucked in, not callout-styled. Hide row when both are 0%. - D-16: Each subsection is a label (
text-sm font-medium text-muted-foreground) followed by metric rows (label left, value right,flex justify-between text-sm py-1.5). No charts, no sparklines — keeps render light and matches DASH-04 / Phase 7 §6.5 ("no multi-series chart on mobile"). - D-17: Hide a row entirely when its underlying field is
nullor0AND it's a "presence" signal (e.g. zoom calls when zoom not configured). For first-class metrics (hours, meetings) always render with0.
Recent items — two separate sections, tap-to-expand
- D-18: Two separate sections rendered in this order (after activity breakdown):
- Recent time entries — last 10 from
recentEntries(existing endpoint already returns these), sorted byentry_datedescending. Each row collapsed shows:entry_date(formatted viauseUserTimezone()),hours_worked, billable badge if applicable, ticket/project ref (if present), one-line notes preview. Tap expands inline to reveal: full notes, ticket title (if available), full project ref, exact timestamp. - Recent meetings — last 10 from
recentTeamsMeetings, sorted bydatedescending. Each row collapsed: subject, date (TZ-formatted), duration (frommeetingMins), attendee count if available. Tap expands inline to reveal: matched time entries (the existingmatchedEntriesarray), Zoom call linkage (if present), organizer.
- Recent time entries — last 10 from
- D-19: Bound to 10 each (count-bounded, not date-bounded). Period selector does NOT affect recent-items count — it remains 10/10 regardless of D7/D30/D90. (Period changes the metrics + breakdown only.)
- D-20: Tap-to-expand mechanism: local component state (
Set<string>of expanded entry IDs / meeting IDs). No URL state, no router push. Expanded rows animate via Tailwindtransition-all+ height; collapsed by default. Reuse shadcnCollapsibleif it fits cleanly, else hand-roll. - D-21: Empty states — when
recentEntriesis empty: show "No time entries in the last 30 days" inline (one row). Same for meetings. Section header still renders.
Data fetching
- D-22: Reuse
/api/engagement/user/[userId]?period={D7|D30|D90}as-is per ENG-08. The endpoint already returnsuser,hours,recentEntries,recentTeamsMeetings,dailyActivity,zoom,afterHours,peerMax— Phase 8 ignorespeerMax(radar/peer comparison is a desktop-only flourish) anddailyActivity(we render via metrics, no chart). - D-23: Single fetch on mount + on period change. Loading skeleton matches Phase 7 pattern: header skeleton + 4 metric-card skeletons + breakdown card skeleton + 2 recent-list skeletons.
- D-24: Error handling: if 404 → "User not found" empty page with back link to
/mobile/engagement. If 500 → toast (sonner) + retry button on the page body.
Avatar/photo endpoint
- D-25: New thin route
/api/mobile/engagement/user/[userId]/photo(server-side) — calls Microsoft Graph/users/{id}/photo/$valueviagetMsgraphClient(), returns the binary or 404.requireAuth()first. Cache headers:Cache-Control: private, max-age=3600. Browser caches the photo per-tab. Fallback to initials happens client-side when the<img>errors out. - D-26: Photo fetch is best-effort. If
MSGRAPH_*env not configured, the endpoint returns 503 — the client treats any non-200 as "use initials." Phase 8 doesn't gate on Graph being configured.
Claude's Discretion
- Exact card/row spacing, typography weights within Phase 7's established tokens (
text-2xl,text-xs,text-sm,space-y-3,gap-3) - Whether to use
Collapsiblefrom shadcn or a hand-rolled disclosure for D-20 - Skeleton component composition (use Phase 7 shapes as reference)
- Toast wording for the 500 error case (D-24)
- Whether to memoize the expand-state
Setor use a plain object — implementation detail - The exact threshold for "Last active" relative-vs-absolute (D-06): treat ≤7d as relative as a starting heuristic; planner can refine
<canonical_refs>
Canonical References
Downstream agents MUST read these before planning or implementing.
Phase scope and requirements
.planning/ROADMAP.md§"Phase 8" — goal, depends-on, success criteria.planning/REQUIREMENTS.mdENG-06, ENG-07, ENG-08 — single-column layout, real-page-not-modal, reuse existing endpoints
Prior-phase context this builds on
.planning/phases/07-engagement-overview-new/07-CONTEXT.md— period chip mapping (D-04..07), summary card visual tokens (D-10), sparkline pattern, list patterns; D-19 establishes the row→/mobile/engagement/[graphUserId]link.planning/phases/07.1-user-timezone-fix-inserted-urgent/07.1-04-SUMMARY.md—useUserTimezone()hook signature, formatting pattern ({ ...options, timeZone: tz }).planning/phases/02-mobile-shell-more-drawer/02-CONTEXT.md—/mobileshell layout, sticky header pattern
Existing endpoints to reuse (no modification)
app/api/engagement/user/[userId]/route.ts— main data source (581 lines): returnsuser,hours,recentEntries,recentTeamsMeetings,dailyActivity,zoom,afterHours,peerMax,snapshotsapp/api/engagement/user/[userId]/history/route.ts— monthly history (per-month metrics), not used in Phase 8 v1
Components to reuse from Phase 7
components/mobile/EngagementPeriodChips.tsx— chip component (D-08)components/mobile/EngagementUserRow.tsxexportsgetInitials()(D-05)components/mobile/EngagementSummaryCard.tsx— referenced as visual token source for metric cardscomponents/ui/card.tsx,components/ui/skeleton.tsx,components/ui/collapsible.tsx,components/ui/badge.tsx— shadcn primitives
Existing services and helpers
lib/services/msgraph-factory.ts—getMsgraphClient()for D-25 photo endpointlib/hooks/use-user-timezone.ts— TZ formatting (D-06, D-18)lib/auth-utils.ts—requireAuth()for the photo endpoint
Desktop reference (do NOT replicate visuals)
app/engagement/profile/page.tsx— desktop modal pattern using recharts (BarChart,RadarChart); kept as-is, not deleted, not migrated. Phase 8 only adds the mobile real-page; desktop modal continues to serve desktop users.
</canonical_refs>
<code_context>
Existing Code Insights
Reusable Assets
components/mobile/EngagementPeriodChips.tsx— drop-in for period selector (D-08)components/mobile/EngagementUserRow.tsxexportsgetInitials(displayName)(D-05)lib/hooks/use-user-timezone.ts—useUserTimezone()returns the user's IANA tz string (D-06, D-18)lib/services/msgraph-factory.ts—getMsgraphClient()+isMsgraphConfigured()for the photo endpoint (D-25)- shadcn
Collapsible— likely fit for the tap-to-expand recent rows (D-20)
Established Patterns
- Mobile pages are
'use client'+useState+useEffect+fetch(CLAUDE.md, Phase 7 D-03) - Sticky chips below H1 use
sticky top-0 z-10 bg-background pt-2 pb-3 -mx-4 px-4(Phase 7 D-05) - Metric cards: big number
text-2xl font-semibold, labeltext-xs text-muted-foreground, no shadow, just border (Phase 7 D-10) - TZ-formatted dates use
{ ...options, timeZone: tz }fromuseUserTimezone()(Phase 7.1) - API routes return JSON; auth via
requireAuth()fromlib/auth-utils.ts; error response shape{ error, message }
Integration Points
- Entry:
EngagementUserRow.tsx's existingLink href="/mobile/engagement/{graphUserId}"(Phase 7 wired this; Phase 8 only creates the destination page) - Data:
/api/engagement/user/[userId](existing) + new thin/api/mobile/engagement/user/[userId]/photo(D-25) - Auth/middleware:
/api/mobile/*is whitelisted inmiddleware.ts; route handlers gate viarequireAuth() - Navigation: Browser back gesture handled by Next.js App Router default scroll restoration (D-04) — no custom code unless verification reveals it's broken
</code_context>
## Specific Ideas- "I want this to feel like the row card I just tapped — same avatar treatment, same identity weight" — header avatar reuses
getInitials()and the visual rhythm of Phase 7 row cards - "When a manager opens this, they want to see the time numbers first" — 2×2 hero grid leads with Hours/Billable, then Days/Meetings; communication and meeting detail go below in the breakdown card
- "Don't bury after-hours" — after-hours% gets a visible row inside Communication subsection rather than being hidden in a tooltip or collapsed section
- Tap-to-expand is inline (no new page, no modal) — preserves the back-gesture-restores-scroll guarantee from SC#2
- Peer comparison / radar chart — desktop has
peerMaxdata + a radar visualization; not on mobile v1. Could be a future "compare to team" toggle. - Monthly history view —
/api/engagement/user/[userId]/historyexists with 16 metrics × N months. Could power a "history" tab on the profile in a future phase. Not v1. dailyActivitychart — the endpoint returns daily breakdown points; could render a single-series sparkline like Phase 7'sEngagementHoursSparkline. Skipped for v1 to keep the page screen-bounded; reconsider if managers ask for it.- Tap-to-open ticket/meeting — D-20 picked tap-to-expand-inline. Future enhancement: an explicit "View ticket" button inside the expanded entry that deep-links to
/mobile/tickets/[id]. - D1 ("today") period chip — Phase 7 D-07 deferred this; same applies here. Aggregate granularity is D7+ until a D1 sync lands.
- Zoom-only client-meeting filter — desktop differentiates "client meetings" from total meetings using calendar metadata. Mobile v1 shows the totals; client-only breakdown is a future enhancement.
Phase: 08-engagement-user-profile-new Context gathered: 2026-05-07