74 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | requirements_addressed | user_setup | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 08-engagement-user-profile-new | 02 | execute | 2 |
|
|
false |
|
|
|
Per ENG-06/07/08 (REQUIREMENTS.md), this page replaces the desktop user-detail
modal pattern on mobile so the device back gesture restores scroll on the overview.
It reuses the existing /api/engagement/user/[userId] endpoint (no new data, no
endpoint modifications) and the photo proxy from Plan 01.
Purpose: Deliver Phase 8's user-facing surface — the page Phase 7's
EngagementUserRow.tsx already links to (<Link href="/mobile/engagement/{graphUserId}">).
Output:
- 1 new page at
app/mobile/engagement/[userId]/page.tsx - 6 new components under
components/mobile/Engagement* - No modifications to existing files except the page (which is new) and the new
components (which are new). Explicitly do NOT touch
EngagementUserRow.tsx(D-01) orapp/api/engagement/user/[userId]/route.ts(D-22).
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/phases/08-engagement-user-profile-new/08-CONTEXT.md @.planning/phases/08-engagement-user-profile-new/08-UI-SPEC.md @CLAUDE.md/api/engagement/user/[userId] response shape (the EXISTING endpoint, NOT modified):
Source: app/api/engagement/user/[userId]/route.ts lines 483–576 (verified 2026-05-07)
Snapshot rows in snapshots[] are returned as raw DB rows (line 499:
snapshots: snapshotsResult.rows). The columns come from migration 041 — they
include the snake_case column period_end (verified at planning time:
migrations/041_create_engagement_tables.sql line 18 declares period_end DATE NOT NULL).
type EngagementUserDetailResponse = {
user: {
id: string;
displayName: string;
email: string;
jobTitle: string | null;
department: string | null;
accountEnabled: boolean | null;
autotaskResourceId: number | null;
};
afterHours: {
messages: number;
meetings: number;
messagesPct: number; // 0–100, rounded
meetingsPct: number; // 0–100, rounded
};
snapshots: Array<{
period_type: 'D7' | 'D30' | 'D90' | string; // raw DB rows, snake_case
period_end: string; // ISO date 'YYYY-MM-DD' — verified present (migration 041)
teams_chat_messages: number;
teams_private_messages: number;
emails_sent: number;
teams_meetings_attended: number;
teams_meetings_organized: number;
after_hours_messages: number;
[key: string]: unknown;
}>;
hours: {
d7: { total: number; billable: number };
d30: { total: number; billable: number };
d90: { total: number; billable: number };
} | null;
recentEntries: Array<{
entry_date: string; // ISO date or 'YYYY-MM-DD'
hours_worked: number;
billable: boolean | null;
notes: string | null;
title: string | null;
start_date_time: string | null;
end_date_time: string | null;
company_name: string | null;
}>;
recentTeamsMeetings: Array<{
subject: string | null;
startTime: string; // ISO
durationMinutes: number | null;
attendeeCount: number;
clientAttendeeCount: number;
hasClientAttendees: boolean;
clientCompanies: string[];
participantNames: string[];
matchedEntries: Array<{
hours_worked: number;
billable: boolean | null;
notes: string | null;
title: string | null;
company_name: string | null;
start_date_time: string | null;
end_date_time: string | null;
}>;
}>;
meetingCounts: { total: number; withClients: number };
dailyActivity: Array<{ date: string; meetings: number; zoomCalls: number; hours: number; meetingMins: number }>;
zoom: {
calls: { d7: ZoomCallBucket; d30: ZoomCallBucket; d90: ZoomCallBucket };
meetings: { d7: ZoomMeetBucket; d30: ZoomMeetBucket; d90: ZoomMeetBucket };
topClients: Array<{ companyName: string; callCount: number; meetingCount: number }>;
recentCalls: unknown[];
recentMeetings: unknown[];
} | null; // null when isZoomConfigured() is false OR tables missing
peerMax: { ... } | null; // Phase 8 IGNORES this (D-22)
trend: { hours: number; billable: number; meetings: number; calls: number };
};
Period mapping for accessing nested per-period buckets:
- period prop value 'D7' → access .hours.d7, .zoom.calls.d7, .zoom.meetings.d7
- period prop value 'D30' → access .hours.d30, .zoom.calls.d30, .zoom.meetings.d30
- period prop value 'D90' → access .hours.d90, .zoom.calls.d90, .zoom.meetings.d90
Period-scoped fields:
- hours: from response.hours[periodKey] where periodKey = period.toLowerCase()
- meetings attended: snapshot row matching period_type === period (response.snapshots)
- days worked: COUNT distinct entry_date in recentEntries (already filtered server-side
to the period's window because the endpoint passes periodDays to the recentEntries
query)
- after-hours: response.afterHours (already period-scoped server-side)
From components/mobile/EngagementPeriodChips.tsx (existing, unchanged):
export type EngagementPeriod = 'D7' | 'D30' | 'D90';
export interface EngagementPeriodChipsProps {
period: EngagementPeriod;
onPeriodChange: (next: EngagementPeriod) => void;
}
export function EngagementPeriodChips(props: EngagementPeriodChipsProps): JSX.Element;
From components/mobile/EngagementUserRow.tsx (existing, unchanged):
export function getInitials(displayName: string): string; // "Jordan Walsh" → "JW"
From lib/hooks/use-user-timezone.ts (existing, unchanged):
export function useUserTimezone(): string; // returns IANA tz like 'America/New_York'
export function formatInUserTimezone(
input: string | number | Date,
tz: string,
options?: Intl.DateTimeFormatOptions,
locale?: string, // defaults 'en-US'
): string;
shadcn primitives (existing in components/ui/):
- Card, CardContent (from '@/components/ui/card')
- Skeleton (from '@/components/ui/skeleton')
- Collapsible, CollapsibleContent, CollapsibleTrigger (from '@/components/ui/collapsible')
- Badge (from '@/components/ui/badge')
@app/api/engagement/user/[userId]/route.ts @components/mobile/EngagementUserRow.tsx @components/mobile/EngagementPeriodChips.tsx @components/mobile/EngagementSummaryCard.tsx @app/mobile/engagement/page.tsx @lib/hooks/use-user-timezone.ts @components/ui/card.tsx @components/ui/skeleton.tsx @components/ui/collapsible.tsx @components/ui/badge.tsx
## Out of scope for this plan (documentation only)UI-SPEC §Typography revision notes (r1) calls out updating text-[10px] in
components/mobile/EngagementUserRow.tsx (the avatar-initials non-standard
size) to the standard text-xs token. D-01 forbids modifying that file in
this phase. That update is deferred to a future Phase 7 patch or a Phase 11
polish phase. Phase 8 will not touch EngagementUserRow.tsx. No task or
acceptance criterion in this plan should attempt to apply that change. The
acceptance criteria below explicitly assert via git diff --name-only that
the file is untouched (D-01 guard rail).
File 1: components/mobile/EngagementProfileSkeleton.tsx
'use client';
/* EngagementProfileSkeleton — phase 08 (D-23).
* Purpose: Full-page loading skeleton matching the final layout —
* header skeleton + 4 metric-card skeletons (2×2) + breakdown card skeleton +
* 2 list-section skeletons. Period chips render OUTSIDE this skeleton (they
* drive the fetch). */
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export function EngagementProfileSkeleton() {
return (
<div className="space-y-4">
{/* Identity header skeleton */}
<Card className="py-0 shadow-none">
<CardContent className="px-4 py-4 flex items-center gap-3">
<Skeleton className="h-14 w-14 rounded-full shrink-0" />
<div className="flex-1 space-y-2 min-w-0">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-3 w-32" />
<Skeleton className="h-3 w-48" />
</div>
</CardContent>
</Card>
{/* 2×2 metric grid skeleton */}
<div className="grid grid-cols-2 gap-3">
{[0, 1, 2, 3].map((i) => (
<Card key={i} className="py-0 shadow-none">
<CardContent className="px-4 py-4">
<Skeleton className="h-7 w-16" />
<Skeleton className="h-3 w-24 mt-2" />
</CardContent>
</Card>
))}
</div>
{/* Breakdown card skeleton */}
<Card className="py-0 shadow-none">
<CardContent className="px-4 py-4 space-y-4">
<div className="space-y-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
</div>
<div className="space-y-2 border-t border-border pt-3">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
</div>
<div className="space-y-2 border-t border-border pt-3">
<Skeleton className="h-3 w-28" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
</div>
</CardContent>
</Card>
{/* Two list skeletons */}
{[0, 1].map((i) => (
<Card key={i} className="py-0 shadow-none">
<CardContent className="px-4 py-4 space-y-3">
<Skeleton className="h-4 w-32" />
{[0, 1, 2].map((j) => (
<div key={j} className="flex items-center justify-between">
<Skeleton className="h-3 w-40" />
<Skeleton className="h-3 w-12" />
</div>
))}
</CardContent>
</Card>
))}
</div>
);
}
File 2: app/mobile/engagement/[userId]/page.tsx
The page itself — 'use client', useState + useEffect + fetch, no SWR. In
this task the page imports ONLY EngagementProfileSkeleton and
EngagementPeriodChips — EngagementProfileHeader and
EngagementProfileMetricGrid are added in Task 1b. While they are missing, the
loaded-data branch renders a TODO placeholder so the page still type-checks and
builds.
Required imports:
'use client';
import { use, useEffect, useState } from 'react';
import Link from 'next/link';
import { toast } from 'sonner';
import { EngagementPeriodChips, type EngagementPeriod } from '@/components/mobile/EngagementPeriodChips';
import { EngagementProfileSkeleton } from '@/components/mobile/EngagementProfileSkeleton';
(Task 1b will add: EngagementProfileHeader, EngagementProfileMetricGrid. Task 2 will add: EngagementProfileBreakdown, EngagementRecentEntries, EngagementRecentMeetings.)
Inline response type (kept private to the page; do NOT export from the existing endpoint file because that would modify it and violate D-22):
interface ApiResponse {
user: {
id: string;
displayName: string;
email: string;
jobTitle: string | null;
department: string | null;
accountEnabled: boolean | null;
autotaskResourceId: number | null;
};
afterHours: { messages: number; meetings: number; messagesPct: number; meetingsPct: number };
snapshots: Array<{
period_type: string;
period_end: string; // verified present — migration 041 line 18
teams_chat_messages: number | null;
teams_private_messages: number | null;
emails_sent: number | null;
teams_meetings_attended: number | null;
teams_meetings_organized: number | null;
meeting_duration_seconds: number | null;
after_hours_messages: number | null;
}>;
hours: {
d7: { total: number; billable: number };
d30: { total: number; billable: number };
d90: { total: number; billable: number };
} | null;
recentEntries: Array<{
entry_date: string;
hours_worked: number;
billable: boolean | null;
notes: string | null;
title: string | null;
start_date_time: string | null;
end_date_time: string | null;
company_name: string | null;
}>;
recentTeamsMeetings: Array<{
subject: string | null;
startTime: string;
durationMinutes: number | null;
attendeeCount: number;
clientAttendeeCount: number;
hasClientAttendees: boolean;
clientCompanies: string[];
participantNames: string[];
matchedEntries: Array<{
hours_worked: number;
billable: boolean | null;
notes: string | null;
title: string | null;
company_name: string | null;
start_date_time: string | null;
end_date_time: string | null;
}>;
}>;
zoom: { calls: { d7: { total: number }; d30: { total: number }; d90: { total: number } } } | null;
}
Component shape:
export default function MobileEngagementUserProfilePage({
params,
}: {
params: Promise<{ userId: string }>;
}) {
// D-04: rely on App Router default scrollRestoration
const { userId } = use(params); // Next.js 16: params is a Promise — unwrap with React.use
const [period, setPeriod] = useState<EngagementPeriod>('D30'); // D-09
const [data, setData] = useState<ApiResponse | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [errorState, setErrorState] = useState<'none' | 'not-found' | 'failed'>('none');
// retryNonce: incrementing this re-runs the fetch effect without changing `period`.
// Used by the Retry button in the failed-state branch (issue-7 fix).
const [retryNonce, setRetryNonce] = useState<number>(0);
useEffect(() => {
let cancelled = false;
setLoading(true);
setErrorState('none');
fetch(`/api/engagement/user/${encodeURIComponent(userId)}?period=${period}`)
.then(async (res) => {
if (cancelled) return;
if (res.status === 404) {
setErrorState('not-found');
setData(null);
return;
}
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const json = (await res.json()) as ApiResponse;
setData(json);
})
.catch((err) => {
if (cancelled) return;
console.error('[mobile/engagement/profile] fetch failed', err);
setErrorState('failed');
toast.error('Failed to load profile — tap to retry');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [userId, period, retryNonce]);
// ── Error: 404 ──────────────────────────────────────────────────────
if (errorState === 'not-found') {
return (
<main className="px-4 pb-safe">
<div className="py-12 text-center space-y-3">
<h1 className="text-xl font-semibold">User not found</h1>
<p className="text-sm text-muted-foreground">This profile is no longer available.</p>
<Link href="/mobile/engagement" className="text-sm text-primary underline-offset-4 hover:underline inline-block min-h-[44px] py-3">
Back to Engagement
</Link>
</div>
</main>
);
}
// ── Error: 500/network — show skeleton + Retry ──────────────────────
if (errorState === 'failed' && !data) {
return (
<main className="px-4 pb-safe">
<EngagementPeriodChips period={period} onPeriodChange={setPeriod} />
<EngagementProfileSkeleton />
<button
type="button"
className="mt-4 inline-flex items-center justify-center min-h-[44px] px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm font-semibold"
onClick={() => setRetryNonce((n) => n + 1)}
>
Retry
</button>
</main>
);
}
// Helper to derive period-scoped values once data is loaded.
const periodKey = period === 'D7' ? 'd7' : period === 'D90' ? 'd90' : 'd30';
const hoursForPeriod = data?.hours?.[periodKey]?.total ?? 0;
const billableForPeriod = data?.hours?.[periodKey]?.billable ?? 0;
const daysWorkedForPeriod = data
? new Set(data.recentEntries.map((e) => String(e.entry_date).slice(0, 10))).size
: 0;
const snapshotForPeriod = data?.snapshots.find((s) => s.period_type === period) ?? null;
const meetingsAttended = snapshotForPeriod?.teams_meetings_attended ?? 0;
// Last-active derivation for the header (D-06):
// most recent of (recentEntries[0].entry_date, latest snapshot.period_end).
// If `period_end` ever turns out to be missing from the API at runtime, fall
// back to recentEntries[0].entry_date alone (executor can simplify this block
// — see <discovery> above).
let lastActiveAt: string | null = null;
if (data) {
const candidates: number[] = [];
if (data.recentEntries[0]) candidates.push(new Date(data.recentEntries[0].entry_date).getTime());
const latestSnapshot = data.snapshots
.filter((s) => s.period_end)
.map((s) => new Date(s.period_end).getTime())
.filter((n) => Number.isFinite(n))
.sort((a, b) => b - a)[0];
if (latestSnapshot) candidates.push(latestSnapshot);
if (candidates.length > 0) {
lastActiveAt = new Date(Math.max(...candidates)).toISOString();
}
}
return (
<main className="px-4 pb-safe">
<h1 className="text-xl font-semibold pt-4 pb-2">
{data?.user.displayName ?? ' '}
</h1>
<EngagementPeriodChips period={period} onPeriodChange={setPeriod} />
<div className="space-y-4">
{loading || !data ? (
<EngagementProfileSkeleton />
) : (
<>
{/* Task 1b will mount EngagementProfileHeader + EngagementProfileMetricGrid here.
Task 2 will add EngagementProfileBreakdown + EngagementRecentEntries + EngagementRecentMeetings. */}
<div className="text-sm text-muted-foreground py-2">
Loaded: {data.user.displayName}. Header and metric grid wired in Task 1b.
</div>
</>
)}
</div>
</main>
);
}
Notes:
'use client'at the very top.paramsis a Promise in Next.js 16; unwrap withReact.use(params)(named importuse). This matches the existing endpoint atapp/api/engagement/user/[userId]/route.tsline 7 ({ params }: { params: Promise<{ userId: string }> }).- D-04 (scroll restoration): no custom code in this task. Next.js App Router default
scrollRestoration: truehandles the device back gesture. Do NOT addsessionStorageworkarounds. The// D-04: rely on App Router default scrollRestorationcomment near the top of the function makes the decision visible to the checker. - D-13:
hoursForPeriod,billableForPeriod, etc. all default to 0 — the page never renders—for these. - The placeholder
<div>...Loaded: …</div>is removed in Task 1b when the real Header + MetricGrid are mounted. retryNonceincrement forces theuseEffectto run again because it is part of the dependency array — this is a deterministic, non-magic refetch trigger (issue-7 fix). test -f /opt/stacks/pulse/app/mobile/engagement/[userId]/page.tsx && test -f /opt/stacks/pulse/components/mobile/EngagementProfileSkeleton.tsx && grep -q "EngagementProfileSkeleton" "/opt/stacks/pulse/app/mobile/engagement/[userId]/page.tsx" && grep -q "EngagementPeriodChips" "/opt/stacks/pulse/app/mobile/engagement/[userId]/page.tsx" && grep -q "retryNonce" "/opt/stacks/pulse/app/mobile/engagement/[userId]/page.tsx" && grep -q "EngagementProfileSkeleton" "/opt/stacks/pulse/components/mobile/EngagementProfileSkeleton.tsx" && npx tsc --noEmit --pretty && npm run build <acceptance_criteria>- File
app/mobile/engagement/[userId]/page.tsxexists. - File
components/mobile/EngagementProfileSkeleton.tsxexists. grep -c "'use client'" app/mobile/engagement/[userId]/page.tsxreturns 1.grep -c "'use client'" components/mobile/EngagementProfileSkeleton.tsxreturns 1.grep -c "EngagementPeriodChips" app/mobile/engagement/[userId]/page.tsxreturns at least 2 (import + JSX).grep -c "EngagementProfileSkeleton" app/mobile/engagement/[userId]/page.tsxreturns at least 2.grep -c "useState<EngagementPeriod>('D30')" app/mobile/engagement/[userId]/page.tsxreturns 1 (D-09 default).grep -c "fetch(\/api/engagement/user/" app/mobile/engagement/[userId]/page.tsxreturns at least 1, AND the line includes?period=`.grep -c "retryNonce" app/mobile/engagement/[userId]/page.tsxreturns at least 3 (state declaration, deps array, onClick handler — issue-7 fix).grep -c "setRetryNonce((n) => n + 1)" app/mobile/engagement/[userId]/page.tsxreturns at least 1 (the Retry click handler — issue-7 fix).grep -c "User not found" app/mobile/engagement/[userId]/page.tsxreturns at least 1 (D-24 404 copy).grep -c "Back to Engagement" app/mobile/engagement/[userId]/page.tsxreturns at least 1 (D-24 404 link).grep -c "toast.error" app/mobile/engagement/[userId]/page.tsxreturns at least 1 (D-24 500 toast).grep -c "Retry" app/mobile/engagement/[userId]/page.tsxreturns at least 1 (D-24 retry button).grep -c "scrollRestoration" app/mobile/engagement/[userId]/page.tsxreturns at least 1 (D-04 comment).grep -c "period_end" app/mobile/engagement/[userId]/page.tsxreturns at least 1 (snapshot last-active derivation).git diff --name-only -- components/mobile/EngagementUserRow.tsxproduces no output (D-01 guard rail).git diff --name-only -- app/api/engagement/user/[userId]/route.tsproduces no output (D-22 guard rail).npx tsc --noEmit --prettyexits 0.npm run buildexits 0. </acceptance_criteria> Visiting/mobile/engagement/{validId}after login renders: H1 (display name) → sticky period chips → full skeleton during load. After load, the placeholder div confirms the data fetch round-trip. 404 → not-found page + back link. 500 → skeleton + toast + Retry button that incrementsretryNonceand re-triggers the fetch effect. EngagementUserRow.tsx and the data endpoint are untouched. Type-check and build both pass.
- File
File 1: components/mobile/EngagementProfileHeader.tsx
Identity header card per UI-SPEC §Layout (identity-card section), §Color (mailto
uses text-primary), §Typography (display name = text-xl font-semibold,
secondary rows = text-xs text-muted-foreground for department/jobTitle/last
active, mailto = text-sm text-primary), §Date/Time Formatting (last active
relative ≤7d / absolute >7d via formatInUserTimezone).
Props:
export interface EngagementProfileHeaderProps {
displayName: string;
email: string;
jobTitle: string | null;
department: string | null;
// Last-active source: caller derives from response (most recent of recentEntries[0].entry_date,
// or the most-recent snapshots[].period_end). null if no signal at all.
lastActiveAt: string | null; // ISO date string or null (D-06)
// Used to build the photo URL — userId is the same value as the route segment
userId: string;
}
Implementation requirements:
- Avatar block (left,
h-14 w-14 rounded-full):- State:
const [photoFailed, setPhotoFailed] = useState(false); - When
!photoFailed: render<img src={\/api/mobile/engagement/user/${userId}/photo`} alt={displayName} className="h-14 w-14 rounded-full object-cover" onError={() => setPhotoFailed(true)} />` - When
photoFailed === true: render the initials span:<span aria-hidden="true" className="h-14 w-14 rounded-full bg-muted flex items-center justify-center shrink-0 text-xs font-semibold text-foreground"> {getInitials(displayName)} </span> - Import
getInitialsfrom@/components/mobile/EngagementUserRow.
- State:
- Identity stack (right, flex-1 min-w-0 space-y-1):
<p className="text-xl font-semibold truncate">{displayName}</p>(UI-SPEC r1: text-xl, not text-lg)- If
jobTitle:<p className="text-xs text-muted-foreground truncate">{jobTitle}</p>(D-06 row 1) - If
department:<p className="text-xs text-muted-foreground truncate">{department}</p>(D-06 row 2; raw value, no prefix label per copywriting contract) <a href={\mailto:${email}`} className="text-sm text-primary underline-offset-4 hover:underline truncate block min-h-[44px] flex items-center">{email}` (UI-SPEC mailto styling; 44px touch target floor)- If
lastActiveAt: render last-active row usinguseUserTimezone()and the rule:- Compute
const ms = Date.now() - new Date(lastActiveAt).getTime(); - If
ms <= 7 * 24 * 60 * 60 * 1000: relative — usedate-fnsformatDistanceToNow(new Date(lastActiveAt), { addSuffix: true })and prefix with "Active " → e.g. "Active 2 hours ago" - Else: absolute —
formatInUserTimezone(lastActiveAt, tz, { month: 'short', day: 'numeric', year: 'numeric' })and prefix with "Last active " → e.g. "Last active May 5, 2026" - Render:
<p className="text-xs text-muted-foreground">{label}</p>
- Compute
- Card layout:
<Card className="py-0 shadow-none"><CardContent className="px-4 py-4 flex items-center gap-3"> ... </CardContent></Card> - Mark
'use client'at top. - Imports:
Card, CardContentfrom@/components/ui/card;getInitialsfrom@/components/mobile/EngagementUserRow;useUserTimezone, formatInUserTimezonefrom@/lib/hooks/use-user-timezone;formatDistanceToNowfromdate-fns;useStatefromreact.
File 2: components/mobile/EngagementProfileMetricGrid.tsx
2×2 grid of 4 hero metric cards per D-11/D-12, copywriting contract row 3.
Props:
export interface EngagementProfileMetricGridProps {
hoursWorked: number; // already period-scoped by caller
billableHours: number;
daysWorked: number;
meetingsAttended: number;
}
Implementation:
- Outer:
<div className="grid grid-cols-2 gap-3"> - Each cell mirrors
EngagementSummaryCard.tsxstructure:<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> - Order (per D-12): top-left "Hours worked" (
hoursWorked.toFixed(1) + 'h'), top-right "Billable hours" (billableHours.toFixed(1) + 'h'), bottom-left "Days worked" (daysWorked.toString()), bottom-right "Meetings attended" (meetingsAttended.toString()) - D-13: when value is 0 or null/undefined → render
0(or0.0hfor hour values), NEVER—. 'use client'at top.
File 3 (modify): app/mobile/engagement/[userId]/page.tsx
Add two imports at the top alongside the existing imports from Task 1a:
import { EngagementProfileHeader } from '@/components/mobile/EngagementProfileHeader';
import { EngagementProfileMetricGrid } from '@/components/mobile/EngagementProfileMetricGrid';
Replace the placeholder <div> from Task 1a (the one that says "Header and
metric grid wired in Task 1b") with the two real sections, in this exact order:
<EngagementProfileHeader
displayName={data.user.displayName}
email={data.user.email}
jobTitle={data.user.jobTitle}
department={data.user.department}
lastActiveAt={lastActiveAt}
userId={userId}
/>
<EngagementProfileMetricGrid
hoursWorked={hoursForPeriod}
billableHours={billableForPeriod}
daysWorked={daysWorkedForPeriod}
meetingsAttended={Number(meetingsAttended)}
/>
{/* Task 2 will mount EngagementProfileBreakdown + EngagementRecentEntries + EngagementRecentMeetings here. */}
Rules:
- Do NOT change the page's existing fetch/state/error wiring.
- Do NOT modify
EngagementUserRow.tsx(D-01 guard rail). - Do NOT modify
app/api/engagement/user/[userId]/route.ts(D-22 guard rail). test -f /opt/stacks/pulse/components/mobile/EngagementProfileHeader.tsx && test -f /opt/stacks/pulse/components/mobile/EngagementProfileMetricGrid.tsx && grep -q "EngagementProfileHeader" "/opt/stacks/pulse/app/mobile/engagement/[userId]/page.tsx" && grep -q "EngagementProfileMetricGrid" "/opt/stacks/pulse/app/mobile/engagement/[userId]/page.tsx" && grep -q "EngagementProfileHeader" "/opt/stacks/pulse/components/mobile/EngagementProfileHeader.tsx" && grep -q "grid-cols-2" "/opt/stacks/pulse/components/mobile/EngagementProfileMetricGrid.tsx" && npx tsc --noEmit --pretty && npm run build <acceptance_criteria>- File
components/mobile/EngagementProfileHeader.tsxexists. - File
components/mobile/EngagementProfileMetricGrid.tsxexists. grep -c "'use client'" components/mobile/EngagementProfileHeader.tsxreturns 1.grep -c "'use client'" components/mobile/EngagementProfileMetricGrid.tsxreturns 1.grep -c "/api/mobile/engagement/user/" components/mobile/EngagementProfileHeader.tsxreturns at least 1 (photo URL).grep -c "onError" components/mobile/EngagementProfileHeader.tsxreturns at least 1 (initials fallback wiring).grep -c "getInitials" components/mobile/EngagementProfileHeader.tsxreturns at least 2 (import + call).grep -c "grid-cols-2 gap-3" components/mobile/EngagementProfileMetricGrid.tsxreturns at least 1.grep -c "text-2xl font-semibold" components/mobile/EngagementProfileMetricGrid.tsxreturns at least 1.grep -c "Hours worked" components/mobile/EngagementProfileMetricGrid.tsxreturns at least 1.grep -c "Billable hours" components/mobile/EngagementProfileMetricGrid.tsxreturns at least 1.grep -c "Days worked" components/mobile/EngagementProfileMetricGrid.tsxreturns at least 1.grep -c "Meetings attended" components/mobile/EngagementProfileMetricGrid.tsxreturns at least 1.grep -c "EngagementProfileHeader" app/mobile/engagement/[userId]/page.tsxreturns at least 2 (import + JSX).grep -c "EngagementProfileMetricGrid" app/mobile/engagement/[userId]/page.tsxreturns at least 2 (import + JSX).grep -c "Header and metric grid wired in Task 1b" app/mobile/engagement/[userId]/page.tsxreturns 0 (placeholder removed).git diff --name-only -- components/mobile/EngagementUserRow.tsxproduces no output (D-01 guard rail).git diff --name-only -- app/api/engagement/user/[userId]/route.tsproduces no output (D-22 guard rail).npx tsc --noEmit --prettyexits 0.npm run buildexits 0. </acceptance_criteria> Visiting/mobile/engagement/{validId}now renders: H1 → sticky chips → identity header card with avatar (photo or initials) → 2×2 metric grid with the 4 hero metrics for the selected period. Period chip change refetches and recomputes metrics. Task 2 will add the breakdown card and recent-items sections.
- File
File 1: components/mobile/EngagementProfileBreakdown.tsx
Single Card with three labeled subsections per D-14..D-17. UI-SPEC overrides
D-16 to use py-2 (not py-1.5) for metric rows.
Props:
export interface EngagementProfileBreakdownProps {
// Time subsection
hoursWorked: number;
billableHours: number;
daysWorked: number;
// Communication subsection
teamsMessages: number; // chat + private summed by caller
emailsSent: number;
afterHoursMessagesPct: number;
afterHoursMeetingsPct: number;
// Meetings subsection
meetingsAttended: number;
meetingsOrganized: number;
meetingDurationSeconds: number;
// Optional: only render Zoom row if non-null (D-17 presence rule)
zoomCalls: number | null;
}
Structure (exact JSX skeleton — the executor must match this row-and-section shape):
'use client';
import { Card, CardContent } from '@/components/ui/card';
const subsectionLabel = "text-sm font-semibold text-muted-foreground mb-2";
const metricRow = "flex justify-between text-sm py-2";
function MetricRow({ label, value }: { label: string; value: string }) {
return (
<div className={metricRow}>
<dt className="text-muted-foreground">{label}</dt>
<dd className="text-foreground tabular-nums">{value}</dd>
</div>
);
}
export function EngagementProfileBreakdown(props: EngagementProfileBreakdownProps) {
const utilizationPct = props.hoursWorked > 0
? Math.round((props.billableHours / props.hoursWorked) * 100)
: null;
const meetingHours = props.meetingDurationSeconds / 3600;
const showAfterHours = props.afterHoursMessagesPct > 0 || props.afterHoursMeetingsPct > 0;
const showZoom = props.zoomCalls !== null && props.zoomCalls !== undefined;
return (
<Card className="py-0 shadow-none">
<CardContent className="px-4 py-4 space-y-4">
{/* Time */}
<section>
<h3 className={subsectionLabel}>Time</h3>
<dl>
<MetricRow label="Hours worked" value={`${props.hoursWorked.toFixed(1)}h`} />
<MetricRow label="Billable hours" value={`${props.billableHours.toFixed(1)}h`} />
<MetricRow label="Days worked" value={String(props.daysWorked)} />
{utilizationPct !== null && (
<MetricRow label={`Utilization · ${utilizationPct}%`} value={`${utilizationPct}%`} />
)}
</dl>
</section>
{/* Communication */}
<section className="border-t border-border pt-3">
<h3 className={subsectionLabel}>Communication</h3>
<dl>
<MetricRow label="Teams messages" value={String(props.teamsMessages)} />
<MetricRow label="Emails sent" value={String(props.emailsSent)} />
{showAfterHours && (
<div className={metricRow}>
<dt className="text-muted-foreground">
After-hours · {props.afterHoursMessagesPct}% messages, {props.afterHoursMeetingsPct}% meetings
</dt>
<dd className="text-foreground tabular-nums" />
</div>
)}
</dl>
</section>
{/* Meetings */}
<section className="border-t border-border pt-3">
<h3 className={subsectionLabel}>Meetings</h3>
<dl>
<MetricRow label="Meetings attended" value={String(props.meetingsAttended)} />
<MetricRow label="Meetings organized" value={String(props.meetingsOrganized)} />
<MetricRow label="Meeting duration" value={`${meetingHours.toFixed(1)}h`} />
{showZoom && (
<MetricRow label="Zoom calls" value={String(props.zoomCalls)} />
)}
</dl>
</section>
</CardContent>
</Card>
);
}
Rules per D-13/D-17:
- Hours / billable / days / meetings (first-class metrics): always render with 0
- Utilization: hide row only when
hoursWorked === 0(utilizationPct null) - After-hours: hide entire row when both pcts are 0 (D-15)
- Zoom calls: hide row when
zoomCalls === null(presence signal — D-17)
File 2: components/mobile/EngagementRecentEntries.tsx
Tap-to-expand list of up to 10 recent time entries (D-18, D-19, D-20, D-21).
export interface RecentTimeEntry {
entry_date: string;
hours_worked: number;
billable: boolean | null;
notes: string | null;
title: string | null;
start_date_time: string | null;
end_date_time: string | null;
company_name: string | null;
}
export interface EngagementRecentEntriesProps {
entries: RecentTimeEntry[]; // caller passes recentEntries.slice(0, 10)
}
Structure:
'use client';
import { useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useUserTimezone, formatInUserTimezone } from '@/lib/hooks/use-user-timezone';
export function EngagementRecentEntries({ entries }: EngagementRecentEntriesProps) {
const tz = useUserTimezone();
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const toggle = (id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
// ID derivation: caller doesn't pass an explicit id, so derive a stable string per row.
const idFor = (e: RecentTimeEntry, i: number) =>
`${e.entry_date}|${e.start_date_time ?? ''}|${i}`;
return (
<Card className="py-0 shadow-none">
<CardContent className="px-4 py-4">
<h3 className="text-sm font-semibold text-foreground mb-3">Recent time entries</h3>
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">No time entries in the last 30 days</p>
) : (
<ul className="space-y-1">
{entries.slice(0, 10).map((entry, i) => {
const id = idFor(entry, i);
const open = expandedIds.has(id);
const dateLabel = formatInUserTimezone(entry.entry_date, tz, { month: 'short', day: 'numeric' });
const isBillable = entry.billable !== false; // null defaults true
const oneLine = entry.notes
? entry.notes.split('\n')[0]?.slice(0, 80) ?? ''
: (entry.title ?? '');
return (
<li key={id}>
<Collapsible open={open} onOpenChange={() => toggle(id)}>
<CollapsibleTrigger asChild>
<button
type="button"
className="w-full text-left flex items-center justify-between gap-2 min-h-[44px] py-2"
>
<span className="flex-1 min-w-0 flex items-baseline gap-2">
<span className="text-sm font-medium tabular-nums shrink-0">{dateLabel}</span>
<span className="text-sm text-muted-foreground tabular-nums shrink-0">
{entry.hours_worked.toFixed(1)}h
</span>
{isBillable && (
<Badge variant="secondary" className="shrink-0">Billable</Badge>
)}
<span className="text-sm text-muted-foreground truncate">{oneLine}</span>
</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="text-sm text-muted-foreground pb-3 pl-2 space-y-1">
{entry.title && <p><span className="font-medium text-foreground">Title:</span> {entry.title}</p>}
{entry.company_name && <p><span className="font-medium text-foreground">Company:</span> {entry.company_name}</p>}
{entry.notes && <p className="whitespace-pre-wrap">{entry.notes}</p>}
{entry.start_date_time && (
<p>
<span className="font-medium text-foreground">Started:</span>{' '}
{formatInUserTimezone(entry.start_date_time, tz, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}
</p>
)}
</CollapsibleContent>
</Collapsible>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
);
}
Notes:
- D-19:
entries.slice(0, 10)— the caller may pass more; we hard-bound here as defence. - D-20: local
Set<string>state for expanded IDs — no URL state, no router push. - D-21: empty state copy "No time entries in the last 30 days" (UI-SPEC copywriting contract — hardcoded; period context is implicit from chips above).
File 3: components/mobile/EngagementRecentMeetings.tsx
Same pattern as Recent entries, for recentTeamsMeetings. The full inline JSX
skeleton below mirrors File 2 in fidelity (issue-5 fix). Executor must match
this structure.
export interface RecentMeeting {
subject: string | null;
startTime: string;
durationMinutes: number | null;
attendeeCount: number;
clientAttendeeCount: number;
hasClientAttendees: boolean;
clientCompanies: string[];
participantNames: string[];
matchedEntries: Array<{
hours_worked: number;
billable: boolean | null;
notes: string | null;
title: string | null;
company_name: string | null;
start_date_time: string | null;
end_date_time: string | null;
}>;
}
export interface EngagementRecentMeetingsProps {
meetings: RecentMeeting[];
}
Structure (exact JSX skeleton — match this row-and-section shape exactly):
'use client';
import { useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useUserTimezone, formatInUserTimezone } from '@/lib/hooks/use-user-timezone';
export function EngagementRecentMeetings({ meetings }: EngagementRecentMeetingsProps) {
const tz = useUserTimezone();
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const toggle = (id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
// ID derivation: caller doesn't pass an explicit id, so derive a stable string per row.
const idFor = (m: RecentMeeting, i: number) =>
`${m.startTime}|${m.subject ?? ''}|${i}`;
// Format a duration in minutes as "Hh Mm" / "Mm" — used in the collapsed summary
const fmtDuration = (mins: number | null): string => {
if (mins === null || mins === undefined || !Number.isFinite(mins) || mins <= 0) return '';
const h = Math.floor(mins / 60);
const m = Math.round(mins % 60);
if (h > 0 && m > 0) return `${h}h ${m}m`;
if (h > 0) return `${h}h`;
return `${m}m`;
};
return (
<Card className="py-0 shadow-none">
<CardContent className="px-4 py-4">
<h3 className="text-sm font-semibold text-foreground mb-3">Recent meetings</h3>
{meetings.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">No meetings recorded</p>
) : (
<ul className="space-y-1">
{meetings.slice(0, 10).map((meeting, i) => {
const id = idFor(meeting, i);
const open = expandedIds.has(id);
const subjectLabel = meeting.subject ?? '(no subject)';
const startLabel = formatInUserTimezone(meeting.startTime, tz, {
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
});
const durationLabel = fmtDuration(meeting.durationMinutes);
const attendeeLabel = meeting.attendeeCount > 0
? `${meeting.attendeeCount} attendee${meeting.attendeeCount === 1 ? '' : 's'}`
: '';
const visibleParticipants = meeting.participantNames.slice(0, 5);
const moreCount = Math.max(0, meeting.participantNames.length - 5);
return (
<li key={id}>
<Collapsible open={open} onOpenChange={() => toggle(id)}>
<CollapsibleTrigger asChild>
<button
type="button"
className="w-full text-left flex items-center justify-between gap-2 min-h-[44px] py-2"
>
<span className="flex-1 min-w-0 flex flex-col">
<span className="text-sm font-medium truncate">{subjectLabel}</span>
<span className="text-xs text-muted-foreground tabular-nums">
{startLabel}
{durationLabel ? ` · ${durationLabel}` : ''}
{attendeeLabel ? ` · ${attendeeLabel}` : ''}
</span>
</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="text-sm text-muted-foreground pb-3 pl-2 space-y-2">
{meeting.matchedEntries.length > 0 && (
<div>
<p className="font-medium text-foreground mb-1">Matched time entries:</p>
<ul className="space-y-1">
{meeting.matchedEntries.map((te, j) => (
<li key={j} className="flex flex-wrap gap-x-2">
<span className="text-foreground tabular-nums">{te.hours_worked.toFixed(1)}h</span>
{te.company_name && <span>· {te.company_name}</span>}
{te.notes && <span className="truncate">· {te.notes.split('\n')[0]?.slice(0, 80) ?? ''}</span>}
</li>
))}
</ul>
</div>
)}
{visibleParticipants.length > 0 && (
<p>
<span className="font-medium text-foreground">Attendees:</span>{' '}
{visibleParticipants.join(', ')}
{moreCount > 0 ? ` and ${moreCount} more` : ''}
</p>
)}
{durationLabel && (
<p>
<span className="font-medium text-foreground">Duration:</span> {durationLabel}
</p>
)}
</CollapsibleContent>
</Collapsible>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
);
}
Notes (mirroring Recent entries):
- D-19:
meetings.slice(0, 10)hard bound. - D-20: local
Set<string>state, no URL state. - D-21: empty state copy "No meetings recorded" (UI-SPEC copywriting contract — hardcoded).
- The expanded "Attendees" and "Matched time entries" rows reuse the existing
participantNamesandmatchedEntriesarrays from the response — no new endpoint fields are introduced. - Zoom call linkage is NOT rendered in this iteration: the existing endpoint
populates
meeting.matchedEntries(Teams meeting → time entry overlap) but not Zoom-call linkage on Teams meetings. That cross-reference is a Phase-9+ enhancement.
File 4 (modify): app/mobile/engagement/[userId]/page.tsx
Add three imports at the top:
import { EngagementProfileBreakdown } from '@/components/mobile/EngagementProfileBreakdown';
import { EngagementRecentEntries } from '@/components/mobile/EngagementRecentEntries';
import { EngagementRecentMeetings } from '@/components/mobile/EngagementRecentMeetings';
Inside the <>...</> block in the loaded-data branch (where Task 1b's comment
says Task 2 will mount EngagementProfileBreakdown ...), replace the comment
with the three sections, in this exact order, between
<EngagementProfileMetricGrid .../> and the closing fragment:
<EngagementProfileBreakdown
hoursWorked={hoursForPeriod}
billableHours={billableForPeriod}
daysWorked={daysWorkedForPeriod}
teamsMessages={
Number(snapshotForPeriod?.teams_chat_messages ?? 0) +
Number(snapshotForPeriod?.teams_private_messages ?? 0)
}
emailsSent={Number(snapshotForPeriod?.emails_sent ?? 0)}
afterHoursMessagesPct={data.afterHours.messagesPct}
afterHoursMeetingsPct={data.afterHours.meetingsPct}
meetingsAttended={Number(snapshotForPeriod?.teams_meetings_attended ?? 0)}
meetingsOrganized={Number(snapshotForPeriod?.teams_meetings_organized ?? 0)}
meetingDurationSeconds={Number(snapshotForPeriod?.meeting_duration_seconds ?? 0)}
zoomCalls={data.zoom ? Number(data.zoom.calls[periodKey].total) : null}
/>
<EngagementRecentEntries entries={data.recentEntries} />
<EngagementRecentMeetings meetings={data.recentTeamsMeetings} />
Rules:
- Do NOT change the page's existing imports list other than adding the three new component imports.
- Do NOT change the period state, fetch, retryNonce, or skeleton wiring.
- Do NOT add any new endpoints or modify the existing data endpoint (D-22 guard rail).
- Do NOT modify
EngagementUserRow.tsx(D-01 guard rail). test -f /opt/stacks/pulse/components/mobile/EngagementProfileBreakdown.tsx && test -f /opt/stacks/pulse/components/mobile/EngagementRecentEntries.tsx && test -f /opt/stacks/pulse/components/mobile/EngagementRecentMeetings.tsx && grep -q "EngagementProfileBreakdown" "/opt/stacks/pulse/app/mobile/engagement/[userId]/page.tsx" && grep -q "EngagementRecentEntries" "/opt/stacks/pulse/app/mobile/engagement/[userId]/page.tsx" && grep -q "EngagementRecentMeetings" "/opt/stacks/pulse/app/mobile/engagement/[userId]/page.tsx" && grep -q "EngagementProfileBreakdown" "/opt/stacks/pulse/components/mobile/EngagementProfileBreakdown.tsx" && grep -q "EngagementRecentEntries" "/opt/stacks/pulse/components/mobile/EngagementRecentEntries.tsx" && grep -q "EngagementRecentMeetings" "/opt/stacks/pulse/components/mobile/EngagementRecentMeetings.tsx" && npx tsc --noEmit --pretty && npm run build <acceptance_criteria>- File
components/mobile/EngagementProfileBreakdown.tsxexists. - File
components/mobile/EngagementRecentEntries.tsxexists. - File
components/mobile/EngagementRecentMeetings.tsxexists. grep -c "'use client'" components/mobile/EngagementProfileBreakdown.tsxreturns 1.grep -c "'use client'" components/mobile/EngagementRecentEntries.tsxreturns 1.grep -c "'use client'" components/mobile/EngagementRecentMeetings.tsxreturns 1.grep -c ">Time<" components/mobile/EngagementProfileBreakdown.tsxreturns at least 1, ANDgrep -c ">Communication<" components/mobile/EngagementProfileBreakdown.tsxreturns at least 1, ANDgrep -c ">Meetings<" components/mobile/EngagementProfileBreakdown.tsxreturns at least 1 (the three subsection headers).grep -c "After-hours" components/mobile/EngagementProfileBreakdown.tsxreturns at least 1.grep -c "py-2" components/mobile/EngagementProfileBreakdown.tsxreturns at least 1 (UI-SPEC override of D-16).grep -c "border-t border-border" components/mobile/EngagementProfileBreakdown.tsxreturns at least 2 (the two inter-section dividers).grep -c "Collapsible" components/mobile/EngagementRecentEntries.tsxreturns at least 2 (import + JSX).grep -c "Collapsible" components/mobile/EngagementRecentMeetings.tsxreturns at least 2.grep -c "Set<string>" components/mobile/EngagementRecentEntries.tsxreturns at least 1 (D-20 expand-state).grep -c "Set<string>" components/mobile/EngagementRecentMeetings.tsxreturns at least 1.grep -c "slice(0, 10)" components/mobile/EngagementRecentEntries.tsxreturns at least 1 (D-19 bound).grep -c "slice(0, 10)" components/mobile/EngagementRecentMeetings.tsxreturns at least 1.grep -c "Recent time entries" components/mobile/EngagementRecentEntries.tsxreturns at least 1.grep -c "Recent meetings" components/mobile/EngagementRecentMeetings.tsxreturns at least 1.grep -c "(no subject)" components/mobile/EngagementRecentMeetings.tsxreturns at least 1 (subject fallback per issue-5 spec).grep -c "Matched time entries" components/mobile/EngagementRecentMeetings.tsxreturns at least 1 (expanded matchedEntries section per issue-5 spec).grep -c "Attendees" components/mobile/EngagementRecentMeetings.tsxreturns at least 1 (expanded participants section per issue-5 spec).grep -c "No time entries in the last 30 days" components/mobile/EngagementRecentEntries.tsxreturns at least 1 (D-21 empty copy).grep -c "No meetings recorded" components/mobile/EngagementRecentMeetings.tsxreturns at least 1.grep -c "Billable" components/mobile/EngagementRecentEntries.tsxreturns at least 1 (Badge usage).grep -c "useUserTimezone" components/mobile/EngagementRecentEntries.tsxreturns at least 2 (import + call).grep -c "useUserTimezone" components/mobile/EngagementRecentMeetings.tsxreturns at least 2 (import + call).grep -c "EngagementProfileBreakdown" app/mobile/engagement/[userId]/page.tsxreturns at least 2 (import + JSX).grep -c "EngagementRecentEntries" app/mobile/engagement/[userId]/page.tsxreturns at least 2.grep -c "EngagementRecentMeetings" app/mobile/engagement/[userId]/page.tsxreturns at least 2.git diff --name-only -- components/mobile/EngagementUserRow.tsxproduces no output (D-01 guard rail).git diff --name-only -- app/api/engagement/user/[userId]/route.tsproduces no output (D-22, CONTEXT.md "no new data" guard rail).npx tsc --noEmit --prettyexits 0.npm run buildexits 0. </acceptance_criteria> The full Phase 8 profile page renders. Below the 2×2 metric grid the page now shows: an activity-breakdown Card with three subsections (Time / Communication / Meetings) including the after-hours row inside Communication and the optional Zoom-calls row in Meetings; a Recent time entries Card with up to 10 collapsible rows (Billable badge, date, hours, one-line preview; tap to reveal title/company/notes/start time); and a Recent meetings Card with up to 10 collapsible rows (subject or '(no subject)', start datetime, duration, attendee count; tap to reveal matched entries and attendees). Period chip changes recompute breakdown values; recent sections stay 10/10. EngagementUserRow.tsx and the data endpoint are untouched. Build and type-check pass.
- File
If the user reports `OK`, SC#2 is satisfied and the phase can ship.
If the user reports `BROKEN: scroll resets`, the executor MUST stop and
return control to the planner so a follow-up plan can add the
`sessionStorage`-based scroll-restoration shim allowed by CONTEXT.md
D-04's fallback clause. Do NOT attempt to fix it inline in this task.
1. Run `npm run dev` (Pulse runs on http://localhost:3100).
2. Sign in as any authenticated user.
3. Open `/mobile/engagement` in a phone-width browser (Chrome DevTools
device emulator on iPhone 15 Pro is fine).
4. Scroll halfway down the user list (verify multiple rows are off the top
of the viewport).
5. Tap any user row → land on the new `/mobile/engagement/[userId]`
profile page. Confirm the page renders with header → period chips →
identity card → 2×2 metric grid → breakdown card → recent entries →
recent meetings.
6. Press the browser back button (or use the OS back gesture if testing on
a real phone).
7. Confirm the overview list restored at the same scroll position you left
it at — NOT scrolled back to the top.
User confirms scroll position restored on back navigation per the steps in ``.
Reply with one of:
- `OK` — scroll restoration works as expected, SC#2 satisfied.
- `BROKEN: scroll resets` — the overview scrolled back to the top. The
planner will spawn a follow-up plan to add a `sessionStorage`-based
scroll-restoration shim (per CONTEXT.md D-04 fallback clause) before the
phase ships.
- `BROKEN: ` — describe what you observed; planner will
triage.
User has replied with `OK` (SC#2 satisfied — phase ready to ship) OR with
`BROKEN: ...` (executor returns control to the planner for a follow-up plan
that adds the sessionStorage scroll-restoration shim before shipping).
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| browser → /mobile/engagement/[userId] (page render) | Authenticated browser session; userId from URL is untrusted input rendered into JSX and used in client-side fetches |
| browser client → /api/engagement/user/[userId] (existing endpoint) | Already-authenticated existing endpoint; gated by Better Auth middleware (page route is NOT in /api/mobile public list — middleware enforces session) |
| browser client → /api/mobile/engagement/user/[userId]/photo | Auth gate enforced by Plan 01's handler |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-08-09 | Information Disclosure (PII in URL/referer) | profile page | medium | mitigate | The URL contains the Graph user id (an opaque GUID-like string), NOT the email or display name — so referer leakage to outbound links exposes only the opaque id. The page DOES render the email as visible text inside a mailto: anchor; this is intentional for the manager workflow but means the email is in the rendered DOM. No additional logging of email is introduced. Mitigation: do not put email or displayName into query strings or document.title beyond the H1. |
| T-08-10 | Information Disclosure (DOM logging) | profile page | low | mitigate | The page uses console.error('[mobile/engagement/profile] fetch failed', err) only on fetch failure — err is an Error object that does NOT contain response body or PII (HTTP status only via the thrown message). The full data response is never console.log-ed. Verified by acceptance: no console.log appears in the new page. |
| T-08-11 | Cross-site Scripting (notes rendering) | EngagementRecentEntries | low | mitigate | Time-entry notes may contain operator-typed text. Rendered as React text content inside <p> (auto-escaped by React) and inside a whitespace-pre-wrap paragraph — never via dangerouslySetInnerHTML. Verified by acceptance: no dangerouslySetInnerHTML in any new component. |
| T-08-12 | Tampering (userId path param) | profile page → /api/engagement/user/[userId] | low | accept | Browser passes userId from URL via encodeURIComponent into the fetch. The existing endpoint already exists, ships in production, and uses parameterized SQL via postgresClient.query(... [userId]) — no SQL injection surface to introduce. No change. |
| T-08-13 | Information Disclosure (404 oracle) | profile page | low | mitigate | A 404 from /api/engagement/user/[userId] (user does not exist) is rendered as a user-friendly "User not found" page with a back link — NOT an error message that distinguishes 404 from other states. The page does not differentiate "this id is malformed" vs "this id was deleted" vs "this id never existed". |
| T-08-14 | Spoofing (page reachable without auth) | profile page route | high | mitigate | The page lives at /mobile/engagement/[userId]/page.tsx. The middleware.ts whitelists /api/mobile/* (NOT /mobile/*), so the existing middleware redirects unauthenticated browsers to /auth/sign-in?callbackUrl=... BEFORE the page renders. Verified by reading middleware.ts lines 6–43 (publicRoutes) — /mobile is NOT in the list, only /api/mobile. No new auth surface needed. |
| T-08-15 | Repudiation | profile page | low | accept | Read-only page; no mutations. No audit log needed. |
| T-08-16 | Denial of Service (large recentEntries arrays) | profile page render | low | mitigate | The endpoint returns up to 500 recent entries server-side (LIMIT 500). Phase 8 hard-bounds rendering with entries.slice(0, 10) in both Recent components. Memory cost ~10 collapsible nodes — bounded constant. |
| T-08-17 | Photo endpoint cache key cross-tenant leakage | photo <img> rendering |
low | accept | Cache-Control: private, max-age=3600 (set in Plan 01) prevents shared cache pollution. On a kiosk/shared device, the next user could see the previous user's cached photo if they navigate to the same userId — but that scenario already exposes the page content itself, so the photo is not an additional leak. Documented as accepted. |
Block-on-high check: T-08-14 (spoofing the page) is the only high severity threat
and is mitigated by the existing middleware.ts redirect (no new code needed in this
plan; verified by reading middleware.ts which gates everything not in publicRoutes).
No unmitigated highs remain.
</threat_model>
Wave-2 complete when:
- All 6 new component files exist under
components/mobile/Engagement* app/mobile/engagement/[userId]/page.tsxexists, imports all 6 components, and renders them in the order: Header → MetricGrid → Breakdown → RecentEntries → RecentMeetings (with Skeleton during load)- All 7 files (page + 6 components) start with
'use client' - Period chip changes refetch via
useEffectdependency onperiod - Retry button increments
retryNoncewhich is in the fetch effect's deps array (issue-7 fix) - Recent items hard-bounded to 10 each (
slice(0, 10)); period changes do NOT clear expanded state (they DO refetch — but the Sets persist because they're on different components from the data that drives metrics) - 404 from data endpoint renders inline "User not found" + Back to Engagement link (D-24)
- 500 / network failure renders sonner
toast.error+ Retry button (D-24) - Photo
<img>hasonErrorhandler that swaps to initials (D-25) - No modifications to
components/mobile/EngagementUserRow.tsx(D-01) - No modifications to
app/api/engagement/user/[userId]/route.ts(D-22) - No
dangerouslySetInnerHTMLintroduced npx tsc --noEmit --prettyexits 0npm run buildexits 0- Task 3 checkpoint: human confirms scroll restoration works on back gesture (or reports BROKEN so planner can add a sessionStorage shim before ship)
<success_criteria> After this plan:
- (SC#1) Tapping any row in
/mobile/engagement(Phase 7'sEngagementUserRow'sLink href="/mobile/engagement/{graphUserId}") navigates to/mobile/engagement/{graphUserId}and renders the new profile page. - (SC#2) The profile is a real Next.js page (not a modal). Pressing the device
back gesture / browser back button returns to the overview at the prior scroll
position. No
sessionStorageshim is added — App Router defaultscrollRestoration: trueis sufficient (D-04). Verified by Task 3 checkpoint. If the checkpoint reports BROKEN, the planner spawns a follow-up plan to add the sessionStorage workaround before shipping. - (SC#3) The profile renders single-column in this exact order:
identity header → period selector (sticky) → 2×2 metric grid → activity
breakdown card (3 subsections) → recent time entries → recent meetings.
All data sourced from the existing
/api/engagement/user/[userId]?period={D7|D30|D90}endpoint plus the photo proxy (Plan 01) — no new data endpoints. - ENG-06: route is
/mobile/engagement/[userId](segment form, shareable URL); single-column layout matches the prescribed order - ENG-07: real page, not a modal — replaces desktop user-detail modal pattern on mobile so back gesture works
- ENG-08: profile reuses existing engagement profile data endpoints; no new data </success_criteria>