wulf-pulse/.planning/phases/08-engagement-user-profile-new/08-CONTEXT.md

15 KiB
Raw Blame History

Phase 8: Engagement User Profile (NEW) - Context

Gathered: 2026-05-07 Status: Ready for planning

## Phase Boundary

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

## Implementation Decisions

Route, segment, and shell integration

  • D-01: New page at app/mobile/engagement/[userId]/page.tsx. The EngagementUserRow component (Phase 7, components/mobile/EngagementUserRow.tsx) already renders an entire-row Link to /mobile/engagement/[graphUserId] per Phase 7 D-19 — Phase 8 owns the destination. Do NOT modify EngagementUserRow.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 the graph_users.id (UUID-like string from Microsoft Graph), matching the existing /api/engagement/user/[userId] endpoint contract. Not the Better Auth user.id. Same convention used by Phase 7's EngagementUserRow.graphUserId.
  • D-04: Scroll restoration to overview: rely on Next.js App Router's default scrollRestoration: trueLink prefetch + browser back/forward restores scroll position automatically. No sessionStorage workaround 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]/photo returning a small JPEG or 404). Initials computed via Phase 7's exported getInitials(displayName) helper from EngagementUserRow.tsx.
  • D-06: Fields under name (in this stacked order):
    1. Job titlegraph_users.job_title
    2. Departmentgraph_users.department (omit row if NULL)
    3. Emailgraph_users.email, rendered as mailto: link
    4. Last active — most recent of (time_entries.entry_date, last engagement_snapshots activity timestamp), formatted via useUserTimezone(). Display as relative if ≤7 days ("2 hours ago"), absolute if older ("2026-04-15"). Omit row if no signal.
  • D-07: Header card uses Card + CardContent with 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 EngagementPeriodChips component as-is. 3 chips: 7d / 30d / 90d mapping to D7/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-4 so 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-3 between cards, no shadows (matches Phase 7 D-10).
  • D-12: Hero metrics (in reading order: top-left, top-right, bottom-left, bottom-right):
    1. Hours worked — sum of time_entries.hours_worked over the selected period
    2. Billable hours — sum where billable = true
    3. Days worked — distinct entry_date count
    4. Meetings attendedteams_meetings_attended summed over the period from engagement_snapshots
  • D-13: When the value is 0 or NULL, render 0 (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 Card with three labeled subsections in this order:
    1. Time — Hours worked / Billable hours / Days worked / utilization% (billable ÷ hours, if applicable)
    2. 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)
    3. Meetings — Meetings attended / Meetings organized / Total meeting duration (hours, from meeting_duration_seconds) / Zoom calls (only if zoom block 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 null or 0 AND it's a "presence" signal (e.g. zoom calls when zoom not configured). For first-class metrics (hours, meetings) always render with 0.

Recent items — two separate sections, tap-to-expand

  • D-18: Two separate sections rendered in this order (after activity breakdown):
    1. Recent time entries — last 10 from recentEntries (existing endpoint already returns these), sorted by entry_date descending. Each row collapsed shows: entry_date (formatted via useUserTimezone()), 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.
    2. Recent meetings — last 10 from recentTeamsMeetings, sorted by date descending. Each row collapsed: subject, date (TZ-formatted), duration (from meetingMins), attendee count if available. Tap expands inline to reveal: matched time entries (the existing matchedEntries array), Zoom call linkage (if present), organizer.
  • 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 Tailwind transition-all + height; collapsed by default. Reuse shadcn Collapsible if it fits cleanly, else hand-roll.
  • D-21: Empty states — when recentEntries is 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 returns user, hours, recentEntries, recentTeamsMeetings, dailyActivity, zoom, afterHours, peerMax — Phase 8 ignores peerMax (radar/peer comparison is a desktop-only flourish) and dailyActivity (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/$value via getMsgraphClient(), 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 Collapsible from 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 Set or 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.md ENG-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.mduseUserTimezone() hook signature, formatting pattern ({ ...options, timeZone: tz })
  • .planning/phases/02-mobile-shell-more-drawer/02-CONTEXT.md/mobile shell layout, sticky header pattern

Existing endpoints to reuse (no modification)

  • app/api/engagement/user/[userId]/route.ts — main data source (581 lines): returns user, hours, recentEntries, recentTeamsMeetings, dailyActivity, zoom, afterHours, peerMax, snapshots
  • app/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.tsx exports getInitials() (D-05)
  • components/mobile/EngagementSummaryCard.tsx — referenced as visual token source for metric cards
  • components/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.tsgetMsgraphClient() for D-25 photo endpoint
  • lib/hooks/use-user-timezone.ts — TZ formatting (D-06, D-18)
  • lib/auth-utils.tsrequireAuth() 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.tsx exports getInitials(displayName) (D-05)
  • lib/hooks/use-user-timezone.tsuseUserTimezone() returns the user's IANA tz string (D-06, D-18)
  • lib/services/msgraph-factory.tsgetMsgraphClient() + 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, label text-xs text-muted-foreground, no shadow, just border (Phase 7 D-10)
  • TZ-formatted dates use { ...options, timeZone: tz } from useUserTimezone() (Phase 7.1)
  • API routes return JSON; auth via requireAuth() from lib/auth-utils.ts; error response shape { error, message }

Integration Points

  • Entry: EngagementUserRow.tsx's existing Link 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 in middleware.ts; route handlers gate via requireAuth()
  • 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
## Deferred Ideas
  • Peer comparison / radar chart — desktop has peerMax data + a radar visualization; not on mobile v1. Could be a future "compare to team" toggle.
  • Monthly history view/api/engagement/user/[userId]/history exists with 16 metrics × N months. Could power a "history" tab on the profile in a future phase. Not v1.
  • dailyActivity chart — the endpoint returns daily breakdown points; could render a single-series sparkline like Phase 7's EngagementHoursSparkline. 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