wulf-pulse/.planning/phases/07-engagement-overview-new/07-UI-SPEC.md

30 KiB
Raw Blame History

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 to D7, D30, D90 respectively) [D-04]
  • Default active: "30d" [D-04]
  • No "today" chip — data layer doesn't support D1 [D-07]
  • Each chip: <button> element, active state bg-primary text-primary-foreground, inactive bg-muted text-foreground hover:bg-muted/80, shape rounded-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:

  1. Active users — count label: "Active users"
  2. Total Graph hours — count label: "Total Graph hours"
  3. Total Autotask hours — count label: "Total Autotask hours"
  4. 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). SVG viewBox="0 0 {width} 48" (read actual width from ref or use viewBox="0 0 300 48" with preserveAspectRatio="none").
  • Stroke: stroke-primary (Tailwind token, references --primary CSS variable). stroke-width="2". fill="none".
  • Baseline: horizontal <line> at y=46 (2px from bottom), stroke="currentColor" with className="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). When points.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 largest billableHours value in the current loaded page set, computed client-side after fetch. If maxRowHours == 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-y on 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
  • IntersectionObserver with rootMargin: '200px' fires fetchNextPage() 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 when hasMore, aria-label="Load more team members"
  • Loading indicator: Loader2 w-4 h-4 animate-spin text-muted-foreground mx-auto my-2 centered 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/users fixed 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+1 fetch 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" with aria-pressed={isActive} on each chip
  • Sort chips: role="button" with aria-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 have aria-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 the Xh label)
  • 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:

  1. <h1>Engagement</h1>text-sm font-semibold — scrolls away (matches Row primary scale to keep declared font size count at 4)
  2. <EngagementPeriodChips> — sticky top-0 z-10, scrolls header out but chips stay
  3. Summary cards section — space-y-3 between 4 <EngagementSummaryCard> instances
  4. <EngagementHoursSparkline> — compact sparkline card
  5. Sort + search controls — space-y-2: <EngagementSortChips> then <EngagementSearchInput>
  6. User list — divide-y bordered rounded container of <EngagementUserRow> instances
  7. Sentinel div + Load more button (when hasMore)

"Not configured" banner replaces sections 37 when configured: false. "Empty state" replaces sections 67 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-* and lib/services/engagement-sync-service.ts — read-only consumption [D-36]
  • app/mobile/layout.tsx (Phase 2 shell) — engagement page docks inside it, no changes needed
  • components/mobile/MoreDrawer.tsx — already routes to /mobile/engagement per 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)