phase
plan
type
wave
depends_on
files_modified
autonomous
requirements
must_haves
07-engagement-overview-new
02
execute
2
components/mobile/EngagementPeriodChips.tsx
components/mobile/EngagementSummaryCard.tsx
components/mobile/EngagementHoursSparkline.tsx
components/mobile/EngagementSortChips.tsx
components/mobile/EngagementSearchInput.tsx
components/mobile/EngagementUserRow.tsx
components/mobile/EngagementUserRowSkeleton.tsx
true
ENG-02
ENG-03
ENG-04
ENG-05
truths
artifacts
key_links
EngagementPeriodChips renders three chips '7d' / '30d' / '90d' with active styling derived from prop
EngagementSummaryCard renders big number (text-2xl font-semibold) + label (text-xs text-muted-foreground)
EngagementHoursSparkline renders an inline SVG path when given non-empty points; renders 'No activity' fallback when empty/all-zero
EngagementSortChips renders three chips 'Hours' / 'Name' / 'Utilization' with active state
EngagementSearchInput renders shadcn Input with leading Search icon and 300ms debounce on onChange
EngagementUserRow renders a Link with avatar (initials)+name+role+hours+hours-bar; tap navigates to /mobile/engagement/[graphUserId]
EngagementUserRowSkeleton mirrors EngagementUserRow's shape
Module-level utility getInitials(displayName) is exported from EngagementUserRow.tsx for Phase 8 reuse
path
provides
exports
components/mobile/EngagementPeriodChips.tsx
3-chip period selector
EngagementPeriodChips
EngagementPeriodChipsProps
path
provides
exports
components/mobile/EngagementSummaryCard.tsx
Single summary card primitive
path
provides
exports
components/mobile/EngagementHoursSparkline.tsx
Custom SVG sparkline
path
provides
exports
components/mobile/EngagementSortChips.tsx
3-chip sort selector
EngagementSortChips
EngagementSortKey
path
provides
exports
components/mobile/EngagementSearchInput.tsx
Debounced search input
path
provides
exports
components/mobile/EngagementUserRow.tsx
User row card (avatar + name + hours + bar) wrapped in Link
EngagementUserRow
getInitials
EngagementUserRowProps
path
provides
exports
components/mobile/EngagementUserRowSkeleton.tsx
Skeleton matching EngagementUserRow shape
EngagementUserRowSkeleton
from
to
via
pattern
components/mobile/EngagementUserRow.tsx
/mobile/engagement/[graphUserId]
next/link href={`/mobile/engagement/${graphUserId}`}
/mobile/engagement/${
from
to
via
pattern
components/mobile/EngagementHoursSparkline.tsx
SparklinePoint type
import type from '@/app/api/mobile/engagement/trend/route'
from '@/app/api/mobile/engagement/trend/route'
from
to
via
pattern
components/mobile/EngagementSummaryCard.tsx
shadcn Card
@/components/ui/card
from '@/components/ui/card'
Build the seven phone-first components that the Engagement page (Plan 03) composes:
period chips, summary card, hours sparkline (custom SVG, no recharts per DASH-04),
sort chips, search input, user row, and user row skeleton. All components are pure
presentational primitives (no fetches, no toasts) — they receive data via props and
emit events via callbacks. The page in Plan 03 owns all orchestration.
Purpose: Lock the visual contract from UI-SPEC into reusable components. Every
Tailwind class string in this plan is copied verbatim from 07-UI-SPEC.md. This is
where typography (4 sizes, 2 weights), spacing, and color tokens become code.
Output:
7 new files under components/mobile/
Exports the getInitials(displayName) utility (Phase 8 reuse, per UI-SPEC §"Note on EngagementUserRow extraction")
Imports types from Plan 01's API routes (SparklinePoint)
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
@.planning/STATE.md
@.planning/ROADMAP.md
@.planning/REQUIREMENTS.md
@.planning/phases/07-engagement-overview-new/07-CONTEXT.md
@.planning/phases/07-engagement-overview-new/07-UI-SPEC.md
@CLAUDE.md
@DESIGN.md
@app/api/mobile/engagement/trend/route.ts
@components/mobile/AnalyzerFeedRow.tsx
@components/mobile/AnalyzerRowSkeleton.tsx
@components/mobile/FinanceRow.tsx
@components/ui/card.tsx
@components/ui/input.tsx
@components/ui/skeleton.tsx
@components/ui/badge.tsx
// From app/api/mobile/engagement/trend/route.ts (created by Plan 01):
import type { SparklinePoint } from '@/app/api/mobile/engagement/trend/route' ;
export interface SparklinePoint {
date : string ; // "YYYY-MM-DD"
hours : number ;
}
// EngagementUser shape — used by EngagementUserRow. The existing /api/engagement/users
// route does NOT export types, so define inline (mirrors UI-SPEC API Shape Contract):
export interface EngagementUser {
graphUserId : string ;
displayName : string ;
userEmail : string ; // existing endpoint returns `email` — Plan 03 maps this
jobTitle : string | null ;
billableHours : number ;
hoursWorked : number ;
}
'use client' ;
/* ComponentName — phase 07 (ENG-NN).
* Purpose: one-line description.
* Props: ... */
Task 1: Build EngagementPeriodChips + EngagementSortChips + EngagementSearchInput (chip + input primitives)
- .planning/phases/07-engagement-overview-new/07-UI-SPEC.md (Period Chips, Sort Chips, Search Input sections — typography, color tokens, copy strings, accessibility)
- .planning/phases/07-engagement-overview-new/07-CONTEXT.md (D-04, D-05, D-06, D-20, D-21, D-28, D-29)
- components/mobile/AnalyzerFeedRow.tsx (component file structure + 'use client' + comment header convention)
- components/ui/input.tsx (shadcn Input — confirm props accept className for pl-9)
components/mobile/EngagementPeriodChips.tsx, components/mobile/EngagementSortChips.tsx, components/mobile/EngagementSearchInput.tsx
Create three small `'use client'` components, all pure presentational. Use the EXACT class strings from UI-SPEC. No fetches, no toasts.
---
**File 1: `components/mobile/EngagementPeriodChips.tsx`** (per D-04, D-05, D-06)
```tsx
'use client';
/* EngagementPeriodChips — phase 07 (ENG-02).
* Purpose: 3-chip period selector (7d/30d/90d) sticky below the page H1.
* Maps 1:1 to data-layer period_type values D7/D30/D90 (D-04).
* Props: period, onPeriodChange. Pure presentational — page owns refetch logic. */
export type EngagementPeriod = 'D7' | 'D30' | 'D90';
export interface EngagementPeriodChipsProps {
period: EngagementPeriod;
onPeriodChange: (next: EngagementPeriod) => void;
}
const CHIPS: ReadonlyArray<{ value: EngagementPeriod; label: string }> = [
{ value: 'D7', label: '7d' },
{ value: 'D30', label: '30d' },
{ value: 'D90', label: '90d' },
];
export function EngagementPeriodChips({ period, onPeriodChange }: EngagementPeriodChipsProps) {
return (
<div className="sticky top-0 z-10 bg-background pt-2 pb-3 -mx-4 px-4 flex gap-2 min-h-[44px]">
{CHIPS.map(chip => {
const isActive = chip.value === period;
return (
<button
key={chip.value}
type="button"
role="button"
aria-pressed={isActive}
onClick={() => { if (!isActive) onPeriodChange(chip.value); }}
className={
isActive
? 'bg-primary text-primary-foreground rounded-full px-3 py-1.5 text-[10px] font-semibold'
: 'bg-muted text-foreground hover:bg-muted/80 rounded-full px-3 py-1.5 text-[10px] font-semibold'
}
>
{chip.label}
</button>
);
})}
</div>
);
}
```
**Notes (D-29 typography fix):** Chip text is `text-[10px]` (NOT `text-xs`) to satisfy UI-SPEC's typography table where "Badge / caption" uses `text-[10px]` for chip labels. The 4-size cap is preserved.
---
**File 2: `components/mobile/EngagementSortChips.tsx`** (per D-20)
```tsx
'use client';
/* EngagementSortChips — phase 07 (ENG-04).
* Purpose: 3-chip sort selector (Hours/Name/Utilization). Same chip styling as period chips.
* Maps to /api/engagement/users sort/order params per D-20.
* Props: activeSort, onSortChange. Pure presentational. */
export type EngagementSortKey = 'Hours' | 'Name' | 'Utilization';
export interface EngagementSortChipsProps {
activeSort: EngagementSortKey;
onSortChange: (next: EngagementSortKey) => void;
}
const SORTS: ReadonlyArray<EngagementSortKey> = ['Hours', 'Name', 'Utilization'];
export function EngagementSortChips({ activeSort, onSortChange }: EngagementSortChipsProps) {
return (
<div className="flex gap-2 items-center min-h-[44px]">
{SORTS.map(key => {
const isActive = key === activeSort;
return (
<button
key={key}
type="button"
role="button"
aria-pressed={isActive}
onClick={() => { if (!isActive) onSortChange(key); }}
className={
isActive
? 'bg-primary text-primary-foreground rounded-full px-3 py-1.5 text-[10px] font-semibold'
: 'bg-muted text-foreground hover:bg-muted/80 rounded-full px-3 py-1.5 text-[10px] font-semibold'
}
>
{key}
</button>
);
})}
</div>
);
}
```
**Per D-20 mapping (consumed by Plan 03):**
- `'Hours'` → `sort=billable_hours&order=desc`
- `'Name'` → `sort=display_name&order=asc`
- `'Utilization'` → `sort=billable_hours&order=desc` (same API sort; visual label differs)
The mapping itself is owned by the page (Plan 03), NOT this component.
---
**File 3: `components/mobile/EngagementSearchInput.tsx`** (per D-21)
```tsx
'use client';
/* EngagementSearchInput — phase 07 (ENG-04).
* Purpose: Search input with leading Search icon. Debounced 300ms before emitting onChange.
* Page applies the filter client-side on loaded users (D-21).
* Props: value (controlled string), onChange (debounced callback). */
import { useEffect, useState } from 'react';
import { Search } from 'lucide-react';
import { Input } from '@/components/ui/input';
export interface EngagementSearchInputProps {
value: string;
onChange: (next: string) => void;
}
export function EngagementSearchInput({ value, onChange }: EngagementSearchInputProps) {
// Local immediate state for the input; debounce flushes to onChange
const [local, setLocal] = useState<string>(value);
// Keep local state synced when the parent resets (e.g. "Clear search" CTA on no-matches state)
useEffect(() => {
setLocal(value);
}, [value]);
// 300ms debounce per D-21
useEffect(() => {
if (local === value) return;
const id = setTimeout(() => { onChange(local); }, 300);
return () => clearTimeout(id);
}, [local, value, onChange]);
return (
<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"
aria-hidden="true"
/>
<Input
type="search"
placeholder="Search by name or email"
aria-label="Search team members by name or email"
className="pl-9"
value={local}
onChange={(e) => setLocal(e.target.value)}
/>
</div>
);
}
```
**Per D-21:** Server-side search NOT used — the parent applies the filter client-side. This component just emits debounced changes.
npx tsc --noEmit --pretty
- All 3 files exist
- PeriodChips: container has all of `sticky top-0 z-10 bg-background pt-2 pb-3 -mx-4 px-4 flex gap-2`: `grep -E "sticky top-0 z-10 bg-background pt-2 pb-3 -mx-4 px-4" components/mobile/EngagementPeriodChips.tsx`
- PeriodChips: 3 chip values D7/D30/D90 with labels 7d/30d/90d: `grep -E "'D7'.*'7d'|D7.*7d" components/mobile/EngagementPeriodChips.tsx`
- PeriodChips: active and inactive class strings exact (per UI-SPEC + deep_work_rules adjusted to `text-[10px]`): `grep -F "bg-primary text-primary-foreground rounded-full px-3 py-1.5 text-[10px] font-semibold" components/mobile/EngagementPeriodChips.tsx && grep -F "bg-muted text-foreground hover:bg-muted/80 rounded-full px-3 py-1.5 text-[10px] font-semibold" components/mobile/EngagementPeriodChips.tsx`
- PeriodChips: `aria-pressed={isActive}` present on each button: `grep -q "aria-pressed={isActive}" components/mobile/EngagementPeriodChips.tsx`
- PeriodChips: exports `EngagementPeriod` type union: `grep -q "export type EngagementPeriod" components/mobile/EngagementPeriodChips.tsx`
- SortChips: 3 chips with labels Hours / Name / Utilization: `grep -E "Hours.*Name.*Utilization|'Hours'" components/mobile/EngagementSortChips.tsx`
- SortChips: same chip class strings as PeriodChips
- SortChips: exports `EngagementSortKey` type: `grep -q "export type EngagementSortKey" components/mobile/EngagementSortChips.tsx`
- SearchInput: imports `Search` from `lucide-react` and `Input` from shadcn: `grep -q "from 'lucide-react'" components/mobile/EngagementSearchInput.tsx && grep -q "from '@/components/ui/input'" components/mobile/EngagementSearchInput.tsx`
- SearchInput: placeholder copy verbatim: `grep -F 'placeholder="Search by name or email"' components/mobile/EngagementSearchInput.tsx`
- SearchInput: 300ms debounce: `grep -E "300\b" components/mobile/EngagementSearchInput.tsx`
- SearchInput: `aria-label="Search team members by name or email"` present: `grep -F 'aria-label="Search team members by name or email"' components/mobile/EngagementSearchInput.tsx`
- SearchInput: leading icon classes verbatim: `grep -F "absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" components/mobile/EngagementSearchInput.tsx`
- All 3 files have `'use client';` directive at top: `grep -L "^'use client';" components/mobile/EngagementPeriodChips.tsx components/mobile/EngagementSortChips.tsx components/mobile/EngagementSearchInput.tsx | wc -l` === 0
- `npx tsc --noEmit --pretty` exits 0
npx tsc --noEmit --pretty
Three files exist with the exact class strings from UI-SPEC. Type-check passes. Components ready for import in Plan 03.
Task 2: Build EngagementSummaryCard + EngagementHoursSparkline (display primitives)
- .planning/phases/07-engagement-overview-new/07-UI-SPEC.md (Summary Cards, Hours Trend Sparkline sections — exact class strings, copy strings, period_label mapping)
- .planning/phases/07-engagement-overview-new/07-CONTEXT.md (D-08, D-09, D-10, D-11, D-12, D-13, D-14, D-15, D-29)
- app/api/mobile/engagement/trend/route.ts (Plan 01 — for SparklinePoint import)
- components/ui/card.tsx (shadcn Card primitive)
- components/mobile/AnalyzerFeedRow.tsx (file structure + comment header pattern)
components/mobile/EngagementSummaryCard.tsx, components/mobile/EngagementHoursSparkline.tsx
Create two `'use client'` display components. The sparkline uses an inline `
` (no recharts — DASH-04 + D-12).
---
**File 1: `components/mobile/EngagementSummaryCard.tsx`** (per D-08, D-10, D-29)
```tsx
'use client';
/* EngagementSummaryCard — phase 07 (ENG-03).
* Purpose: Single summary card with big number + label, stacked single-column.
* Used 4× on the page: Active users, Total Graph hours, Total Autotask hours, Hours / active user (D-08).
* Card has no shadow, only border (matches FinanceRow density per D-10).
* Props: value (display string), label (display string). */
import { Card, CardContent } from '@/components/ui/card';
export interface EngagementSummaryCardProps {
value: string; // pre-formatted: "42", "128.5h", "—"
label: string; // "Active users", "Total Graph hours", etc.
}
export function EngagementSummaryCard({ value, label }: EngagementSummaryCardProps) {
return (
<Card className="py-0 shadow-none">
<CardContent className="px-4 py-4">
<p className="text-2xl font-semibold text-foreground leading-none">{value}</p>
<p className="text-xs text-muted-foreground mt-2">{label}</p>
</CardContent>
</Card>
);
}
```
**Per D-10 / UI-SPEC:** Big number `text-2xl font-semibold`. Label `text-xs text-muted-foreground`. No shadow, border only.
**Value formatting is owned by the page (Plan 03).** This card receives a pre-formatted string. Page passes:
- Card 1 ("Active users"): `String(activeUsers)` (e.g. "42")
- Card 2 ("Total Graph hours"): `${totalGraphHours.toFixed(1)}h` (e.g. "128.5h")
- Card 3 ("Total Autotask hours"): `${totalAutotaskHours.toFixed(1)}h`
- Card 4 ("Hours / active user"): `activeUsers === 0 ? '—' : `${hoursPerActiveUser.toFixed(1)}h`` (per D-08 "render '—' (em dash) for card 4")
---
**File 2: `components/mobile/EngagementHoursSparkline.tsx`** (per D-11..D-15)
```tsx
'use client';
/* EngagementHoursSparkline — phase 07 (ENG-05).
* Purpose: Custom inline SVG sparkline for daily hours trend over the selected period.
* One series, no axes, no tooltips, no animation. DASH-04 (no recharts on mobile).
* Renders the sparkline card per UI-SPEC: label row (left + right) + 48px-tall SVG.
* Props: points (Plan 01 SparklinePoint[]), period (D7|D30|D90 — drives the period_label). */
import { Card, CardContent } from '@/components/ui/card';
import type { SparklinePoint } from '@/app/api/mobile/engagement/trend/route';
export interface EngagementHoursSparklineProps {
points: SparklinePoint[];
period: 'D7' | 'D30' | 'D90';
}
const PERIOD_LABEL: Record<EngagementHoursSparklineProps['period'], string> = {
D7: '7 days',
D30: '30 days',
D90: '90 days',
};
function formatLatestValue(points: SparklinePoint[]): string {
// Find last point with hours > 0
let latest: SparklinePoint | null = null;
for (let i = points.length - 1; i >= 0; i--) {
if (points[i].hours > 0) { latest = points[i]; break; }
}
if (!latest) return '—';
const todayIso = new Date().toISOString().slice(0, 10); // "YYYY-MM-DD" UTC
const isToday = latest.date === todayIso;
const hoursLabel = `${latest.hours.toFixed(1)}h`;
if (isToday) return `${hoursLabel} today`;
// shortDate: "May 2"
const [y, m, d] = latest.date.split('-').map(Number);
const dt = new Date(Date.UTC(y, m - 1, d));
const short = dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });
return `${hoursLabel} ${short}`;
}
function buildPath(points: SparklinePoint[], svgWidth: number, svgHeight: number): string {
if (points.length === 0) return '';
const maxHours = Math.max(...points.map(p => p.hours), 0);
const yScale = maxHours === 0 ? 0 : (svgHeight - 8) / maxHours; // 4px top + 4px bottom margin
const xStep = points.length === 1 ? 0 : svgWidth / (points.length - 1);
return points.map((pt, i) => {
const x = points.length === 1 ? svgWidth / 2 : i * xStep;
const y = svgHeight - 4 - (pt.hours * yScale); // baseline 4px above bottom
return `${i === 0 ? 'M' : 'L'} ${x.toFixed(2)},${y.toFixed(2)}`;
}).join(' ');
}
export function EngagementHoursSparkline({ points, period }: EngagementHoursSparklineProps) {
const periodLabel = PERIOD_LABEL[period];
const allZero = points.length === 0 || points.every(p => p.hours === 0);
const SVG_W = 300;
const SVG_H = 48;
return (
<Card className="py-0 shadow-none">
<CardContent className="px-4 py-3">
<div className="flex justify-between items-center mb-2 gap-2">
<p className="text-xs text-muted-foreground">{`Hours trend · last ${periodLabel}`}</p>
<p className="text-xs text-muted-foreground">{formatLatestValue(points)}</p>
</div>
{allZero ? (
<p className="text-xs text-muted-foreground text-center py-3">No activity</p>
) : (
<svg
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
preserveAspectRatio="none"
className="h-12 w-full"
role="presentation"
aria-hidden="true"
>
{/* Baseline at y=46 (2px from bottom) per UI-SPEC */}
<line
x1="0"
y1={SVG_H - 2}
x2={SVG_W}
y2={SVG_H - 2}
className="text-muted-foreground/20"
stroke="currentColor"
strokeWidth="1"
fill="none"
/>
{/* Series path — stroke-primary stroke-2 fill-none */}
<path
d={buildPath(points, SVG_W, SVG_H)}
className="stroke-primary"
strokeWidth="2"
fill="none"
vectorEffect="non-scaling-stroke"
/>
</svg>
)}
</CardContent>
</Card>
);
}
```
**Per D-12 / UI-SPEC sparkline contract:**
- Linear interpolation only (`M x0,y0 L x1,y1 L x2,y2 ...`)
- Missing-day handling: zero hours draw to baseline, NEVER gap (the trend endpoint already returns continuous days via `generate_series`)
- No animation, no dots, no tooltips
- `stroke-primary` Tailwind token (resolves to `--primary` CSS variable per UI-SPEC color contract)
- `vectorEffect="non-scaling-stroke"` keeps the line at 2px width even with `preserveAspectRatio="none"` stretching the viewBox
**Per D-13:** Label row left text "Hours trend · last 30 days" (note the middle dot `·`, U+00B7). Right text "X.Xh today" or "X.Xh May 2" or "—".
**Per D-15:** No-data fallback when `points.length === 0` or all-zero — render `<p className="text-xs text-muted-foreground text-center py-3">No activity</p>` and skip the SVG entirely.
npx tsc --noEmit --pretty
- Both files exist
- SummaryCard: imports `Card, CardContent` from `@/components/ui/card`: `grep -q "from '@/components/ui/card'" components/mobile/EngagementSummaryCard.tsx`
- SummaryCard: big number class verbatim: `grep -F "text-2xl font-semibold text-foreground leading-none" components/mobile/EngagementSummaryCard.tsx`
- SummaryCard: label class verbatim: `grep -F "text-xs text-muted-foreground" components/mobile/EngagementSummaryCard.tsx`
- SummaryCard: shadow-none on Card (D-10): `grep -F "shadow-none" components/mobile/EngagementSummaryCard.tsx`
- Sparkline: imports SparklinePoint type from Plan 01: `grep -F "from '@/app/api/mobile/engagement/trend/route'" components/mobile/EngagementHoursSparkline.tsx`
- Sparkline: period_label mapping has all 3 periods with strings "7 days" / "30 days" / "90 days": `grep -E "'7 days'|'30 days'|'90 days'" components/mobile/EngagementHoursSparkline.tsx | wc -l` ≥ 3
- Sparkline: copy "Hours trend · last": `grep -F "Hours trend · last" components/mobile/EngagementHoursSparkline.tsx`
- Sparkline: no-data copy "No activity": `grep -F "No activity" components/mobile/EngagementHoursSparkline.tsx`
- Sparkline: SVG element with `viewBox`: `grep -E "viewBox=" components/mobile/EngagementHoursSparkline.tsx`
- Sparkline: stroke-primary class: `grep -F "stroke-primary" components/mobile/EngagementHoursSparkline.tsx`
- Sparkline: stroke-2 width: `grep -E 'strokeWidth="2"' components/mobile/EngagementHoursSparkline.tsx`
- Sparkline: 48px tall (`h-12`): `grep -F "h-12 w-full" components/mobile/EngagementHoursSparkline.tsx`
- Sparkline: linear path commands `M` then `L`: `grep -E "'M'|'L'" components/mobile/EngagementHoursSparkline.tsx`
- Sparkline: NO recharts import (DASH-04): `! grep -q "from 'recharts'" components/mobile/EngagementHoursSparkline.tsx`
- `npx tsc --noEmit --pretty` exits 0
npx tsc --noEmit --pretty
Both files exist with verbatim UI-SPEC class strings + copy. Sparkline uses inline SVG only (no chart library). Type-check passes.
Task 3: Build EngagementUserRow + EngagementUserRowSkeleton (with exported getInitials utility)
- .planning/phases/07-engagement-overview-new/07-UI-SPEC.md (User Row, User Row Skeleton sections — full markup with hours bar, exact class strings; "Note on EngagementUserRow extraction" for getInitials utility)
- .planning/phases/07-engagement-overview-new/07-CONTEXT.md (D-19, D-23, D-29; "Claude's Discretion" notes about avatar initials algorithm and Phase 8 reuse)
- components/mobile/AnalyzerFeedRow.tsx (Link wrapper pattern, file header convention)
- components/mobile/AnalyzerRowSkeleton.tsx (skeleton pattern reference)
- components/ui/skeleton.tsx (Skeleton primitive)
components/mobile/EngagementUserRow.tsx, components/mobile/EngagementUserRowSkeleton.tsx
Create two files. `EngagementUserRow.tsx` is the single most-load-bearing component on the page — it owns the visual contract for the per-employee list. Mirror the exact markup from UI-SPEC §"User Row".
---
**File 1: `components/mobile/EngagementUserRow.tsx`** (per D-19; getInitials per UI-SPEC §"Note on EngagementUserRow extraction")
```tsx
'use client';
/* EngagementUserRow — phase 07 (ENG-04).
* Purpose: Per-employee row card — avatar (initials) + name + role + hours + hours bar.
* Entire row is a Link to /mobile/engagement/[graphUserId] (D-19; Phase 8 owns destination).
* Hours bar width = (billableHours / maxHours) * 100% — bounded at 100%.
* Props: user (EngagementUser shape), maxHours (largest billableHours in current page set; computed by parent). */
import Link from 'next/link';
export interface EngagementUserRowData {
graphUserId: string;
displayName: string;
userEmail: string;
jobTitle: string | null;
billableHours: number;
hoursWorked: number;
}
export interface EngagementUserRowProps {
user: EngagementUserRowData;
maxHours: number; // largest billableHours in the loaded set (parent computes)
}
/**
* getInitials — first letter of first word + first letter of last word of displayName,
* uppercased. E.g. "Jordan Walsh" → "JW", "Alex" → "A". Exported for Phase 8 reuse
* (the user profile header may share the avatar identity block per UI-SPEC).
*/
export function getInitials(displayName: string): string {
const parts = displayName.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return '?';
if (parts.length === 1) return parts[0]![0]!.toUpperCase();
const first = parts[0]![0] ?? '';
const last = parts[parts.length - 1]![0] ?? '';
return (first + last).toUpperCase();
}
export function EngagementUserRow({ user, maxHours }: EngagementUserRowProps) {
const initials = getInitials(user.displayName);
const hoursLabel = `${user.billableHours.toFixed(1)}h`;
const barWidthPct = maxHours > 0
? Math.min(100, (user.billableHours / maxHours) * 100)
: 0;
return (
<Link
href={`/mobile/engagement/${user.graphUserId}`}
className="block px-4 py-3 hover:bg-muted/50 transition-colors active:bg-muted/50"
>
{/* Top line: avatar + identity + hours value */}
<div className="flex items-center gap-3">
<span
aria-hidden="true"
className="h-8 w-8 rounded-full bg-muted flex items-center justify-center shrink-0 text-[10px] font-semibold text-foreground"
>
{initials}
</span>
<div className="flex-1 min-w-0 flex items-baseline gap-2">
<span className="text-sm font-semibold truncate flex-1">
{user.displayName}
</span>
<span className="text-sm font-semibold shrink-0 text-right">
{hoursLabel}
</span>
</div>
</div>
{/* Role line — render only if jobTitle present (UI-SPEC: "render nothing (no empty line) if absent") */}
{user.jobTitle && (
<p className="text-xs text-muted-foreground truncate pl-11 mt-0.5">
{user.jobTitle}
</p>
)}
{/* Hours bar */}
<div className="mt-2">
<div className="h-1.5 rounded-full bg-muted overflow-hidden" role="presentation" aria-hidden="true">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${barWidthPct}%` }}
/>
</div>
</div>
</Link>
);
}
```
**Notes:**
- Avatar text uses `text-[10px]` per UI-SPEC typography table (badge/caption size). The avatar background is `bg-muted` (no per-user color hashing per UI-SPEC §Avatar Color).
- Role indentation: UI-SPEC says `px-[44px]` but with the row's `px-4` (16px) page padding, the avatar (32px) + gap (12px) sums to 44px — so the inner indent is `pl-11` (44px) which aligns the role text under the name (avatar baseline).
- `getInitials` is a top-level **named export** so Phase 8 can `import { getInitials } from '@/components/mobile/EngagementUserRow'`. Algorithm per CONTEXT.md "Claude's Discretion": first letter of first word + first letter of last word, uppercased.
- Hours bar transition: `transition-all duration-300` keeps the bar smooth when the page set changes (e.g., after sort).
**Per D-19 link:** `<Link href={\`/mobile/engagement/${user.graphUserId}\`}>` — Phase 8 (a future phase) builds the destination page. Phase 7 just wires the link.
---
**File 2: `components/mobile/EngagementUserRowSkeleton.tsx`** (per D-23)
```tsx
'use client';
/* EngagementUserRowSkeleton — phase 07 (D-23).
* Purpose: Skeleton placeholder matching EngagementUserRow shape. Renders 5 instances on initial load.
* Props: none — purely presentational. */
import { Skeleton } from '@/components/ui/skeleton';
export function EngagementUserRowSkeleton() {
return (
<div className="px-4 py-3 space-y-2">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1 flex items-center justify-between gap-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-12" />
</div>
</div>
<Skeleton className="h-3 w-24 ml-11" />
<Skeleton className="h-1.5 w-full mt-2" />
</div>
);
}
```
**Notes:** Mirrors the row shape exactly — avatar circle (32× 32), name placeholder, hours placeholder, role placeholder indented past avatar (`ml-11`), hours bar placeholder. UI-SPEC mandates 5 skeleton instances on initial load (rendered by the page).
npx tsc --noEmit --pretty
- Both files exist
- UserRow: imports `Link` from `next/link`: `grep -q "from 'next/link'" components/mobile/EngagementUserRow.tsx`
- UserRow: Link href targets /mobile/engagement/[graphUserId]: `grep -E "/mobile/engagement/\\\$\\{user\\.graphUserId\\}" components/mobile/EngagementUserRow.tsx`
- UserRow: exports `getInitials` function: `grep -q "export function getInitials" components/mobile/EngagementUserRow.tsx`
- UserRow: getInitials handles empty string + single-word + multi-word (test by inspection — function references parts.length === 0, parts.length === 1, parts.length > 1)
- UserRow: avatar classes verbatim: `grep -F "h-8 w-8 rounded-full bg-muted flex items-center justify-center shrink-0 text-[10px] font-semibold text-foreground" components/mobile/EngagementUserRow.tsx`
- UserRow: row container classes verbatim: `grep -F "block px-4 py-3 hover:bg-muted/50 transition-colors active:bg-muted/50" components/mobile/EngagementUserRow.tsx`
- UserRow: hours bar track verbatim: `grep -F "h-1.5 rounded-full bg-muted overflow-hidden" components/mobile/EngagementUserRow.tsx`
- UserRow: hours bar fill: `grep -F "h-full rounded-full bg-primary transition-all duration-300" components/mobile/EngagementUserRow.tsx`
- UserRow: name uses `text-sm font-semibold truncate flex-1` (D-19): `grep -F "text-sm font-semibold truncate flex-1" components/mobile/EngagementUserRow.tsx`
- UserRow: role line conditional + `text-xs text-muted-foreground truncate`: `grep -F "text-xs text-muted-foreground truncate" components/mobile/EngagementUserRow.tsx`
- UserRow: hours value formatted with `.toFixed(1)`: `grep -E "\.toFixed\(1\)" components/mobile/EngagementUserRow.tsx`
- UserRow: bar width bounded at 100%: `grep -E "Math\.min\(100" components/mobile/EngagementUserRow.tsx`
- UserRow: avatar has `aria-hidden="true"`: `grep -E 'aria-hidden="true"' components/mobile/EngagementUserRow.tsx`
- Skeleton: imports `Skeleton` from shadcn: `grep -q "from '@/components/ui/skeleton'" components/mobile/EngagementUserRowSkeleton.tsx`
- Skeleton: includes `h-8 w-8 rounded-full` (avatar) + `h-4 w-32` (name) + `h-4 w-12` (hours) + `h-3 w-24 ml-11` (role) + `h-1.5 w-full mt-2` (bar): `grep -E "h-8 w-8 rounded-full|h-4 w-32|h-4 w-12|h-3 w-24 ml-11|h-1.5 w-full" components/mobile/EngagementUserRowSkeleton.tsx | wc -l` ≥ 5
- `npx tsc --noEmit --pretty` exits 0
npx tsc --noEmit --pretty
Both files exist. `getInitials` is exported. Row markup verbatim from UI-SPEC. Skeleton mirrors row shape. Type-check passes. Phase 8 can import `getInitials` directly.
<threat_model>
Trust Boundaries
Boundary
Description
Component props → DOM
All seven components receive typed props from the page. No fetch calls, no localStorage access, no untrusted serialization. Display strings come from API data already validated by Plan 01.
User input → onChange callbacks
EngagementSearchInput emits debounced strings; PeriodChips/SortChips emit typed enum values. The page (Plan 03) consumes these — no DB writes, no URL injection from this layer.
STRIDE Threat Register
Threat ID
Category
Component
Disposition
Mitigation Plan
T-07-07
XSS / Tampering
EngagementUserRow display values
mitigate
All user-supplied strings (displayName, jobTitle) rendered via React JSX text interpolation — auto-escaped by React. No dangerouslySetInnerHTML, no innerHTML, no eval. The hours bar width style is computed from a clamped numeric (Math.min(100, …)), not user input.
T-07-08
XSS via SVG injection
EngagementHoursSparkline path d attribute
mitigate
Path string is built from numeric coordinates only (toFixed(2) on each); no user-supplied strings flow into the SVG path. The viewBox and stroke classes are hardcoded constants.
T-07-09
Information Disclosure
EngagementUserRow → Link href
accept
Link href contains the graphUserId (Azure AD object ID). This is the same URL surface Phase 8 builds; not considered sensitive (similar to /mobile/tickets/[id] exposing ticket ids). The destination page enforces auth via middleware.
T-07-10
DoS / Re-render storms
EngagementSearchInput debounce
mitigate
300ms setTimeout debounce + cleanup on unmount + dependency-tracked effect. Prevents per-keystroke re-renders propagating to the parent's filter logic. Local input state remains responsive (no debounce on the field itself).
</threat_model>
- All 7 component files exist under `components/mobile/`
- `npx tsc --noEmit --pretty` exits 0 with no errors in any new file
- `getInitials` exported from `EngagementUserRow.tsx` (Phase 8 reuse)
- All chip/row class strings present verbatim (per acceptance criteria grep checks)
- Sparkline contains NO `recharts` import; uses inline SVG only
- All components have `'use client'` directive at top
- Plan 03 can import all 7 components without errors
<success_criteria>
7 component files written
npx tsc --noEmit --pretty exits 0
All UI-SPEC class strings present verbatim (period chip active/inactive, summary card big number, hours bar track/fill, avatar)
All UI-SPEC copy strings present verbatim ("Search by name or email", "Hours trend · last", "No activity", "7 days"/"30 days"/"90 days", chip labels)
getInitials is a named export from EngagementUserRow.tsx
No fetches, no toasts, no router calls, no useEffect data loaders inside any component (orchestration is owned by Plan 03)
</success_criteria>
After completion, create `.planning/phases/07-engagement-overview-new/07-02-SUMMARY.md` documenting:
- 7 components and their public exports
- The exported `getInitials` utility (referenced by Phase 8)
- Confirmation that no recharts is used
- Class strings copied verbatim from UI-SPEC (D-29 typography count maintained: text-sm, text-xs, text-[10px], text-2xl)