39 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 07-engagement-overview-new | 03 | execute | 3 |
|
|
true |
|
|
Per CONTEXT.md ENG-01, this is a "real refactor, not a thin adaptation of the ~1300-line
desktop page." Build phone-first against the data sources, do NOT port desktop
app/engagement/page.tsx (D-35: untouched).
Per ENG-09, Engagement is NOT on the bottom nav — it's reached from the More drawer which Phase 2 already wired (D-01, D-02).
Purpose: Wire all the pieces into the shipping page. Match Phase 4 / Phase 6 mobile-page
structure ('use client', useState + useEffect + fetch, IntersectionObserver, sonner
toasts on error, no SWR/react-query per CLAUDE.md + D-37, no Zod per D-38).
Output: app/mobile/engagement/page.tsx — single new file.
<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.mdPlan 01 endpoints (Wave 1) — types imported from these
@app/api/mobile/engagement/summary/route.ts @app/api/mobile/engagement/trend/route.ts
Existing endpoint reused as-is (D-16, D-34 — DO NOT MODIFY)
@app/api/engagement/users/route.ts
Plan 02 components (Wave 2) — imported by name
@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
Phase shell (DO NOT MODIFY) — verify Engagement reachability after page lands
@app/mobile/layout.tsx @components/mobile/BottomNav.tsx @components/mobile/MoreDrawer.tsx
Pattern references (page-level orchestration)
@app/mobile/analyzer/page.tsx @app/mobile/tickets/page.tsx
import type { MobileEngagementSummary } from '@/app/api/mobile/engagement/summary/route';
import type { SparklinePoint, EngagementTrendResponse } from '@/app/api/mobile/engagement/trend/route';
import type { EngagementPeriod } from '@/components/mobile/EngagementPeriodChips';
import type { EngagementSortKey } from '@/components/mobile/EngagementSortChips';
import { EngagementPeriodChips } from '@/components/mobile/EngagementPeriodChips';
import { EngagementSummaryCard } from '@/components/mobile/EngagementSummaryCard';
import { EngagementHoursSparkline } from '@/components/mobile/EngagementHoursSparkline';
import { EngagementSortChips } from '@/components/mobile/EngagementSortChips';
import { EngagementSearchInput } from '@/components/mobile/EngagementSearchInput';
import { EngagementUserRow } from '@/components/mobile/EngagementUserRow';
import { EngagementUserRowSkeleton } from '@/components/mobile/EngagementUserRowSkeleton';
interface EngagementUserApiRow {
graphUserId: string;
displayName: string;
email: string; // existing endpoint returns 'email', not 'userEmail'
jobTitle: string | null;
billableHours: number;
hoursWorked: number;
// ...other fields the page does NOT consume
}
interface EngagementUsersResponse {
users: EngagementUserApiRow[];
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number; // present when total > 0; existing endpoint returns this
};
}
const SORT_TO_API: Record<EngagementSortKey, { sort: string; order: 'asc' | 'desc' }> = {
Hours: { sort: 'billable_hours', order: 'desc' },
Name: { sort: 'display_name', order: 'asc' },
Utilization: { sort: 'billable_hours', order: 'desc' },
};
Task 1: Create app/mobile/engagement/page.tsx (orchestrates Plan 01 endpoints + Plan 02 components)
- .planning/phases/07-engagement-overview-new/07-UI-SPEC.md (full file — Page Layout Order section is the spec; copy strings, accessibility, error toast labels)
- .planning/phases/07-engagement-overview-new/07-CONTEXT.md (D-01, D-03, D-16, D-17, D-18, D-21, D-22, D-23, D-24, D-25, D-26, D-27, D-30, D-31, D-37)
- app/mobile/analyzer/page.tsx (closest pattern reference — IntersectionObserver loop, error/Retry, skeleton render block, useCallback fetch, toast.error)
- app/mobile/tickets/page.tsx lines 1-100 (filter + URL pattern reference; URL syncing NOT used in this phase per D-21 note)
- app/api/engagement/users/route.ts (existing route — confirm response shape `users[]` + `pagination.{page,pageSize,total,totalPages}`)
- components/mobile/BottomNav.tsx (verify no Engagement entry — must NOT be modified per D-02 / ENG-09)
- components/mobile/MoreDrawer.tsx (verify Engagement entry exists — DO NOT modify per D-01)
app/mobile/engagement/page.tsx
Create new file `app/mobile/engagement/page.tsx`. Use the EXACT structure below — every section maps to a UI-SPEC layout block. NO router pushes for state (per D-21 note: period/sort/search are component state only, scale doesn't warrant deep-linking). NO SWR / react-query (D-37 / CLAUDE.md). NO Zod (D-38).
**File header:**
```tsx
'use client';
/* MobileEngagementPage — phase 07 (ENG-01..05, ENG-09).
* Purpose: Phone-first refactor of /engagement — period chips + 4 stacked summary cards +
* compact sparkline + sortable/searchable per-employee list with infinite scroll.
* Real refactor, not a thin adaptation of the ~1300-line desktop page (ENG-01).
* Reachable from More drawer only — Engagement is NOT on the bottom nav (ENG-09).
* Per D-01..D-31. */
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { Loader2, Users } from 'lucide-react';
import { toast } from 'sonner';
import type { MobileEngagementSummary } from '@/app/api/mobile/engagement/summary/route';
import type { SparklinePoint, EngagementTrendResponse } from '@/app/api/mobile/engagement/trend/route';
import { EngagementPeriodChips, type EngagementPeriod } from '@/components/mobile/EngagementPeriodChips';
import { EngagementSummaryCard } from '@/components/mobile/EngagementSummaryCard';
import { EngagementHoursSparkline } from '@/components/mobile/EngagementHoursSparkline';
import { EngagementSortChips, type EngagementSortKey } from '@/components/mobile/EngagementSortChips';
import { EngagementSearchInput } from '@/components/mobile/EngagementSearchInput';
import { EngagementUserRow } from '@/components/mobile/EngagementUserRow';
import { EngagementUserRowSkeleton } from '@/components/mobile/EngagementUserRowSkeleton';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
```
**Inline types (existing /api/engagement/users response — D-16):**
```tsx
interface EngagementUserApiRow {
graphUserId: string;
displayName: string;
email: string;
jobTitle: string | null;
billableHours: number;
hoursWorked: number;
}
interface EngagementUsersResponse {
users: EngagementUserApiRow[];
pagination: {
page: number;
pageSize: number;
total: number;
totalPages?: number;
};
}
// Sort key → API param mapping (D-20)
const SORT_TO_API: Record<EngagementSortKey, { sort: string; order: 'asc' | 'desc' }> = {
Hours: { sort: 'billable_hours', order: 'desc' },
Name: { sort: 'display_name', order: 'asc' },
Utilization: { sort: 'billable_hours', order: 'desc' },
};
const SUMMARY_LABELS = {
activeUsers: 'Active users',
totalGraphHours: 'Total Graph hours',
totalAutotaskHours: 'Total Autotask hours',
hoursPerActiveUser: 'Hours / active user',
} as const;
```
**Component body (state + fetches + render):**
```tsx
export default function MobileEngagementPage() {
// ── State ─────────────────────────────────────────────────────────────
const [period, setPeriod] = useState<EngagementPeriod>('D30'); // D-04 default
const [sortKey, setSortKey] = useState<EngagementSortKey>('Hours'); // D-20 default
const [searchQuery, setSearchQuery] = useState<string>(''); // D-21
const [summary, setSummary] = useState<MobileEngagementSummary | null>(null);
const [summaryLoading, setSummaryLoading] = useState<boolean>(true);
const [trendPoints, setTrendPoints] = useState<SparklinePoint[]>([]);
const [trendLoading, setTrendLoading] = useState<boolean>(true);
const [users, setUsers] = useState<EngagementUserApiRow[]>([]);
const [usersLoading, setUsersLoading] = useState<boolean>(true);
const [currentPage, setCurrentPage] = useState<number>(1);
const [hasMore, setHasMore] = useState<boolean>(false);
const [loadingMore, setLoadingMore] = useState<boolean>(false);
const [loadMoreError, setLoadMoreError] = useState<boolean>(false);
// ── Fetchers ──────────────────────────────────────────────────────────
const loadSummary = useCallback(async (p: EngagementPeriod) => {
setSummaryLoading(true);
try {
const r = await fetch(`/api/mobile/engagement/summary?period=${p}`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data: MobileEngagementSummary = await r.json();
setSummary(data);
} catch (e) {
console.error('Engagement summary fetch failed:', e);
toast.error('Failed to load engagement summary'); // D-25
} finally {
setSummaryLoading(false);
}
}, []);
const loadTrend = useCallback(async (p: EngagementPeriod) => {
setTrendLoading(true);
try {
const r = await fetch(`/api/mobile/engagement/trend?period=${p}`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data: EngagementTrendResponse = await r.json();
setTrendPoints(data.points);
} catch (e) {
console.error('Engagement trend fetch failed:', e);
toast.error('Failed to load hours trend'); // D-25
} finally {
setTrendLoading(false);
}
}, []);
const loadUsersPage1 = useCallback(async (p: EngagementPeriod, sk: EngagementSortKey) => {
setUsersLoading(true);
setLoadMoreError(false);
try {
const { sort, order } = SORT_TO_API[sk];
const sp = new URLSearchParams({ period: p, sort, order, page: '1' });
const r = await fetch(`/api/engagement/users?${sp.toString()}`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data: EngagementUsersResponse = await r.json();
setUsers(data.users);
setCurrentPage(1);
const totalPages = data.pagination.totalPages
?? Math.ceil(data.pagination.total / data.pagination.pageSize);
setHasMore(1 < totalPages);
} catch (e) {
console.error('Engagement users fetch failed:', e);
toast.error('Failed to load engagement users'); // D-25
} finally {
setUsersLoading(false);
}
}, []);
const loadMoreUsers = useCallback(async () => {
if (loadingMore || !hasMore) return;
setLoadingMore(true);
setLoadMoreError(false);
try {
const next = currentPage + 1;
const { sort, order } = SORT_TO_API[sortKey];
const sp = new URLSearchParams({ period, sort, order, page: String(next) });
const r = await fetch(`/api/engagement/users?${sp.toString()}`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data: EngagementUsersResponse = await r.json();
setUsers(prev => [...prev, ...data.users]);
setCurrentPage(next);
const totalPages = data.pagination.totalPages
?? Math.ceil(data.pagination.total / data.pagination.pageSize);
setHasMore(next < totalPages);
} catch (e) {
console.error('Engagement users load-more fetch failed:', e);
toast.error('Failed to load more team members'); // D-25
setLoadMoreError(true);
} finally {
setLoadingMore(false);
}
}, [loadingMore, hasMore, currentPage, sortKey, period]);
// ── Effects ───────────────────────────────────────────────────────────
// Period change: refetch all three (summary + trend + users page 1) — D-04
useEffect(() => {
void loadSummary(period);
void loadTrend(period);
void loadUsersPage1(period, sortKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [period]);
// Sort change: refetch users only (D-20: summary/trend are period-scoped, not sort-scoped)
useEffect(() => {
void loadUsersPage1(period, sortKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sortKey]);
// IntersectionObserver — D-18 (mirrors Phase 4/6 pattern, rootMargin '200px')
const sentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const node = sentinelRef.current;
if (!node) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && hasMore && !loadingMore && !usersLoading) {
void loadMoreUsers();
}
},
{ rootMargin: '200px' },
);
observer.observe(node);
return () => observer.disconnect();
}, [hasMore, loadingMore, usersLoading, loadMoreUsers]);
// ── Derived data ──────────────────────────────────────────────────────
// Client-side search filter (D-21): applies on displayName + email of loaded users
const filteredUsers = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return users;
return users.filter(u =>
u.displayName.toLowerCase().includes(q) || u.email.toLowerCase().includes(q),
);
}, [users, searchQuery]);
// maxHours for hours bar normalization (UI-SPEC: largest billableHours in current page set)
const maxHours = useMemo(() => {
return filteredUsers.reduce((m, u) => Math.max(m, u.billableHours), 0);
}, [filteredUsers]);
// Empty-state predicate (D-26): both summary.activeUsers AND users.length === 0
const showEmptyState = !summaryLoading && !usersLoading
&& summary !== null && summary.activeUsers === 0 && users.length === 0;
// Not-configured banner predicate (D-27)
const showNotConfiguredBanner = !summaryLoading && summary !== null && summary.configured === false;
// Summary card values (formatted strings — Plan 02's SummaryCard accepts pre-formatted)
const summaryCard1 = summary ? String(summary.activeUsers) : '0';
const summaryCard2 = summary ? `${summary.totalGraphHours.toFixed(1)}h` : '0.0h';
const summaryCard3 = summary ? `${summary.totalAutotaskHours.toFixed(1)}h` : '0.0h';
const summaryCard4 = summary
? (summary.activeUsers === 0 ? '—' : `${summary.hoursPerActiveUser.toFixed(1)}h`) // D-08
: '—';
// ── Render ────────────────────────────────────────────────────────────
return (
<div className="px-4 py-4 space-y-4"> {/* D-30 */}
{/* H1 — D-31, scrolls away under the sticky chips */}
<h1 className="text-sm font-semibold">Engagement</h1> {/* D-29 */}
{/* Sticky period chips — D-05 */}
<EngagementPeriodChips period={period} onPeriodChange={setPeriod} />
{showNotConfiguredBanner ? (
// Not-configured banner (D-27) — replaces all data sections
<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"
rel="noopener noreferrer"
aria-label="Open Admin on desktop"
className="underline ml-1"
>
Open Admin
</a>
</p>
</div>
) : (
<>
{/* Section 3 — Summary cards (D-08, D-23) */}
{summaryLoading ? (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i} className="py-0 shadow-none">
<CardContent className="px-4 py-4 space-y-2">
<Skeleton className="h-8 w-20" />
<Skeleton className="h-3 w-28" />
</CardContent>
</Card>
))}
</div>
) : (
<div className="space-y-3">
<EngagementSummaryCard value={summaryCard1} label={SUMMARY_LABELS.activeUsers} />
<EngagementSummaryCard value={summaryCard2} label={SUMMARY_LABELS.totalGraphHours} />
<EngagementSummaryCard value={summaryCard3} label={SUMMARY_LABELS.totalAutotaskHours} />
<EngagementSummaryCard value={summaryCard4} label={SUMMARY_LABELS.hoursPerActiveUser} />
</div>
)}
{/* Section 4 — Sparkline (D-11..D-15, D-23) */}
{trendLoading ? (
<Card className="py-0 shadow-none">
<CardContent className="px-4 py-3 space-y-2">
<div className="flex justify-between gap-2">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-3 w-16" />
</div>
<Skeleton className="h-12 w-full mt-2" />
</CardContent>
</Card>
) : (
<EngagementHoursSparkline points={trendPoints} period={period} />
)}
{/* Section 5 — Sort + search (D-20, D-21) */}
<div className="space-y-2">
<EngagementSortChips activeSort={sortKey} onSortChange={setSortKey} />
<EngagementSearchInput value={searchQuery} onChange={setSearchQuery} />
</div>
{/* Section 6 — User list (D-19, D-22, D-23, D-26) */}
{showEmptyState ? (
// Empty state (D-26)
<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" aria-hidden="true" />
<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"
rel="noopener noreferrer"
aria-label="Open Admin on desktop"
className="underline ml-1"
>Admin</a>
</p>
</div>
</div>
) : usersLoading ? (
<div className="divide-y border rounded-xl overflow-hidden">
{Array.from({ length: 5 }).map((_, i) => <EngagementUserRowSkeleton key={i} />)}
</div>
) : filteredUsers.length === 0 && searchQuery.trim() !== '' ? (
// No-matches inline state (D-22) — preserves the list border + rounding
<div className="border rounded-xl overflow-hidden">
<div className="px-4 py-6 text-center space-y-2">
<p className="text-sm text-muted-foreground">{`No matches for "${searchQuery}"`}</p>
<button
type="button"
onClick={() => setSearchQuery('')}
className="text-xs font-semibold text-primary underline"
>
Clear search
</button>
</div>
</div>
) : (
<>
<div className="divide-y border rounded-xl overflow-hidden">
{filteredUsers.map(u => (
<EngagementUserRow
key={u.graphUserId}
user={{
graphUserId: u.graphUserId,
displayName: u.displayName,
userEmail: u.email,
jobTitle: u.jobTitle,
billableHours: u.billableHours,
hoursWorked: u.hoursWorked,
}}
maxHours={maxHours}
/>
))}
</div>
{/* Sentinel — D-18 */}
<div ref={sentinelRef} aria-hidden="true" />
{/* Loading-more spinner — D-24 */}
{loadingMore && (
<div className="flex justify-center py-2">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" aria-hidden="true" />
</div>
)}
{/* Load more fallback button — D-18, D-25 */}
{hasMore && (
<button
type="button"
onClick={() => void loadMoreUsers()}
disabled={loadingMore}
aria-label="Load more team members"
className="w-full py-3 rounded-xl border text-sm font-semibold hover:bg-muted/50 transition-colors disabled:opacity-50 min-h-[44px]"
>
{loadMoreError ? 'Retry' : loadingMore ? 'Loading…' : 'Load more'}
</button>
)}
</>
)}
</>
)}
</div>
);
}
```
**Critical orchestration notes:**
- **Period change** (D-04): triggers `loadSummary + loadTrend + loadUsersPage1` via the `useEffect([period])`.
- **Sort change** (D-20): triggers `loadUsersPage1` only — summary/trend are period-scoped.
- **Search change** (D-21): NEVER triggers a fetch — filter is purely client-side via `useMemo`.
- **Initial mount**: the period effect runs with default `D30`, kicking off all three fetches simultaneously (no waterfall).
- **No URL syncing** (per D-21 note in CONTEXT.md): period/sort/search live in component state. Mirrors the lower data scale relative to Tickets (Phase 4 deep-links because filters are diverse + URL-shareable; Engagement state is simpler).
- **No router push, no useSearchParams**: Phase 4 uses URL-synced filters, Phase 7 does NOT.
**Per ENG-09 / D-02:** This page does NOT modify `BottomNav.tsx`. Engagement entry stays in More drawer (Phase 2 already wired DRAWER-03).
**Per D-01 / D-35:** This page does NOT modify `MoreDrawer.tsx` or any desktop `app/engagement/*` files.
**Per D-30 / UI-SPEC:** Page container is `px-4 py-4 space-y-4`. The H1 is `text-sm font-semibold` (D-29 — keeps font-size count at 4: `text-sm`, `text-xs`, `text-[10px]`, `text-2xl`).
**Per D-37 / D-38:** No SWR, no react-query, no Zod.
npx tsc --noEmit --pretty
- File exists: `test -f app/mobile/engagement/page.tsx`
- Has `'use client';` at top: `head -1 app/mobile/engagement/page.tsx | grep -q "'use client';"`
- Imports all 7 Plan 02 components: `grep -E "EngagementPeriodChips|EngagementSummaryCard|EngagementHoursSparkline|EngagementSortChips|EngagementSearchInput|EngagementUserRow|EngagementUserRowSkeleton" app/mobile/engagement/page.tsx | wc -l` ≥ 7
- Imports types from Plan 01 endpoints: `grep -F "from '@/app/api/mobile/engagement/summary/route'" app/mobile/engagement/page.tsx && grep -F "from '@/app/api/mobile/engagement/trend/route'" app/mobile/engagement/page.tsx`
- Default export: `grep -E "export default function MobileEngagementPage" app/mobile/engagement/page.tsx`
- Page H1 with copy "Engagement" + class `text-sm font-semibold`: `grep -F 'Engagement
' app/mobile/engagement/page.tsx` - Page container classes verbatim (D-30): `grep -F 'className="px-4 py-4 space-y-4"' app/mobile/engagement/page.tsx` - All 4 summary card labels present (D-08): `grep -F "Active users" app/mobile/engagement/page.tsx && grep -F "Total Graph hours" app/mobile/engagement/page.tsx && grep -F "Total Autotask hours" app/mobile/engagement/page.tsx && grep -F "Hours / active user" app/mobile/engagement/page.tsx` - All 4 toast.error labels (D-25): `grep -F "Failed to load engagement summary" app/mobile/engagement/page.tsx && grep -F "Failed to load hours trend" app/mobile/engagement/page.tsx && grep -F "Failed to load engagement users" app/mobile/engagement/page.tsx && grep -F "Failed to load more team members" app/mobile/engagement/page.tsx` - Empty state copy (D-26): `grep -F "No engagement data for this period" app/mobile/engagement/page.tsx` - Not-configured copy (D-27): `grep -F "Engagement sync not configured" app/mobile/engagement/page.tsx` - No-matches copy (D-22): `grep -F "No matches for" app/mobile/engagement/page.tsx && grep -F "Clear search" app/mobile/engagement/page.tsx` - Default period is D30 (D-04): `grep -E "useState\('D30'\)" app/mobile/engagement/page.tsx` - Default sort is Hours (D-20): `grep -E "useState\('Hours'\)" app/mobile/engagement/page.tsx` - SORT_TO_API mapping present and includes display_name asc + billable_hours desc: `grep -E "display_name.*asc|billable_hours.*desc" app/mobile/engagement/page.tsx | wc -l` ≥ 2 - Fetches all 3 endpoints: `grep -F "/api/mobile/engagement/summary?period=" app/mobile/engagement/page.tsx && grep -F "/api/mobile/engagement/trend?period=" app/mobile/engagement/page.tsx && grep -F "/api/engagement/users?" app/mobile/engagement/page.tsx` - IntersectionObserver with rootMargin '200px' (D-18): `grep -F "rootMargin: '200px'" app/mobile/engagement/page.tsx` - Load more button + classes (D-18): `grep -F 'className="w-full py-3 rounded-xl border text-sm font-semibold hover:bg-muted/50 transition-colors disabled:opacity-50 min-h-[44px]"' app/mobile/engagement/page.tsx` - Load more aria-label "Load more team members": `grep -F 'aria-label="Load more team members"' app/mobile/engagement/page.tsx` - Retry / Loading… / Load more labels: `grep -E "'Retry'|'Loading…'|'Load more'" app/mobile/engagement/page.tsx | wc -l` ≥ 3 - User list container has `divide-y border rounded-xl overflow-hidden`: `grep -F 'divide-y border rounded-xl overflow-hidden' app/mobile/engagement/page.tsx` - Card 4 zero-users renders "—" (D-08): `grep -E "activeUsers === 0.*'—'" app/mobile/engagement/page.tsx` - Hours formatted with .toFixed(1): `grep -E "toFixed\(1\)" app/mobile/engagement/page.tsx | wc -l` ≥ 3 - Search filter is client-side useMemo (D-21): `grep -E "useMemo|filteredUsers" app/mobile/engagement/page.tsx` - NO Zod, NO SWR, NO react-query (D-37, D-38): `! grep -E "from 'zod'|from 'swr'|from '@tanstack/react-query'" app/mobile/engagement/page.tsx` - NO router push for state (D-21 note): `! grep -E "router\.push.*setPeriod|router\.push.*sort" app/mobile/engagement/page.tsx` - NO useSearchParams (period/sort/search are component state only): `! grep -E "useSearchParams|useRouter" app/mobile/engagement/page.tsx` - BottomNav.tsx is NOT modified (Engagement is NOT a tab — ENG-09): `! grep -F "Engagement" components/mobile/BottomNav.tsx` (confirms current state preserved) - MoreDrawer.tsx still routes to /mobile/engagement (D-01): `grep -F "/mobile/engagement" components/mobile/MoreDrawer.tsx` - File length ≥ 200 lines (orchestration + render block is substantive): `wc -l < app/mobile/engagement/page.tsx | awk '{ if ($1 < 200) exit 1; else exit 0 }'` - `npx tsc --noEmit --pretty` exits 0 npx tsc --noEmit --pretty Page file written. Type-check passes. Manually visiting `/mobile/engagement` while logged in shows H1 + sticky chips + 4 stacked summary cards + sparkline + sort/search + user rows. Period chip change refetches all three datasets. Sort chip change refetches users. Search debounces 300ms and filters in place. Empty/error/not-configured states render per UI-SPEC. BottomNav and MoreDrawer remain unchanged. No type errors anywhere in the project.<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Authenticated browser session → /api/mobile/engagement/* | Page issues GET fetches with cookie-based session; new endpoints already gated by requireAuth() (Plan 01) |
| Authenticated browser session → /api/engagement/users (existing) | Reused as-is per D-16; this endpoint does NOT call requireAuth() (D-33 inherited risk) — middleware.ts is the only auth gate |
| User input (search query) → DOM | Rendered via React JSX text interpolation (auto-escaped); no DB writes, no URL injection |
| Page state → Link href | graphUserId flows into next/link href; not user-controlled (comes from API response) |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-07-11 | Spoofing/AuthN | new mobile endpoints | mitigate | Plan 01's two new endpoints call requireAuth() first. middleware.ts also blocks unauth access to /api/* not in the public list. Verify with curl: GET /api/mobile/engagement/summary unauthenticated returns 401 / redirect. |
| T-07-12 | Information Disclosure (inherited) | reuse of /api/engagement/users (D-16, D-33) |
accept | Existing desktop endpoint lacks requireAuth() (D-33). Reuse from this mobile page does NOT introduce new exposure: middleware.ts already requires session for /api/* (the endpoint is not in the public list per CLAUDE.md). Out of scope to fix per D-34 + PROJECT.md ("Restyling or replacing the desktop pages…"). Mirrors Phase 6 IDOR T-06P03-02 disposition pattern. Document as a STATE.md follow-up; recommend a future security phase for /api/engagement/* requireAuth() retrofit. |
| T-07-13 | XSS / DOM injection | search query, user displayName/email/jobTitle, sparkline values | mitigate | All user-supplied strings rendered via React JSX text interpolation (auto-escaped). Search query embedded in copy via template literal (No matches for "${searchQuery}") — React escapes the closing tags. No dangerouslySetInnerHTML, no innerHTML, no eval. |
| T-07-14 | Tampering / Open redirect | "Open Admin" + "Admin" links in banner/empty-state | mitigate | Both links use target="_blank" + rel="noopener noreferrer" + aria-label. The href is a hardcoded relative path /admin (not user-controlled). |
| T-07-15 | DoS / Re-render storm | period/sort change cancellation | accept | Period/sort change does not cancel inflight fetches — the latest setState wins because useEffect re-runs and React renders the latest state. A user thrashing chips fires multiple fetches but the result of the most recent setState is what renders. Acceptable for the data scale (≤50 staff, period in {7/30/90}). Documented as accept; revisit if perf measurements warrant AbortController. |
| T-07-16 | DoS / Unbounded list | infinite scroll | mitigate | Page-based pagination with size 50 from existing endpoint; hasMore derived from pagination.totalPages. Sentinel triggers ONE page-advance request per intersection (guard: `if (loadingMore |
| </threat_model> |
<success_criteria>
- One new file:
app/mobile/engagement/page.tsx(no other files modified) npx tsc --noEmit --prettyexits 0- Page renders inside Phase 2 mobile shell at
/mobile/engagement - All Plan 02 components consumed
- All Plan 01 endpoints called with
period={D7|D30|D90}query param - Existing
/api/engagement/usersreused as-is (no modifications to that file) - BottomNav.tsx and MoreDrawer.tsx unchanged
- All UI-SPEC copy strings present verbatim
- All UI-SPEC class strings on inline elements present verbatim
- Loading / empty / not-configured / error / no-matches states all wired
- Infinite scroll + Load more fallback both functional </success_criteria>