From 8b6e8b27b199efca3f3c0d167e01cefd97beff97 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sun, 3 May 2026 22:22:41 -0400 Subject: [PATCH] docs(07): UI design contract for Engagement Overview --- .../07-engagement-overview-new/07-UI-SPEC.md | 612 ++++++++++++++++++ 1 file changed, 612 insertions(+) create mode 100644 .planning/phases/07-engagement-overview-new/07-UI-SPEC.md diff --git a/.planning/phases/07-engagement-overview-new/07-UI-SPEC.md b/.planning/phases/07-engagement-overview-new/07-UI-SPEC.md new file mode 100644 index 0000000..3861d29 --- /dev/null +++ b/.planning/phases/07-engagement-overview-new/07-UI-SPEC.md @@ -0,0 +1,612 @@ +--- +phase: 7 +slug: engagement-overview-new +status: draft +shadcn_initialized: true +preset: new-york / neutral base / CSS variables +created: 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 | `
` 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 | +| 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-base` (16px) | `font-semibold` (600) | `leading-snug` (1.375) | IBM Plex Sans | "Engagement" heading — renders in page body, scrolls away [D-31] | +| 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] Three declared sizes: `text-sm` (14px), `text-xs` (12px), `text-[10px]` (10px). Plus `text-2xl` for big summary numbers and `text-base` for the page H1. + +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: ` + +``` + +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 `` 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|D30|D90`, returns 4 totals + `configured: boolean`. Exports `MobileEngagementSummary`. | +| `app/api/mobile/engagement/trend/route.ts` | GET handler — requireAuth, accepts `?period=D7|D30|D90`, returns `{ points: SparklinePoint[] }`. Exports `EngagementTrendResponse`. | + +Component comment block convention (Phase 3/4/5/6 pattern): +```typescript +/* 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. + +```typescript +// 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 +} +``` + +```typescript +// 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 +} +``` + +```typescript +// 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. `

Engagement

` — `text-base font-semibold` — scrolls away +2. `` — sticky `top-0 z-10`, scrolls header out but chips stay +3. Summary cards section — `space-y-3` between 4 `` instances +4. `` — compact sparkline card +5. Sort + search controls — `space-y-2`: `` then `` +6. User list — `divide-y` bordered rounded container of `` instances +7. 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-*` 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)*