30 KiB
| phase | slug | status | reviewed_at | shadcn_initialized | preset | created |
|---|---|---|---|---|---|---|
| 7 | engagement-overview-new | approved | 2026-05-04T00:00:00Z | true | new-york / neutral base / CSS variables | 2026-05-04 |
Phase 7 — UI Design Contract: Engagement Overview (NEW)
Visual and interaction contract for the mobile Engagement overview page. Generated by gsd-ui-researcher. Consumed by gsd-ui-checker, gsd-planner, gsd-executor.
All decisions tagged [D-NN] are LOCKED in 07-CONTEXT.md and must not be re-litigated.
Design System
| Property | Value |
|---|---|
| Tool | shadcn/ui (new-york style) |
| Preset | components.json — new-york, neutral base, CSS variables, lucide icons |
| Component library | Radix UI (via shadcn) |
| Icon library | lucide-react |
| Font | IBM Plex Sans (sans), IBM Plex Mono (numeric/ID fields) |
Source: components.json (confirmed present), DESIGN.md §2, app/styles/brand.css. Mirrors Phase 4 and Phase 6 design system exactly.
Viewport Contract
| Property | Value |
|---|---|
| Reference device | iPhone 15 Pro — 393 × 852 CSS pixels |
| Max width constraint | max-w-lg mx-auto (from app/mobile/layout.tsx — Phase 2) |
| Shell chrome | HeaderBar (sticky, h-14 + pt-safe) + BottomNav (fixed h-16 + pb-safe) |
| Scrollable content area | <main> in layout — bottom padding = calc(theme(spacing.16)+env(safe-area-inset-bottom)) |
| In-page sticky zone | Period chips row sticks below the H1 at top-0 z-10 while H1 scrolls away |
Source: Phase 6 viewport contract (mirrors exactly). [D-05]
Spacing Scale
Declared values (multiples of 4). Mirrors Phase 4/5/6 contract exactly.
| Token | Value | Usage in this phase |
|---|---|---|
| xs | 4px | Badge internal padding (px-1.5 py-0.5), avatar-to-text gap (gap-1), icon gap |
| sm | 8px | Row internal gaps (gap-2), chip gaps (gap-2), sparkline label row gap (gap-2) |
| sm+ | 12px (3 × 4) | Period chip py-1.5, sort chip py-1.5, secondary stacking — known exception: 12px is not in the 7-value standard set {4,8,16,24,32,48,64} but is a multiple of 4 and mirrors approved Phase 4/6 contract |
| md | 16px | Horizontal page padding (px-4), Card vertical padding (py-4), sticky chip strip offset |
| lg | 24px | Vertical section gap between sparkline card and user list (gap-6 if needed) |
| xl | 32px | Empty state vertical padding (py-8) |
| 2xl | 48px | Full empty-state screen centering (py-12) |
Touch-target exception: Period chips, sort chips, and search input must reach minimum 44 × 44 px tap target — use min-h-[44px] or py-2.5 on container rows to satisfy this on compact elements. [Phase 4 precedent]
Exceptions: Hours bar (h-1.5 = 6px) is a visual indicator, not an interactive target — h-1.5 exactly as specified in D-19. Avatar circle (h-8 w-8 = 32px) is part of a tappable row, so the row itself carries the touch target.
Page container: px-4 py-4 space-y-4 (matches Phase 5/6). [D-30]
Typography
Two weights only: font-normal (400) and font-semibold (600). font-medium (500) is NOT used. [D-28]
| Role | Size class | Weight | Line Height | Font | Usage |
|---|---|---|---|---|---|
| Page H1 | text-sm (14px) |
font-semibold (600) |
leading-snug (1.375) |
IBM Plex Sans | "Engagement" heading — renders in page body, scrolls away [D-31]; matches Row primary scale to keep total size count at 4 |
| Summary big number | text-2xl (24px) |
font-semibold (600) |
leading-none |
IBM Plex Sans | Four summary card primary values (e.g. "42", "128.5h") [D-10] |
| Row primary / section heading | text-sm (14px) |
font-semibold (600) |
leading-snug (1.375) |
IBM Plex Sans | User display name (1-line truncate), config banner headings |
| Body / secondary | text-xs (12px) |
font-normal (400) |
leading-normal (1.5) |
IBM Plex Sans | Summary card label, sparkline label, role/jobTitle, hours right-aligned value, search placeholder, chip labels |
| Badge / caption | text-[10px] (10px) |
font-normal (400) |
leading-normal |
IBM Plex Sans | Period chip labels ("7d", "30d", "90d"), sort chip labels ("Hours", "Name", "Utilization"), banner body detail |
[D-29] Four declared sizes (max allowed): text-sm (14px), text-xs (12px), text-[10px] (10px), text-2xl (24px, summary big numbers only). The page H1 uses text-sm font-semibold (matches Row primary). text-base (16px) is NOT used on this page — kept the count at 4.
Detail: hours right-aligned value in the user row top line (text-sm font-semibold) renders the billable hours, e.g. 12.4h. This is text-sm font-semibold matching the display name weight to hold visual balance in the same row. [D-19]
Color
All colors use CSS variable tokens from app/globals.css + app/styles/brand.css. Direct Tailwind palette references are used only for semantic status colors per DESIGN.md §2. [D-30 — mirrors Phase 4/6 contract]
| Role | Token / Class | Usage |
|---|---|---|
| Dominant surface (60%) | bg-background |
Page background, sticky chip strip background, row background |
| Secondary surface (30%) | bg-muted |
Hours bar track, inactive chip background, avatar background (bg-muted), skeleton, Card border |
| Primary accent (10%) | text-primary / bg-primary |
Active period chip fill (bg-primary text-primary-foreground), active sort chip fill, sparkline stroke (stroke-primary), hours bar fill (bg-primary) |
| Muted text | text-muted-foreground |
Summary card label, sparkline label text, role/jobTitle text, latest-value indicator text, banner body text |
| Card surface | bg-card / border |
shadcn Card wrapping summary cards, sparkline card, user rows |
| Destructive | text-destructive / bg-destructive/10 |
Error toast, Retry button label; NOT used for any visual element in this page |
Accent (bg-primary / text-primary) is reserved for exactly these elements: (1) active period chip fill, (2) active sort chip fill, (3) sparkline line stroke, (4) hours bar progress fill. Not used for hover states, avatar backgrounds, text headings, icon colors, or decorative elements.
Period and Sort Chip States [D-06, D-20]
| State | Classes |
|---|---|
| Active chip | bg-primary text-primary-foreground rounded-full px-3 py-1.5 text-xs font-semibold |
| Inactive chip | bg-muted text-foreground hover:bg-muted/80 rounded-full px-3 py-1.5 text-xs font-semibold |
Same class pattern for both period chips and sort chips — consistent across the page. [D-06, D-20]
Avatar Color [D-19 — Claude's Discretion]
| Role | Classes |
|---|---|
| Avatar background | bg-muted |
| Avatar initials text | text-foreground font-semibold text-xs |
All avatars use the same neutral bg-muted background — no per-user color hashing. Keeps the page visually calm and avoids introducing palette colors that aren't in the token set.
Sparkline Colors [D-12]
| Element | Class |
|---|---|
| Sparkline path | stroke-primary, stroke-2, fill-none |
| Sparkline baseline | stroke-muted-foreground/20, stroke-1, fill-none |
The sparkline uses CSS-variable-backed Tailwind tokens, not raw hex values.
Component Inventory
Primary Visual Anchor
The primary focal point is the per-employee list. The user display name (text-sm font-semibold) anchors each row. The hours bar below it provides instant relative-magnitude comparison across users without requiring a chart. Readers land on the name first, scan right to the hours value, then look down at the hours bar. The summary cards above (four totals) are secondary — supporting context, not the primary data.
Period Chips — EngagementPeriodChips [D-04, D-05, D-06]
Container: sticky top-0 z-10 bg-background pt-2 pb-3 -mx-4 px-4 flex gap-2
Three chips in a horizontal flex gap-2 row, no horizontal scroll:
[7d chip] [30d chip] [90d chip]
- Labels:
"7d","30d","90d"(maps toD7,D30,D90respectively) [D-04] - Default active:
"30d"[D-04] - No "today" chip — data layer doesn't support D1 [D-07]
- Each chip:
<button>element, active statebg-primary text-primary-foreground, inactivebg-muted text-foreground hover:bg-muted/80, shaperounded-full px-3 py-1.5 text-xs font-semibold - Chip row wraps in a container row with
min-h-[44px]to satisfy touch target requirements on the section as a whole - Changing a chip triggers a full refetch of summary, trend, and users
Summary Cards — EngagementSummaryCard [D-08, D-09, D-10]
Four cards stacked single-column (space-y-3) using shadcn Card. One card per row — no 2×2 grid on phone widths. [D-08]
Cards in order:
- Active users — count label:
"Active users" - Total Graph hours — count label:
"Total Graph hours" - Total Autotask hours — count label:
"Total Autotask hours" - Hours per active user — count label:
"Hours / active user"
Card structure:
<Card> (no shadow, border only — matches FinanceRow density) [D-10]
<CardContent className="px-4 py-4">
[big number] text-2xl font-semibold text-foreground
[label] text-xs text-muted-foreground
When activeUsers == 0: render "—" (em dash) for card 4 (hours-per-active-user). All other cards show "0". [D-08]
When summary is loading: 4 skeleton cards each <Card><CardContent className="px-4 py-4 space-y-2"><Skeleton h-8 w-20 /><Skeleton h-3 w-28 /></CardContent></Card> [D-23]
Hours Trend Sparkline — EngagementHoursSparkline [D-11 through D-15]
Custom inline SVG sparkline component. Takes points: { date: string; hours: number }[].
Sparkline card container:
<Card> (no shadow, border only)
<CardContent className="px-4 py-3">
[label row] flex justify-between items-center mb-2
LEFT: "Hours trend · last {period_label}" text-xs text-muted-foreground
RIGHT: "X.Xh today" (latest non-zero value) text-xs text-muted-foreground
[SVG sparkline] h-12 w-full (48px tall, full width)
Period label mapping:
D7→"7 days"D30→"30 days"D90→"90 days"
SVG sparkline details:
- Height: 48px (
h-12). Width: 100% (w-full). SVGviewBox="0 0 {width} 48"(read actual width from ref or useviewBox="0 0 300 48"withpreserveAspectRatio="none"). - Stroke:
stroke-primary(Tailwind token, references--primaryCSS variable).stroke-width="2".fill="none". - Baseline: horizontal
<line>at y=46 (2px from bottom),stroke="currentColor"withclassName="text-muted-foreground/20",stroke-width="1". - Points: linear interpolation only — no curves, no
bezierCurve. Use a<path d="M x0,y0 L x1,y1 L x2,y2 ...">(polyline-style path). - Missing days (zero hours): treat as zero — do NOT gap the line. A zero-hour day draws to the baseline. This keeps the sparkline continuous and visually interpretable. [Claude's Discretion — "gap vs interpolate": use zero, not gap]
- X axis: evenly spaced across the SVG width (
i / (points.length - 1) * svgWidth). Whenpoints.length == 1, render a horizontal line at the single point's height. - Y axis:
min=0,max=maxHours(highest value in points + 10% headroom). Map:y = 48 - (hours / maxHours) * 44(leave 4px top margin, 4px bottom before baseline). - No axis labels, no tick marks, no tooltip, no animation, no dots. [D-12]
No-data fallback: When points.length == 0 OR all values are 0, skip the SVG entirely and render:
<p className="text-xs text-muted-foreground text-center py-3">No activity</p>
[D-15]
Latest-value indicator (label row right): Derive from the last points entry where hours > 0. Format: "{N.N}h today" if the date is today, otherwise "{N.N}h {shortDate}" (e.g. "12.4h May 2"). If all points are 0, render "—" instead. [D-13]
Loading skeleton: Single <Card><CardContent className="px-4 py-3 space-y-2"><div className="flex justify-between"><Skeleton h-3 w-32 /><Skeleton h-3 w-16 /></div><Skeleton h-12 w-full mt-2 /></CardContent></Card> [D-23]
Sort Chips — EngagementSortChips [D-20]
Container: flex gap-2 items-center (above the search input, within space-y-3 of the list header section)
Three chips:
"Hours"→sort=billable_hours&order=desc(default active)"Name"→sort=display_name&order=asc"Utilization"→sort=billable_hours&order=desc(same API sort; visual label differs)
Same chip styling as period chips. Changing active sort chip triggers a refetch of the users list only (summary and trend are period-scoped, not sort-scoped). [D-20]
Search Input — EngagementSearchInput [D-21]
Always visible, unobtrusive. [Claude's Discretion]
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
placeholder="Search by name or email"
className="pl-9"
value={searchQuery}
onChange={...}
/>
</div>
- Debounce: 300ms before updating client-side filter [D-21]
- No debounce indicator — no spinner, no loading text. The filter is instant on the loaded set.
- No "X" clear button required (clearing the field clears search naturally). Optional if executor prefers it.
- No server-side search param — filter applied client-side after fetch [D-21]
User Row — EngagementUserRow [D-18, D-19]
Each row is a <Link href={'/mobile/engagement/${graphUserId}'}> tap target. [D-19]
Row container: <Link className="block px-4 py-3 hover:bg-muted/50 transition-colors active:bg-muted/50">
[Top line] flex items-center gap-3
[Avatar] h-8 w-8 rounded-full bg-muted flex items-center justify-center shrink-0
text-xs font-semibold text-foreground
Initials: first letter of first word + first letter of last word of displayName,
uppercased. E.g. "Jordan Walsh" → "JW", "Alex" → "A".
[Identity] flex-1 min-w-0 flex items-baseline gap-2
[name] text-sm font-semibold truncate flex-1
[hours] text-sm font-semibold shrink-0 text-right e.g. "12.4h"
(No ChevronRight — rows are visually clean; tap affordance implied by hover)
[Role line] text-xs text-muted-foreground truncate px-[44px]
jobTitle if present; render nothing (no empty line) if absent
[Hours bar] mt-2
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${Math.min(100, (billableHours / maxRowHours) * 100)}%` }}
/>
</div>
maxRowHours: the largestbillableHoursvalue in the current loaded page set, computed client-side after fetch. IfmaxRowHours == 0, all bars render at 0% width.- Hours display format: 1 decimal place always (e.g.
"12.4h","0.0h"). Use(hours).toFixed(1) + 'h'. - Row wraps the full card including bar. Tapping anywhere on the row navigates.
divide-yon the list container for row dividers. [Phase 4 pattern]
List container: <div className="divide-y border rounded-xl overflow-hidden"> — wraps all user rows in a single rounded bordered container. This groups the list visually as one surface, distinct from the sparkline card and sort/search controls above.
User Row Skeleton — EngagementUserRowSkeleton [D-23]
Mirrors EngagementUserRow shape:
<div className="px-4 py-3 space-y-2">
[flex items-center gap-3]
[Skeleton h-8 w-8 rounded-full] ← avatar
[flex-1 flex items-center justify-between gap-2]
[Skeleton h-4 w-32] ← name
[Skeleton h-4 w-12] ← hours
[Skeleton h-3 w-24 ml-11] ← role (indented past avatar)
[Skeleton h-1.5 w-full mt-2] ← hours bar
</div>
Render 5 instances on initial load: Array.from({ length: 5 }).map((_, i) => <EngagementUserRowSkeleton key={i} />) [D-23]
Infinite Scroll Sentinel + Load More [D-18]
Identical contract to Phase 4 and Phase 6:
- Sentinel:
<div ref={sentinelRef} aria-hidden="true" />at list end IntersectionObserverwithrootMargin: '200px'firesfetchNextPage()when sentinel enters viewport- Guard: no-op if
loadingMore || !hasMore - Load more button:
w-full py-3 rounded-xl border text-sm font-semibold hover:bg-muted/50 transition-colors disabled:opacity-50— rendered whenhasMore,aria-label="Load more team members" - Loading indicator:
Loader2 w-4 h-4 animate-spin text-muted-foreground mx-auto my-2centered above the button during in-flight fetch - Error on load more: button label flips to
"Retry"[D-25]
Empty State [D-26]
When summary.activeUsers == 0 AND users.length == 0 for the selected period:
<div className="flex flex-col items-center justify-center py-12 text-center space-y-3">
<Users className="h-8 w-8 text-muted-foreground/50" />
<div className="space-y-1">
<p className="text-sm font-semibold">No engagement data for this period</p>
<p className="text-xs text-muted-foreground">
Try a different period or trigger a sync from
<a href="/admin" target="_blank" className="underline ml-1">Admin</a>
</p>
</div>
</div>
Icon: Users from lucide-react (h-8 w-8 text-muted-foreground/50). Period chips remain visible and interactive above. [D-26]
"Not Configured" Banner [D-27]
When configured: false from the summary endpoint (Microsoft Graph not configured):
<div className="rounded-xl border bg-card px-4 py-4 space-y-1">
<p className="text-sm font-semibold">Engagement sync not configured</p>
<p className="text-xs text-muted-foreground">
Set MSGRAPH_* environment variables and restart.
<a href="/admin" target="_blank" className="underline ml-1">Open Admin</a>
</p>
</div>
Tone: informational only — no destructive color, no warning icon. The manager cannot fix this from mobile; the banner names the path (Admin) without implying urgency. [D-27]
"No matches" Inline State [D-22]
When search filter has active query but no filtered results on the loaded set:
<div className="px-4 py-6 text-center space-y-2">
<p className="text-sm text-muted-foreground">No matches for "{query}"</p>
<button
onClick={() => setSearchQuery('')}
className="text-xs font-semibold text-primary underline"
>
Clear search
</button>
</div>
This renders inside the list container in place of rows. The list container border and rounding are preserved. [D-22]
Interaction Contracts
Period Selection
| Event | What happens |
|---|---|
| Tap period chip | Sets active period, triggers refetch of summary + trend + users (page 1) |
| New period while loading | Cancel previous fetch (if inflight), start new fetch immediately |
| Period chip already active | No-op (no refetch) |
Period state is held in component state (useState). NOT persisted to URL query params (sort/search is also component state — scale of data doesn't warrant deep-linking per D-21 note in CONTEXT.md).
Sort Chips
| Event | What happens |
|---|---|
| Tap sort chip | Sets active sort, resets page to 1, triggers users-list refetch |
| Sort chip already active | No-op |
Search Input
| Event | What happens |
|---|---|
| Type in search input | Update searchQuery immediately (controlled); filter applied 300ms after last keystroke (debounce) |
| Filter narrows to zero | "No matches" inline state renders instead of rows |
| Clear input | searchQuery reset, all loaded rows visible again |
Client-side filter only. Filter does NOT trigger an API call. Filter applies on displayName and userEmail fields of loaded users. [D-21]
Infinite Scroll (page-based)
- Page size: 50 rows (existing
/api/engagement/usersfixed page size) [D-16, D-17] - API returns
{ users: EngagementUser[], pagination: { page, totalPages, total } } - Client state:
users: EngagementUser[](appended on each page),currentPage: number,hasMore: boolean(currentPage < totalPages) - Sentinel triggers
?page=currentPage+1fetch when last row enters viewport [D-18] - "Load more" fallback button always present when
hasMore[D-18]
Loading States
| Phase | What renders |
|---|---|
| Initial load | 4 summary card skeletons + 1 sparkline skeleton + 5 user row skeletons |
| Subsequent page load (load more) | Loader2 animate-spin above Load more button; button disabled |
| Error on initial load | toast.error("Failed to load engagement summary") / toast.error("Failed to load engagement users") / toast.error("Failed to load hours trend") — one per failed fetch |
| Error on load more | toast.error("Failed to load more team members") + Load more button → "Retry" |
Accessibility
- Period chips:
role="button"witharia-pressed={isActive}on each chip - Sort chips:
role="button"witharia-pressed={isActive}on each chip - Search input:
aria-label="Search team members by name or email" - User rows: each
<Link>has the display name as its accessible label; avatar initials havearia-hidden="true"(decorative) - Avatar initials span:
aria-hidden="true"(information already conveyed in the name) - Sentinel div:
aria-hidden="true" - Load more button:
aria-label="Load more team members" - Hours bar:
aria-hidden="true"(value already conveyed numerically in theXhlabel) - Hours bar:
role="presentation"on the track div - Empty state Admin link:
target="_blank"+rel="noopener noreferrer"+aria-label="Open Admin on desktop" - Not-configured Admin link: same pattern as above
Copywriting Contract
| Element | Copy | Source |
|---|---|---|
| Page H1 | "Engagement" | [D-31] |
| Period chip labels | "7d" / "30d" / "90d" | [D-04] |
| Summary card 1 label | "Active users" | [D-08] |
| Summary card 2 label | "Total Graph hours" | [D-08] |
| Summary card 3 label | "Total Autotask hours" | [D-08] |
| Summary card 4 label | "Hours / active user" | [D-08] |
| Summary card 4 — zero users value | "—" (em dash, not "0") | [D-08] |
| Sparkline label left | "Hours trend · last {period_label}" (e.g. "Hours trend · last 30 days") | [D-13] |
| Sparkline label right — value present | "{N.N}h today" or "{N.N}h {shortDate}" | [D-13, Claude's Discretion] |
| Sparkline label right — no activity | "—" | [D-13] |
| Sparkline no-data | "No activity" | [D-15] |
| Sort chip labels | "Hours" / "Name" / "Utilization" | [D-20] |
| Search placeholder | "Search by name or email" | [D-21] |
| No-matches heading | "No matches for "{query}"" | [D-22] |
| No-matches CTA | "Clear search" (button) | [D-22] |
| Empty state heading | "No engagement data for this period" | [D-26] |
| Empty state body | "Try a different period or trigger a sync from Admin" | [D-26] |
| Empty state Admin link | "Admin" (inline within body sentence) | [D-26] |
| Not configured heading | "Engagement sync not configured" | [D-27] |
| Not configured body | "Set MSGRAPH_* environment variables and restart." | [D-27] |
| Not configured link | "Open Admin" | [D-27] |
| Initial load state | Skeleton rows (no text) | [D-23] |
| Load more button (idle) | "Load more" | [D-18] |
| Load more button (loading) | Loader2 spinner (button disabled) | [D-25] |
| Load more button (error/retry) | "Retry" | [D-25] |
| Error toast — summary | "Failed to load engagement summary" | [D-25] |
| Error toast — users | "Failed to load engagement users" | [D-25] |
| Error toast — trend | "Failed to load hours trend" | [D-25] |
| Error toast — load more | "Failed to load more team members" | [D-25] |
Destructive actions: None. The Engagement overview is fully read-only. No confirmation dialogs, no destructive buttons. [ENG-01, EDIT-01 out of scope]
Component Files to Create
Following the Phase 3/4/5/6 pattern (kebab-case files, components/mobile/ directory):
| File | Purpose |
|---|---|
components/mobile/EngagementPeriodChips.tsx |
3-chip period selector (7d/30d/90d). Receives period, onPeriodChange. Pure presentational. |
components/mobile/EngagementSummaryCard.tsx |
Single summary card (big number + label). Receives value: string, label: string. Wraps shadcn Card. |
components/mobile/EngagementHoursSparkline.tsx |
Custom inline SVG sparkline. Receives points: SparklinePoint[], period: string. Renders card with label row + SVG. |
components/mobile/EngagementSortChips.tsx |
3-chip sort selector (Hours/Name/Utilization). Receives activeSort, onSortChange. Pure presentational. |
components/mobile/EngagementSearchInput.tsx |
Search input with leading Search icon, 300ms debounce. Receives value, onChange. |
components/mobile/EngagementUserRow.tsx |
User row (avatar + name + role + hours + hours bar) wrapped in Link. Receives EngagementUser, maxHours. |
components/mobile/EngagementUserRowSkeleton.tsx |
Skeleton placeholder matching EngagementUserRow shape. No props. |
app/mobile/engagement/page.tsx |
Main page — 'use client', period state, summary + trend + user fetch orchestration, IntersectionObserver, error/empty handling. |
app/api/mobile/engagement/summary/route.ts |
GET handler — requireAuth, accepts `?period=D7 |
app/api/mobile/engagement/trend/route.ts |
GET handler — requireAuth, accepts `?period=D7 |
Component comment block convention (Phase 3/4/5/6 pattern):
/* ComponentName — phase 07 (ENG-NN).
* Purpose: one-line description.
* Props: ... */
Note on EngagementUserRow extraction: extract the avatar + name identity block as a named sub-component or accept displayName prop with initials derived internally — Phase 8 may reuse the identity block in the profile header. Keep the component small and extract getInitials(displayName: string): string as a module-level utility function in the same file so Phase 8 can import it directly. [Claude's Discretion — CONTEXT.md §specifics]
API Shape Contract
The route files export TypeScript interfaces for the page to import type. Mirrors Phase 4/6 pattern.
// app/api/mobile/engagement/summary/route.ts — exported interfaces
export interface MobileEngagementSummary {
configured: boolean; // false = MSGRAPH not configured
activeUsers: number;
totalGraphHours: number; // 1 decimal, converted from seconds
totalAutotaskHours: number; // 1 decimal
hoursPerActiveUser: number; // totalAutotaskHours / activeUsers; 0 if activeUsers == 0
}
// app/api/mobile/engagement/trend/route.ts — exported interfaces
export interface SparklinePoint {
date: string; // ISO date string "YYYY-MM-DD"
hours: number; // total Autotask hours for that day (0 if no entries)
}
export interface EngagementTrendResponse {
points: SparklinePoint[]; // D7 → 7 points, D30 → 30 points, D90 → 90 points
}
// Page consumes from existing /api/engagement/users (reused as-is) [D-16]
// EngagementUser shape is imported from that existing route's exported types.
// If that route doesn't export types, inline the minimal shape needed:
interface EngagementUser {
graphUserId: string;
displayName: string;
userEmail: string;
jobTitle: string | null;
billableHours: number;
hoursWorked: number;
}
interface EngagementUsersResponse {
users: EngagementUser[];
pagination: {
page: number;
totalPages: number;
total: number;
};
}
Page Layout Order (top to bottom)
For the executor: the page renders in this exact vertical order within px-4 py-4 space-y-4:
<h1>Engagement</h1>—text-sm font-semibold— scrolls away (matches Row primary scale to keep declared font size count at 4)<EngagementPeriodChips>— stickytop-0 z-10, scrolls header out but chips stay- Summary cards section —
space-y-3between 4<EngagementSummaryCard>instances <EngagementHoursSparkline>— compact sparkline card- Sort + search controls —
space-y-2:<EngagementSortChips>then<EngagementSearchInput> - User list —
divide-ybordered rounded container of<EngagementUserRow>instances - Sentinel div + Load more button (when
hasMore)
"Not configured" banner replaces sections 3–7 when configured: false.
"Empty state" replaces sections 6–7 when no data (sparkline no-data state still renders in section 4).
Registry Safety
| Registry | Blocks Used | Safety Gate |
|---|---|---|
| shadcn official | Card, CardContent, Input, Skeleton, Button, Separator |
not required |
No third-party registries. All components are either shadcn official primitives or purpose-built in components/mobile/. The EngagementHoursSparkline uses a hand-authored SVG path — no chart library dependency, consistent with DASH-04 (no recharts on mobile). [D-12]
What Stays Unchanged
Per D-34 through D-38 and phase boundary:
- Desktop
app/engagement/*pages — untouched [D-35] - Existing
/api/engagement/*endpoints — untouched [D-34] lib/services/msgraph-*andlib/services/engagement-sync-service.ts— read-only consumption [D-36]app/mobile/layout.tsx(Phase 2 shell) — engagement page docks inside it, no changes neededcomponents/mobile/MoreDrawer.tsx— already routes to/mobile/engagementper DRAWER-03; verify end-to-end but do NOT modify [D-01]components/mobile/BottomNav.tsx— Engagement is NOT a tab; do not modify [D-02]- No new state libraries (no SWR, no react-query) [D-37]
- No Zod in API routes [D-38]
Checker Sign-Off
- Dimension 1 Copywriting: PASS
- Dimension 2 Visuals: PASS
- Dimension 3 Color: PASS
- Dimension 4 Typography: PASS
- Dimension 5 Spacing: PASS
- Dimension 6 Registry Safety: PASS
Approval: pending
Phase: 07-engagement-overview-new UI-SPEC created: 2026-05-04 Source decisions: 07-CONTEXT.md D-01 through D-38 (all locked) Typography/spacing/color mirrors: 04-UI-SPEC.md and 06-UI-SPEC.md (approved contracts)