22 KiB
Phase 7: Engagement Overview (NEW) - Context
Gathered: 2026-05-04 (auto mode) Status: Ready for planning
## Phase BoundaryBuild a phone-first refactor of the Engagement overview at /mobile/engagement
— accessed exclusively from the More drawer (NOT the bottom bar). The page
gives a manager a quick read on team engagement: a sticky 3-chip period
selector under the H1, four stacked summary cards (active users / total Graph
hours / total Autotask hours / hours-per-active-user), one compact "hours
trend" sparkline at the top of the per-employee list, and a sortable +
searchable list of stacked employee rows (avatar/initials, name, role,
hours bar) sourced from the existing engagement data layer.
In scope:
- New page
app/mobile/engagement/page.tsx - 2 new mobile endpoints:
/api/mobile/engagement/summary(4 totals for ENG-03) and/api/mobile/engagement/trend(daily hours time-series for ENG-05 sparkline) - Reuse of existing
/api/engagement/usersfor the per-employee list (ENG-04) - New components:
EngagementPeriodChips,EngagementSummaryCard,EngagementHoursSparkline,EngagementSortChips,EngagementSearchInput,EngagementUserRow,EngagementUserRowSkeleton - Confirm More drawer entry to
/mobile/engagementis correctly wired (Phase 2 already added it per DRAWER-03; verify and don't regress)
Out of scope:
- The user profile page at
/mobile/engagement/[userId]— that's Phase 8 (ENG-06..08) - Multi-series charts on mobile (explicit spec out-of-scope §6.5, §7)
- Mobile editing (read-only by design — PROJECT.md Out of Scope, REQ EDIT-01)
- "Today" period chip — engagement_snapshots only aggregate at D7/D30/D90 granularity; a "today" period would require a new D1 sync and is out of spec scope for this phase
- Modifying desktop
/engagement/*pages or APIs (PROJECT.md Out of Scope: "Restyling or replacing the desktop pages…") - Sort by anything beyond the 3 ENG-04 axes (hours / name / utilization)
- Server-side search (client-side filter satisfies ENG-04)
Page route, drawer entry, and shell integration
- D-01: New page at
app/mobile/engagement/page.tsx. The More drawer already routes here perDRAWER-03(Phase 2). Verify the route works end- to-end after this phase lands; do not changeMoreDrawer.tsx. - D-02: ENG-09 enforcement — Engagement is NOT on the bottom nav. The
Phase 2
BottomNav.tsxis already correct (4 tabs + More cell, no Engagement). Do not modifyBottomNav.tsx. - D-03: Page is
'use client'+useState+useEffect+fetch(CLAUDE.md: no SWR/react-query, no new state libs).
Period selector (ENG-02)
- D-04: 3 chips:
7d,30d,90d. Mapped 1:1 to the data layer'speriod_typevaluesD7,D30,D90(permigrations/041_create_engagement_tables.sql). Default:30d(D30) — matches the existing endpoints' default. - D-05: Sticky just below the page H1:
sticky top-0 z-10 bg-background pt-2 pb-3 -mx-4 px-4(offset for the page padding so the chips run edge-to-edge of the shell while content above scrolls). - D-06: Active chip:
bg-primary text-primary-foreground. Inactive:bg-muted text-foreground hover:bg-muted/80. Chip shape:rounded-full px-3 py-1.5 text-xs font-semibold(matches mobile compact-control density). Three chips in a horizontalflex gap-2row, no scroll. - D-07: Spec text says "today / 7d / 30d" but the data layer offers
only D7/D30/D90 aggregates. We follow the data layer and document this as
a deviation. "Today" is captured in
<deferred>for a future D1 sync.
Summary cards (ENG-03)
- D-08: 4 cards stacked single-column (no 4-up grid on phone widths).
In order:
- Active users — count of users with engagement activity in the
selected period (matches existing
summary.activeThisPeriod) - Total Graph hours — total Microsoft Graph activity hours
(
audio_duration_seconds + meeting_duration_seconds, summed across all active users in the period, converted to hours with 1 decimal) - Total Autotask hours — total
time_entries.hours_workedacross all human resources matching graph_users in the period - Hours per active user —
totalAutotaskHours / activeUsers(one decimal). IfactiveUsers == 0, render "—"
- Active users — count of users with engagement activity in the
selected period (matches existing
- D-09: New endpoint
/api/mobile/engagement/summaryreturns these 4 metrics directly, acceptingperiod=D7|D30|D90. Reason: existing/api/engagement/summaryreturns averages (avgHoursWorked, avgBillableHours, etc.), not the totals ENG-03 specifies. A thin mobile endpoint is cleaner than computing on the client. - D-10: Card visual: shadcn
Card+CardContent. Big number (text-2xl font-semibold), small label below (text-xs text-muted-foreground). One card per row,space-y-3between cards. Cards have no shadow, just border (matches FinanceRow density).
Hours trend sparkline (ENG-05)
- D-11: Single compact sparkline at the top of the per-employee list, scoped to the selected period. Series: total Autotask hours per day for the period.
- D-12: Custom inline SVG sparkline component —
EngagementHoursSparkline— takespoints: { date: string; hours: number }[]and renders a 3rem- tall path. Reason: DASH-04 precedent (no recharts on mobile); spec §6.5 ("no multi-series chart on mobile in this iteration"). One series, no axes, no tooltips. A faint baseline at 0 and a single colored line (stroke-primary stroke-2 fill-none). - D-13: Sparkline card has a small label row:
Hours trend · last {period_label}left, latest-value (e.g.12.4h today) right, bothtext-xs text-muted-foreground. Card height ~80px total. - D-14: New endpoint
/api/mobile/engagement/trendreturns{ points: { date: string; hours: number }[] }for the period. Aggregates daily totals acrosstime_entriesjoined to graph_users (same scope as the summary card filter). Period maps: D7 → 7 daily points, D30 → 30 daily points, D90 → 90 daily points (or 30 grouped weekly for D90 if perf matters — planner decides if 90 daily points renders cleanly at narrow viewport). - D-15: When
pointsis empty, render the card with the period label and "No activity" inline — no broken empty SVG.
Per-employee list (ENG-04)
- D-16: Reuse existing
/api/engagement/usersdirectly, no mobile wrapper. Existing response shape ({ users[], pagination }) is suitable. - D-17: Initial fetch:
?period={D7|D30|D90}&sort=billable_hours&order=desc&page=1(page size 50 is the existing endpoint's fixed value). - D-18: Pagination strategy: page-based, infinite scroll via
IntersectionObserver (mirror Phase 4/6 pattern). Sentinel triggers
?page=N+1fetch when last row enters viewport. "Load more" fallback button below the sentinel, hidden whenpagination.totalPagesreached. Most teams have ≤50 staff so most users will only see one page. - D-19: Row shape (stacked card per user):
- Top line: Avatar circle (initials from
displayName, h-8 w-8) +displayName(text-sm font-semibold, 1-line truncate) + role (jobTitleif present,text-xs text-muted-foreground, 1-line truncate) — left side; total billable hours on right (text-sm font-semibold, e.g. "12.4h") - Hours bar:
<div className="h-1.5 bg-muted rounded">with inner<div>width =min(100%, billableHours / maxRowHours * 100%),bg-primary.maxRowHours= the largest billable hours value in the current page (computed client-side after fetch). - Tap target: wraps in
<Link href={\/mobile/engagement/${graphUserId}`}>` so Phase 8 (already-planned) can pick up navigation. Even though Phase 8 builds the destination page, the link is wired here so Phase 7's row component is feature-complete; Phase 8 owns the page that receives the tap.
- Top line: Avatar circle (initials from
Sort + search (ENG-04)
- D-20: Sort control above the list: 3 chips —
Hours,Name,Utilization. Active chip:bg-primary text-primary-foreground. Tapping a chip triggers a refetch with the correspondingsortparam. Default:Hoursdesc.Hours→sort=billable_hours&order=descName→sort=display_name&order=ascUtilization→sort=billable_hours&order=desc(utilization isn't a direct sort on the endpoint; we sort by billable_hours and visually computebillableHours / hoursWorkedratio in the row. If a planner finds this confusing, pivot tosort=hours_workedand compute utilization =billable / total× 100% in row content)
- D-21: Search input above the list: a single
<Input>placeholder "Search by name or email", debounced 300ms, filters loaded users client-side (no server search param). Filter applies AFTER fetch — so it's instant on the visible page set but won't auto-load more pages when filter narrows results. Acceptable for ≤50-user teams; document in<deferred>if scale grows. - D-22: When the search filter has no results on the loaded set, render an inline "No matches for '{query}'" line with a "Clear search" button. Don't hide the page entirely.
Loading / empty / error states
- D-23: Initial load → 4 summary card skeletons + 1 sparkline-card
skeleton + 5 user-row skeletons. Reuse
Skeletonfromcomponents/ui/skeleton.tsx. - D-24: Subsequent infinite-scroll → small inline spinner above the Load more button. Mirror Phase 4 D-21 / Phase 6 D-29.
- D-25: Fetch errors →
toast.error()(sonner) per failure; Load more button flips to "Retry". Mirror Phase 6 D-30. - D-26: Empty: when
summary.activeUsers == 0ANDusers.length == 0for the selected period, render anEmptyState-style card: heading "No engagement data for this period", body "Try a different period or trigger a sync from/admin", with aSettingsicon. Period chips remain interactive so the user can switch. - D-27: When
configured: falsefrom the summary endpoint (Microsoft Graph not configured), render a banner card: heading "Engagement sync not configured", body "Set MSGRAPH_* env vars and restart" with a link out to admin. Inherit existingisMsgraphConfigured()semantics from the existing endpoint.
Typography & spacing (mirror Phase 4 UI-SPEC)
- D-28: Two weights only:
font-normal(400) andfont-semibold(600). Nofont-medium. - D-29: Four declared sizes (Phase 4 typography rule cap):
text-sm(14px) primary,text-xs(12px) secondary/labels,text-[10px]for badges/captions, andtext-2xlfor the four big summary numbers. Page H1 "Engagement" usestext-sm font-semibold(matches Row primary scale; H1 scrolls away under the sticky period chips).text-base(16px) is NOT used on this page — kept the count at 4 to satisfy the UI-checker typography cap. - D-30: Page container:
px-4 py-4 space-y-4(matches Phase 5/6). No horizontal overflow at 360px viewport.
Page H1 placement (NAV-01 / spec §5.1)
- D-31:
<h1>Engagement</h1>renders in the page body, not the shell header (Phase 2 spec: "no page title in the header"). H1 sits above the period selector. The period selector is sticky relative to the page; the H1 scrolls away.
Auth + scoping
- D-32: Both new endpoints use
requireAuth()fromlib/auth-utils.ts. No company scoping (kiosk_settings) — engagement data is org-wide and the desktop endpoints already operate org-wide (no per-company filter on/api/engagement/*). Mobile follows the same posture. - D-33: Existing
/api/engagement/usersdoes NOT userequireAuth()— it's an existing-product gap (similar to the IDOR posture noted in Phase 6). Document as inherited risk in the threat model; do NOT fix the desktop endpoint in this phase (the spec out-of-scope explicitly forbids modifying desktop pages/endpoints).
What NOT to change
- D-34: Existing
/api/engagement/*endpoints are unchanged. - D-35: Existing
app/engagement/*desktop pages are unchanged. - D-36: No edits to
lib/services/msgraph-*orlib/services/engagement-sync-service.ts— all read-only consumption. - D-37: No new state libraries; no SWR/react-query (CLAUDE.md).
- D-38: No Zod in API routes (CLAUDE.md: "no Zod in API routes unless required").
Claude's Discretion
- Exact sparkline math (linear interpolation across days, gap handling for missing days)
- Whether to use
BarChartrectangles or apathfor the sparkline (recommendpathfor compactness) - Avatar fallback initials algorithm (recommend first letter of first +
last word of
displayName) - Whether to expand or hide the search input by default (recommend always-visible, unobtrusive)
- Skeleton visual pattern density
- Exact chip vs button styling for the period selector and sort toggle
(recommend matching shadcn
Toggledensity) - Whether
EngagementUserRowextracts a separate component (yes, for Phase 8 reuse — the user profile page may share the avatar + name identity block)
<canonical_refs>
Canonical References
Downstream agents MUST read these before planning or implementing.
Phase spec
docs/superpowers/specs/2026-05-03-mobile-shell-design.md§6.5 (Engagement Overview) — primary scope. §3.2 (More drawer) confirms Engagement entry. §7 lists explicit non-goals for this phase..planning/REQUIREMENTS.md(ENG-01..05, ENG-09) — locked acceptance criteria.
Project conventions
CLAUDE.md— Pulse stack rules (no SWR/react-query, no ORM, no Zod in API routes, port 3100),/mobile/*boundary, kebab-case files, PascalCase exports.DESIGN.md— design tokens, component vocabulary, navigation IA.ARCHITECTURE.md— engagement sync overview (read for context; sync pipeline itself is unchanged this phase).
Prior phase contracts (patterns to mirror)
.planning/phases/02-mobile-shell-more-drawer/02-CONTEXT.md— shell decisions; the engagement page docks under this layout. DRAWER-03 wires Engagement entry from the More drawer..planning/phases/03-dashboard-restyle/03-01-SUMMARY.md—KpiCardMobilepattern (the four summary cards mirror this scale)..planning/phases/04-tickets-restyle/04-CONTEXT.md— IntersectionObserver- Load more pattern (D-08..D-14), URL-synced filter pattern (NOT used here — sort/search stays in component state per the lower data scale).
.planning/phases/04-tickets-restyle/04-UI-SPEC.md— typography contract (2 weights × 3 sizes), spacing scale, color tokens to mirror..planning/phases/05-finance-restyle/05-CONTEXT.md— Card/typography reuse pattern; inline error/Retry pattern (D-18); empty state convention (D-19)..planning/phases/06-analyzer-feed-new/06-CONTEXT.md— Pattern for reusing existing data endpoints (D-25), pattern for adding a /api/mobile/* mobile endpoint when the existing shape doesn't fit (D-09 here mirrors 06-09 there).
Existing code (entry points)
app/api/engagement/summary/route.ts— desktop summary endpoint; reference for averages / period mapping / staff filter logic.app/api/engagement/users/route.ts— REUSED as-is by the mobile list. Pattern reference for the period mapping (D7|D30|D90), sort whitelist, and pagination shape.app/api/engagement/user/[userId]/route.ts— Phase 8 scope, mentioned here only because Phase 7's row links to/mobile/engagement/[userId]which Phase 8 owns.app/api/engagement/user/[userId]/history/route.ts— Phase 8 scope.app/engagement/page.tsx— desktop overview (~1300 lines). Reference ONLY — DO NOT modify, DO NOT port; build mobile views from same data sources, phone-first.app/engagement/profile/page.tsx— desktop profile (~650 lines). Reference ONLY — Phase 8 scope.app/mobile/layout.tsx(Phase 2) — shell where the engagement page docks; no changes needed.components/mobile/MoreDrawer.tsx(Phase 2) — already routes to/mobile/engagementper DRAWER-03; verify, don't modify.
Existing schema
migrations/041_create_engagement_tables.sql—graph_users+engagement_snapshotstables.period_typeenum:D7,D30,D90.engagement_snapshots.UNIQUE(user_email, period_type, period_end).migrations/042_add_engagement_calendar_columns.sql— additional columns added later; read for additional fields available on snapshots if helpful.- The
time_entries,resources,zoom_calls,zoom_meetingstables are joined for hours/zoom totals in the existing endpoints — same joins apply for the trend endpoint.
Components and primitives
components/ui/{card,badge,skeleton,empty-state,input,separator}.tsx— shadcn primitives in use across mobile phases.components/mobile/KpiCardMobile.tsx— Phase 3 KPI card pattern; consider for the four summary cards (or its scale, if a separate component fits better).components/mobile/AnalyzerFeedRow.tsx,components/mobile/FinanceRow.tsx— recent extracted-row component patterns to mirror forEngagementUserRow.components/mobile/AnalyzerRowSkeleton.tsx— recent skeleton pattern.
</canonical_refs>
<code_context>
Existing Code Insights
Reusable Assets
requireAuth()fromlib/auth-utils.ts— auth gate for new endpoints.postgresClient.query()fromlib/services/postgres-client.ts— parameterized SQL.isMsgraphConfigured()fromlib/services/msgraph-factory.ts— returns boolean; surface as banner per D-27.engagement_snapshots+graph_users+time_entries+resourcestables — already populated by the engagement sync (engagement-dailyschedule, 6am).- shadcn primitives:
Card,Badge,Skeleton,EmptyState,Input,Button,Separator. All incomponents/ui/. lucide-reacticons already in deps (Settings, Search, Loader2, Users, etc.).IntersectionObserver— browser-native, no dep.relTime()helper inline inapp/mobile/tickets/page.tsx:29-37— not relevant here (no time-ago) but the helper file shows the pattern.
Established Patterns
- Mobile pages:
'use client'+useState+useEffect+fetch('/api/...') - API routes: NextResponse.json +
requireAuth()fromlib/auth-utils.ts - Postgres via
postgresClient.query()parameterized SQL; manual snake_case → camelCase transform. - TypeScript interfaces exported from API route file alongside the
handler; pages consume via
import type { ... } from '@/app/api/.../route'. - IntersectionObserver pattern from
app/mobile/tickets/page.tsxandapp/mobile/analyzer/page.tsx(Phase 4 / Phase 6). - Skeleton render pattern from
components/mobile/AnalyzerRowSkeleton.tsx/components/mobile/TicketRowSkeleton.tsx.
Integration Points
app/mobile/layout.tsx(Phase 2) renders the shell —/mobile/engagementdocks inside it. Bottom nav is unaffected (Engagement is NOT a tab per ENG-09).MoreDrawer.tsx(Phase 2) already routes to/mobile/engagementper DRAWER-03 — verify with a manual tap during human UAT.- The new
/api/mobile/engagement/*endpoints sit under existing Better Auth + middleware (already requires auth for/api/*routes not in the public list permiddleware.ts). - Phase 8 will build
/mobile/engagement/[userId]— Phase 7's user row already wires<Link href={\/mobile/engagement/${graphUserId}`}>` so the navigation works as soon as Phase 8 lands.
</code_context>
## Specific Ideas- Mirror Phase 6's pattern of "reuse existing data endpoint where possible, add a thin /api/mobile/* endpoint only where the existing shape doesn't fit." Two new endpoints here is the minimum: one for totals (existing endpoint returns averages), one for time-series (no existing endpoint).
- Sparkline should feel calm, not flashy: a single thin line, no dots, no axis labels, no animation, ~3rem tall. Linear's "monthly active" sparklines are a good reference.
- Avatar initials: same algorithm as
app/engagement/page.tsxif it has one; otherwise first letter of first word + first letter of last word ofdisplayName. Stick to upper-case, neutral background. - Hours bar: keep it 6px tall (
h-1.5); width should make it obvious who's putting in the most time without being a chart on its own. Don't add a numeric label inside the bar — the right-aligned hours number above the bar already shows the value.
- "Today" period chip — engagement_snapshots only aggregate at
D7/D30/D90. A D1 sync would require adding a new period_type and
updating
engagement-sync-service.ts. Defer to a future phase. - Server-side search on the per-employee list — client-side filter is fine at ≤50 staff. Add server-side search if the team grows past ~150 staff and the page=1 fetch can no longer cover all visible results.
- Multi-series trend chart (Graph hours vs Autotask hours overlaid) — explicitly out-of-scope per spec §6.5 ("no multi-series chart on mobile"). Desktop already has this.
- Per-row drill-down to the user profile — that's Phase 8 (ENG-06..08).
Phase 7 wires the
<Link>only. - Sort by zoom calls / meetings / emails — outside ENG-04's three sort axes. Add if managers request.
- "Engagement sync now" button on mobile — admin action, lives on the
desktop
/adminpage. Mobile is read-only (REQ EDIT-01). - IDOR fix on existing
/api/engagement/*endpoints — inherited risk from the existing product. Out of scope per spec §7 ("Restyling or replacing the desktop pages reachable from the More drawer — desktop pages stay as they are"). Track in STATE.md follow-up; recommend a future security phase. - Response virtualization for the per-employee list — page size is 50
and most teams have ≤50 staff, so a single rendered list is fine for
v1. Add
react-windowor similar only if perf measurement warrants.
Phase: 07-engagement-overview-new Context gathered: 2026-05-04