docs(07): create phase 7 engagement overview plans

This commit is contained in:
lorentz 2026-05-03 22:38:21 -04:00
parent 71b20612b2
commit d4841a7eb5
4 changed files with 2048 additions and 2 deletions

View file

@ -130,7 +130,10 @@ Decimal phases appear between their surrounding integers in numeric order.
3. Summary cards (active users, total Graph hours, total Autotask hours, hours-per-active-user) render single-column stacked — no 4-up grid on phone widths
4. The per-employee list renders as stacked rows (avatar/initials, name, role, hours bar) with a search input and a sort control above (sort by hours, name, utilization)
5. A single compact "hours trend" sparkline renders at the top of the list, scoped to the selected period — no multi-series chart
**Plans**: TBD
**Plans**: 3 plans
- [ ] 07-01-PLAN.md — /api/mobile/engagement/summary + /api/mobile/engagement/trend endpoints with period whitelist + requireAuth (ENG-03, ENG-05)
- [ ] 07-02-PLAN.md — Engagement* mobile components (PeriodChips, SummaryCard, HoursSparkline, SortChips, SearchInput, UserRow, UserRowSkeleton + getInitials utility) (ENG-02, ENG-03, ENG-04, ENG-05)
- [ ] 07-03-PLAN.md — app/mobile/engagement/page.tsx orchestration (period/sort state, IntersectionObserver, empty/error/not-configured states) (ENG-01, ENG-02, ENG-03, ENG-04, ENG-05, ENG-09)
**UI hint**: yes
### Phase 8: Engagement User Profile (NEW)
@ -157,7 +160,7 @@ Phases execute in numeric order. Phase 2 unblocks Phases 37 (any order, paral
| 4. Tickets Restyle | 0/3 | Not started | - |
| 5. Finance Restyle | 2/2 | Complete | 2026-05-03 |
| 6. Analyzer Feed | 0/3 | Not started | - |
| 7. Engagement Overview | 0/TBD | Not started | - |
| 7. Engagement Overview | 0/3 | Not started | - |
| 8. Engagement User Profile | 0/TBD | Not started | - |
---

View file

@ -0,0 +1,558 @@
---
phase: 07-engagement-overview-new
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- app/api/mobile/engagement/summary/route.ts
- app/api/mobile/engagement/trend/route.ts
autonomous: true
requirements:
- ENG-03
- ENG-05
must_haves:
truths:
- "GET /api/mobile/engagement/summary?period=D30 returns 200 with { configured, activeUsers, totalGraphHours, totalAutotaskHours, hoursPerActiveUser } when authed"
- "GET /api/mobile/engagement/trend?period=D30 returns 200 with { points: [{ date, hours }] } where points.length === 30"
- "Both endpoints reject unauth requests via requireAuth() (401/redirect)"
- "Both endpoints reject period values outside ['D7','D30','D90'] with 400"
- "Endpoints export TypeScript interfaces (MobileEngagementSummary, EngagementTrendResponse, SparklinePoint) consumable via `import type`"
- "When MSGRAPH not configured, summary returns configured: false with zeroed totals (does not throw)"
artifacts:
- path: "app/api/mobile/engagement/summary/route.ts"
provides: "Mobile engagement summary endpoint (4 totals + configured flag)"
exports: ["GET", "MobileEngagementSummary"]
- path: "app/api/mobile/engagement/trend/route.ts"
provides: "Mobile engagement daily-hours trend endpoint"
exports: ["GET", "SparklinePoint", "EngagementTrendResponse"]
key_links:
- from: "app/api/mobile/engagement/summary/route.ts"
to: "lib/auth-utils.ts"
via: "requireAuth() at handler entry"
pattern: "requireAuth\\("
- from: "app/api/mobile/engagement/summary/route.ts"
to: "lib/services/msgraph-factory.ts"
via: "isMsgraphConfigured()"
pattern: "isMsgraphConfigured\\("
- from: "app/api/mobile/engagement/summary/route.ts"
to: "engagement_snapshots / time_entries"
via: "postgresClient.query parameterized SQL"
pattern: "postgresClient\\.query"
- from: "app/api/mobile/engagement/trend/route.ts"
to: "time_entries"
via: "daily aggregate join with graph_users + resources"
pattern: "time_entries"
---
<objective>
Build the two new mobile engagement API endpoints that the page (Plan 03) consumes. The
existing `/api/engagement/summary` returns averages, not the totals ENG-03 specifies, so
a thin mobile endpoint is required (D-09). No existing trend endpoint exists, so the
sparkline (ENG-05) needs `/api/mobile/engagement/trend` (D-14). Reuse the existing
`/api/engagement/users` endpoint as-is for the per-employee list (D-16) — no work here.
Purpose: Deliver the two read-only data endpoints with `requireAuth()`, period whitelist
validation, parameterized SQL, manual snake_case → camelCase transform, and exported
TypeScript interfaces (mirrors Phase 4/6 API style). Both endpoints follow CLAUDE.md
rules: no Zod (D-38), no ORM, NextResponse.json envelopes.
Output:
- `app/api/mobile/engagement/summary/route.ts` — GET handler, exports `MobileEngagementSummary`
- `app/api/mobile/engagement/trend/route.ts` — GET handler, exports `SparklinePoint` and `EngagementTrendResponse`
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
@app/api/engagement/summary/route.ts
@app/api/engagement/users/route.ts
@app/api/mobile/analyzer/feed/route.ts
@app/api/mobile/tickets/route.ts
@migrations/041_create_engagement_tables.sql
@migrations/042_add_engagement_calendar_columns.sql
@lib/auth-utils.ts
@lib/services/msgraph-factory.ts
<interfaces>
<!-- Reference SQL filter from existing /api/engagement/summary/route.ts (DO NOT MODIFY) -->
<!-- The "not-automated" filter excludes pure-outbound service accounts. Reuse verbatim. -->
```ts
// In existing app/api/engagement/summary/route.ts (lines 42-49) — copy this filter into the new endpoint:
const notAutomatedFilter = `NOT (
es.user_email IS NOT NULL
AND COALESCE(es.emails_received, 0) = 0
AND COALESCE(es.teams_chat_messages, 0) = 0
AND COALESCE(es.teams_meetings_attended, 0) = 0
AND COALESCE(es.teams_calls, 0) = 0
)`;
// Period to interval map (used by both endpoints, matches existing endpoints):
const intervalMap: Record<string, string> = {
D7: '7 days',
D30: '30 days',
D90: '90 days',
};
```
```ts
// requireAuth signature (lib/auth-utils.ts):
export async function requireAuth(): Promise<{ session: Session; error: null } | { session: null; error: NextResponse }>;
// isMsgraphConfigured (lib/services/msgraph-factory.ts):
export function isMsgraphConfigured(): boolean;
// postgresClient (lib/services/postgres-client.ts):
postgresClient.query(sql: string, params?: unknown[]): Promise<{ rows: any[] }>;
```
```ts
// Interfaces this plan MUST export (consumed by 07-03 page):
export interface MobileEngagementSummary {
configured: boolean;
activeUsers: number;
totalGraphHours: number; // 1 decimal, derived from audio + meeting seconds
totalAutotaskHours: number; // 1 decimal, sum of time_entries.hours_worked
hoursPerActiveUser: number; // totalAutotaskHours / activeUsers (0 if activeUsers === 0)
}
export interface SparklinePoint {
date: string; // "YYYY-MM-DD"
hours: number; // total Autotask hours for that day across all matched users (0 if no entries)
}
export interface EngagementTrendResponse {
points: SparklinePoint[]; // D7→7, D30→30, D90→90 points
}
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="false">
<name>Task 1: Create /api/mobile/engagement/summary endpoint</name>
<read_first>
- app/api/engagement/summary/route.ts (existing desktop summary — pattern reference for SQL filters; DO NOT modify, per D-34)
- app/api/mobile/analyzer/feed/route.ts (mobile API style: requireAuth, exported interfaces, NextResponse.json)
- .planning/phases/07-engagement-overview-new/07-CONTEXT.md (D-08, D-09, D-32, D-33, D-38)
- .planning/phases/07-engagement-overview-new/07-UI-SPEC.md (API Shape Contract section, MobileEngagementSummary)
- lib/auth-utils.ts (requireAuth signature)
- lib/services/msgraph-factory.ts (isMsgraphConfigured)
- migrations/041_create_engagement_tables.sql + migrations/042_add_engagement_calendar_columns.sql (column names: audio_duration_seconds, meeting_duration_seconds, period_type)
</read_first>
<files>app/api/mobile/engagement/summary/route.ts</files>
<behavior>
- Test 1: GET ?period=D30 (authed) returns 200 + JSON with keys { configured, activeUsers, totalGraphHours, totalAutotaskHours, hoursPerActiveUser }
- Test 2: Default period (no param) === D30
- Test 3: GET ?period=D7 / D90 also returns 200 with the same shape
- Test 4: GET ?period=D1 or ?period=foo returns 400 (whitelist rejection per D-07)
- Test 5: When activeUsers === 0, hoursPerActiveUser === 0 (numeric zero, not "—" — UI renders the dash)
- Test 6: When MSGRAPH not configured (isMsgraphConfigured() === false), returns 200 with configured: false and zeroed totals (does not throw)
- Test 7: Unauth request → handled by requireAuth (returns its NextResponse)
</behavior>
<action>
Create new file `app/api/mobile/engagement/summary/route.ts`. Mirror the structure of `app/api/mobile/analyzer/feed/route.ts` (Phase 6 reference): imports, exported interface, `requireAuth()` gate, period validation, SQL via `postgresClient.query`, manual snake_case → camelCase transform, NextResponse.json envelope. NO Zod (per D-38, CLAUDE.md).
**Imports (top of file):**
```ts
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth-utils';
import postgresClient from '@/lib/services/postgres-client';
import { isMsgraphConfigured } from '@/lib/services/msgraph-factory';
```
**Exported interface (must match UI-SPEC API Shape Contract verbatim):**
```ts
export interface MobileEngagementSummary {
configured: boolean;
activeUsers: number;
totalGraphHours: number;
totalAutotaskHours: number;
hoursPerActiveUser: number;
}
```
**Period whitelist (D-07, D-32, security threat T-07-04):**
```ts
const ALLOWED_PERIODS = ['D7', 'D30', 'D90'] as const;
type AllowedPeriod = typeof ALLOWED_PERIODS[number];
function parsePeriod(raw: string | null): AllowedPeriod | null {
if (!raw) return 'D30'; // default per D-04
return (ALLOWED_PERIODS as readonly string[]).includes(raw) ? (raw as AllowedPeriod) : null;
}
```
**GET handler shape (per D-09, D-32, D-33):**
```ts
export async function GET(request: NextRequest): Promise<NextResponse> {
const { error: authError } = await requireAuth();
if (authError) return authError;
const { searchParams } = request.nextUrl;
const period = parsePeriod(searchParams.get('period'));
if (period === null) {
return NextResponse.json(
{ error: 'Invalid period', message: "period must be one of 'D7', 'D30', 'D90'" },
{ status: 400 },
);
}
try {
// 1. Latest snapshot date for this period (mirrors existing /api/engagement/summary)
const latestResult = await postgresClient.query(
`SELECT MAX(period_end) as latest_date FROM engagement_snapshots WHERE period_type = $1`,
[period],
);
const latestDate = latestResult.rows[0]?.latest_date;
if (!latestDate) {
return NextResponse.json({
configured: isMsgraphConfigured(),
activeUsers: 0,
totalGraphHours: 0,
totalAutotaskHours: 0,
hoursPerActiveUser: 0,
} satisfies MobileEngagementSummary);
}
const intervalMap: Record<AllowedPeriod, string> = { D7: '7 days', D30: '30 days', D90: '90 days' };
const interval = intervalMap[period];
// notAutomatedFilter — copy verbatim from app/api/engagement/summary/route.ts:42-49
const notAutomatedFilter = `NOT (
es.user_email IS NOT NULL
AND COALESCE(es.emails_received, 0) = 0
AND COALESCE(es.teams_chat_messages, 0) = 0
AND COALESCE(es.teams_meetings_attended, 0) = 0
AND COALESCE(es.teams_calls, 0) = 0
)`;
// 2. activeUsers — match the existing summary endpoint's "active this period" query
// (count of distinct users with teams meetings/messages/emails activity)
const activeResult = await postgresClient.query(
`SELECT COUNT(DISTINCT es.user_email) as count
FROM engagement_snapshots es
JOIN graph_users gu ON LOWER(gu.email) = LOWER(es.user_email)
JOIN (
SELECT DISTINCT ON (LOWER(email)) id, email
FROM resources
WHERE (is_deleted = false OR is_deleted IS NULL) AND email IS NOT NULL
ORDER BY LOWER(email), id
) r ON LOWER(r.email) = LOWER(gu.email)
WHERE es.period_type = $1 AND es.period_end = $2
AND (es.teams_meetings_attended > 0 OR es.teams_chat_messages > 0 OR es.emails_sent > 0)
AND gu.account_enabled = true
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'
AND LOWER(gu.email) NOT LIKE '%#ext#%'
AND ${notAutomatedFilter}`,
[period, latestDate],
);
const activeUsers = parseInt(activeResult.rows[0]?.count ?? '0', 10);
// 3. totalGraphHours — sum(audio_duration_seconds + meeting_duration_seconds) / 3600 for matched users
const graphHoursResult = await postgresClient.query(
`SELECT COALESCE(SUM(COALESCE(es.audio_duration_seconds, 0) + COALESCE(es.meeting_duration_seconds, 0)), 0) AS total_seconds
FROM engagement_snapshots es
JOIN graph_users gu ON LOWER(gu.email) = LOWER(es.user_email)
JOIN (
SELECT DISTINCT ON (LOWER(email)) id, email
FROM resources
WHERE (is_deleted = false OR is_deleted IS NULL) AND email IS NOT NULL
ORDER BY LOWER(email), id
) r ON LOWER(r.email) = LOWER(gu.email)
WHERE es.period_type = $1 AND es.period_end = $2
AND gu.account_enabled = true
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'
AND LOWER(gu.email) NOT LIKE '%#ext#%'
AND ${notAutomatedFilter}`,
[period, latestDate],
);
const totalGraphSeconds = parseFloat(graphHoursResult.rows[0]?.total_seconds ?? '0');
const totalGraphHours = Math.round((totalGraphSeconds / 3600) * 10) / 10;
// 4. totalAutotaskHours — SUM(time_entries.hours_worked) for matched human resources in interval
// NOTE: ${interval} is interpolated NOT parameterized. Safe because period is whitelisted above.
const atHoursResult = await postgresClient.query(
`SELECT COALESCE(SUM(te.hours_worked), 0) AS total_hours
FROM time_entries te
JOIN resources r ON r.id = te.resource_id AND (r.is_deleted = false OR r.is_deleted IS NULL)
JOIN graph_users gu ON LOWER(gu.email) = LOWER(r.email)
WHERE te.entry_date >= NOW() - INTERVAL '${interval}'
AND (te.is_deleted = false OR te.is_deleted IS NULL)
AND gu.account_enabled = true
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'
AND LOWER(gu.email) NOT LIKE '%#ext#%'`,
);
const totalAutotaskHours = Math.round(parseFloat(atHoursResult.rows[0]?.total_hours ?? '0') * 10) / 10;
// 5. hoursPerActiveUser — totalAutotaskHours / activeUsers (0 if activeUsers === 0; UI renders "—")
const hoursPerActiveUser = activeUsers === 0
? 0
: Math.round((totalAutotaskHours / activeUsers) * 10) / 10;
return NextResponse.json({
configured: isMsgraphConfigured(),
activeUsers,
totalGraphHours,
totalAutotaskHours,
hoursPerActiveUser,
} satisfies MobileEngagementSummary);
} catch (error) {
console.error('GET /api/mobile/engagement/summary failed:', error);
return NextResponse.json(
{ error: 'Failed to fetch engagement summary', message: error instanceof Error ? error.message : 'unknown' },
{ status: 500 },
);
}
}
```
**Per D-09:** This endpoint exists because the existing `/api/engagement/summary` returns averages (avgHoursWorked, avgTeamsMeetings, etc.), not the four totals ENG-03 specifies. We reuse the SAME SQL filters/joins (notAutomatedFilter, account_enabled + email scoping, resources DISTINCT ON dedupe) for consistency.
**Per D-32:** `requireAuth()` gates every request. **Do not call any DB code before this gate.**
**Per D-33:** Document inherited risk for the existing `/api/engagement/users` endpoint (which lacks `requireAuth()`) in the threat model — but DO NOT modify the desktop endpoint (out of scope per D-34 and PROJECT.md "Restyling or replacing the desktop pages…").
**Per D-36:** Read-only consumption — no edits to `lib/services/msgraph-*` or `lib/services/engagement-sync-service.ts`.
**Per D-38 / CLAUDE.md:** No Zod. The whitelist function above is sufficient input validation.
</action>
<verify>
<automated>npx tsc --noEmit --pretty 2>&1 | tee /tmp/p07-01-task1-tsc.log; grep -E "app/api/mobile/engagement/summary" /tmp/p07-01-task1-tsc.log && exit 1; exit 0</automated>
</verify>
<acceptance_criteria>
- File exists: `test -f app/api/mobile/engagement/summary/route.ts`
- Has `requireAuth` import: `grep -q "from '@/lib/auth-utils'" app/api/mobile/engagement/summary/route.ts`
- Calls `requireAuth()` BEFORE any `postgresClient.query`: `awk '/postgresClient\.query|requireAuth\(\)/' app/api/mobile/engagement/summary/route.ts | head -1 | grep -q 'requireAuth()'`
- Exports the interface: `grep -q "export interface MobileEngagementSummary" app/api/mobile/engagement/summary/route.ts`
- All 5 fields present in interface: `grep -E "configured|activeUsers|totalGraphHours|totalAutotaskHours|hoursPerActiveUser" app/api/mobile/engagement/summary/route.ts | wc -l` ≥ 5
- Period whitelist present: `grep -E "D7.*D30.*D90|'D7'|'D30'|'D90'" app/api/mobile/engagement/summary/route.ts` matches AND a 400 response path exists: `grep -q "status: 400" app/api/mobile/engagement/summary/route.ts`
- Calls `isMsgraphConfigured()`: `grep -q "isMsgraphConfigured()" app/api/mobile/engagement/summary/route.ts`
- NO Zod (D-38): `! grep -q "from 'zod'" app/api/mobile/engagement/summary/route.ts`
- NO ORM (CLAUDE.md): `! grep -q "prisma\|drizzle" app/api/mobile/engagement/summary/route.ts`
- SQL params parameterized (period passed as $1, not interpolated): `grep -E '\$1.*\$2' app/api/mobile/engagement/summary/route.ts`
- `npx tsc --noEmit --pretty` exits 0 (no type errors)
</acceptance_criteria>
<verify>
<automated>npx tsc --noEmit --pretty</automated>
</verify>
<done>
Endpoint file created. `npx tsc --noEmit --pretty` exits 0. Hitting GET /api/mobile/engagement/summary?period=D30 (when authed) returns the 5-field MobileEngagementSummary JSON. Invalid period returns 400.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 2: Create /api/mobile/engagement/trend endpoint</name>
<read_first>
- app/api/mobile/engagement/summary/route.ts (sibling — created in Task 1; reuse the same period whitelist + notAutomatedFilter pattern)
- app/api/engagement/users/route.ts (existing endpoint — pattern reference for time_entries + resources + graph_users join; DO NOT modify)
- .planning/phases/07-engagement-overview-new/07-CONTEXT.md (D-11, D-12, D-14, D-32, D-38)
- .planning/phases/07-engagement-overview-new/07-UI-SPEC.md (Sparkline section: D7→7 points, D30→30 points, D90→90 points; missing days = 0)
- migrations/041_create_engagement_tables.sql (graph_users + engagement_snapshots schema)
</read_first>
<files>app/api/mobile/engagement/trend/route.ts</files>
<behavior>
- Test 1: GET ?period=D7 (authed) returns 200 + { points: [...] } where points.length === 7
- Test 2: GET ?period=D30 (authed) returns 200 with points.length === 30
- Test 3: GET ?period=D90 (authed) returns 200 with points.length === 90
- Test 4: GET ?period=foo returns 400 (whitelist rejection per D-07; same pattern as Task 1)
- Test 5: Each point has shape { date: "YYYY-MM-DD", hours: number }; days with no time_entries return hours: 0 (NOT omitted — UI-SPEC requires continuous series)
- Test 6: Days are in ascending date order (oldest first → most recent last so the SVG renders left-to-right with most recent on the right)
- Test 7: Total payload size capped at 90 days max — no unbounded result set (T-07-03 mitigation)
- Test 8: Unauth request → handled by requireAuth
</behavior>
<action>
Create new file `app/api/mobile/engagement/trend/route.ts`. Same structural pattern as Task 1: requireAuth gate first, period whitelist validation second, parameterized SQL third, manual transform last. NO Zod (D-38).
**Imports:**
```ts
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth-utils';
import postgresClient from '@/lib/services/postgres-client';
```
**Exported interfaces (must match UI-SPEC API Shape Contract):**
```ts
export interface SparklinePoint {
date: string; // ISO date string "YYYY-MM-DD"
hours: number; // total Autotask hours for that day (0 if no entries)
}
export interface EngagementTrendResponse {
points: SparklinePoint[]; // D7 → 7, D30 → 30, D90 → 90 points
}
```
**Period whitelist (same as Task 1, copy):**
```ts
const ALLOWED_PERIODS = ['D7', 'D30', 'D90'] as const;
type AllowedPeriod = typeof ALLOWED_PERIODS[number];
const PERIOD_DAYS: Record<AllowedPeriod, number> = { D7: 7, D30: 30, D90: 90 };
function parsePeriod(raw: string | null): AllowedPeriod | null {
if (!raw) return 'D30';
return (ALLOWED_PERIODS as readonly string[]).includes(raw) ? (raw as AllowedPeriod) : null;
}
```
**GET handler — daily aggregate query:**
The query aggregates `time_entries.hours_worked` per `entry_date` for human resources matching `graph_users` in the period. Unlike Task 1's totals, this produces one row per day so the sparkline can render a continuous line.
```ts
export async function GET(request: NextRequest): Promise<NextResponse> {
const { error: authError } = await requireAuth();
if (authError) return authError;
const { searchParams } = request.nextUrl;
const period = parsePeriod(searchParams.get('period'));
if (period === null) {
return NextResponse.json(
{ error: 'Invalid period', message: "period must be one of 'D7', 'D30', 'D90'" },
{ status: 400 },
);
}
const days = PERIOD_DAYS[period];
// T-07-03 mitigation: bounded by whitelisted period (max 90 days). No user-supplied row cap.
try {
// generate_series produces one row per day so days with zero hours are still represented (D-15: continuous line, no gaps).
// ${days} is interpolated NOT parameterized — safe because period is whitelisted to 7/30/90.
const sql = `
WITH day_series AS (
SELECT generate_series(
(CURRENT_DATE - INTERVAL '${days - 1} days')::date,
CURRENT_DATE,
INTERVAL '1 day'
)::date AS day
),
daily_hours AS (
SELECT te.entry_date::date AS day, COALESCE(SUM(te.hours_worked), 0) AS hours
FROM time_entries te
JOIN resources r ON r.id = te.resource_id AND (r.is_deleted = false OR r.is_deleted IS NULL)
JOIN graph_users gu ON LOWER(gu.email) = LOWER(r.email)
WHERE te.entry_date >= CURRENT_DATE - INTERVAL '${days - 1} days'
AND te.entry_date <= CURRENT_DATE
AND (te.is_deleted = false OR te.is_deleted IS NULL)
AND gu.account_enabled = true
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'
AND LOWER(gu.email) NOT LIKE '%#ext#%'
GROUP BY te.entry_date::date
)
SELECT to_char(ds.day, 'YYYY-MM-DD') AS date,
COALESCE(dh.hours, 0)::numeric AS hours
FROM day_series ds
LEFT JOIN daily_hours dh ON dh.day = ds.day
ORDER BY ds.day ASC
`;
const result = await postgresClient.query(sql);
const points: SparklinePoint[] = result.rows.map(row => ({
date: String(row.date),
hours: Math.round(parseFloat(row.hours ?? '0') * 10) / 10,
}));
return NextResponse.json({ points } satisfies EngagementTrendResponse);
} catch (error) {
console.error('GET /api/mobile/engagement/trend failed:', error);
return NextResponse.json(
{ error: 'Failed to fetch engagement trend', message: error instanceof Error ? error.message : 'unknown' },
{ status: 500 },
);
}
}
```
**Per D-12 / D-15:** Continuous series — `generate_series` ensures every day in the period has a row even when zero hours were logged (no gaps). The UI sparkline draws zero-hour days down to baseline, never breaks the line.
**Per D-32:** `requireAuth()` is the first call — DB queries only run after auth.
**Per D-38:** No Zod. Whitelist sufficient.
**Per D-34, D-36:** No edits to existing engagement endpoints, services, or migrations.
**Per security threat T-07-03 (rate-limiting/DoS):** Period whitelist caps the date range to ≤90 days; the query has bounded results (one row per day, max 90). No user-supplied row-count parameter.
</action>
<verify>
<automated>npx tsc --noEmit --pretty 2>&1 | tee /tmp/p07-01-task2-tsc.log; grep -E "app/api/mobile/engagement/trend" /tmp/p07-01-task2-tsc.log && exit 1; exit 0</automated>
</verify>
<acceptance_criteria>
- File exists: `test -f app/api/mobile/engagement/trend/route.ts`
- Has `requireAuth` import + call BEFORE DB query: `awk '/postgresClient\.query|requireAuth\(\)/' app/api/mobile/engagement/trend/route.ts | head -1 | grep -q 'requireAuth()'`
- Exports both interfaces: `grep -q "export interface SparklinePoint" app/api/mobile/engagement/trend/route.ts && grep -q "export interface EngagementTrendResponse" app/api/mobile/engagement/trend/route.ts`
- Period whitelist present + 400 path: `grep -q "'D7', 'D30', 'D90'" app/api/mobile/engagement/trend/route.ts && grep -q "status: 400" app/api/mobile/engagement/trend/route.ts`
- Uses `generate_series` to ensure continuous days (D-15): `grep -q "generate_series" app/api/mobile/engagement/trend/route.ts`
- SQL is bounded by whitelisted period (no user-supplied days param): `! grep -E "searchParams.get\('days'\)|searchParams.get\(\"days\"\)" app/api/mobile/engagement/trend/route.ts`
- NO Zod: `! grep -q "from 'zod'" app/api/mobile/engagement/trend/route.ts`
- `npx tsc --noEmit --pretty` exits 0 (no type errors)
</acceptance_criteria>
<verify>
<automated>npx tsc --noEmit --pretty</automated>
</verify>
<done>
Endpoint file created. `npx tsc --noEmit --pretty` exits 0. GET /api/mobile/engagement/trend?period=D30 (authed) returns { points: SparklinePoint[] } with exactly 30 points in ascending date order. Invalid period returns 400.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → /api/mobile/engagement/* | Authenticated mobile clients send GET requests with cookie-based session; period query param is untrusted input |
| Mobile API → Postgres | Endpoints query engagement_snapshots, graph_users, resources, time_entries; SQL parameters supplied by handler (period whitelisted) |
| Mobile API → MSGraph factory | Read-only `isMsgraphConfigured()` boolean check; no credential exposure |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-07-01 | Spoofing/AuthN | summary + trend GET handlers | mitigate | Both handlers call `requireAuth()` from `lib/auth-utils.ts` as the FIRST statement; if `authError` is non-null, return immediately. Acceptance criteria check enforces ordering via awk grep. |
| T-07-02 | Tampering / Injection | period query parameter | mitigate | Whitelist `['D7','D30','D90']` via `parsePeriod()`. Invalid values return 400 BEFORE any SQL executes. The whitelisted period is then used both as a bound parameter (`$1`) and to look up a static `INTERVAL '7 days' / '30 days' / '90 days'` constant — interpolation is on a fixed map value, not user input. |
| T-07-03 | DoS / Resource exhaustion | trend endpoint daily aggregation | mitigate | Whitelist caps the date range to max 90 days. `generate_series` produces at most 90 rows. No user-supplied row-count or page-size parameter. SQL aggregation runs against indexed columns (entry_date, resource_id per existing engagement endpoints — no new index required). |
| T-07-04 | Information Disclosure | summary/trend response payloads | mitigate | Both endpoints return ONLY scalars (totals, hours-per-day) — no per-user PII (no display_name, no email, no jobTitle). Sensitive metadata (teams_chat_messages, emails_sent, etc.) is summed, never enumerated. |
| T-07-05 | Information Disclosure (inherited) | existing `/api/engagement/users` (NOT modified by this plan) | accept | Existing desktop endpoint lacks `requireAuth()` (CONTEXT.md D-33). Reuse from Plan 03 does not increase exposure since middleware.ts blocks unauth access to `/api/*` not in the public list (verify on first run). Out of scope per D-34 + PROJECT.md ("Restyling or replacing the desktop pages…"). Document follow-up; recommend a future security phase. Mirrors Phase 6 IDOR T-06P03-02 disposition pattern. |
| T-07-06 | Repudiation | Both new endpoints | accept | Read-only GET endpoints surfacing aggregate metrics. No state mutation, no audit trail required. Standard `console.error` logging on exception paths. |
</threat_model>
<verification>
- Both endpoint files exist and pass `npx tsc --noEmit --pretty`
- `grep -q "export interface" app/api/mobile/engagement/summary/route.ts && grep -q "export interface" app/api/mobile/engagement/trend/route.ts` (interfaces exported for Plan 03 to import)
- `requireAuth()` is called BEFORE any `postgresClient.query` in both files (auth gate ordering)
- Period whitelist + 400 path present in both files
- Manual run-time check: `curl -s -i http://localhost:3100/api/mobile/engagement/summary?period=D30 | head -3` returns 401 or redirect when not authed; returns 200 with 5-field JSON when authed
- Manual run-time check: `curl -s 'http://localhost:3100/api/mobile/engagement/trend?period=foo' | head -1` returns 400 (or middleware redirect; behavior depends on session)
</verification>
<success_criteria>
- Both files written, both export the documented interfaces
- `npx tsc --noEmit --pretty` exits 0
- Period whitelist enforced (D7/D30/D90 only) on both endpoints
- `requireAuth()` is the first statement in each handler
- No Zod, no ORM, no edits outside the two new files
- Plan 03 can `import type { MobileEngagementSummary } from '@/app/api/mobile/engagement/summary/route'` and `import type { SparklinePoint, EngagementTrendResponse } from '@/app/api/mobile/engagement/trend/route'` without errors
</success_criteria>
<output>
After completion, create `.planning/phases/07-engagement-overview-new/07-01-SUMMARY.md` documenting:
- Endpoint paths + exported interfaces
- Period whitelist values
- Any deviations from UI-SPEC API Shape Contract (none expected)
- Confirmed inherited risk for `/api/engagement/users` (per D-33; out-of-scope to fix)
</output>

View file

@ -0,0 +1,782 @@
---
phase: 07-engagement-overview-new
plan: 02
type: execute
wave: 2
depends_on:
- 07-01
files_modified:
- 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
autonomous: true
requirements:
- ENG-02
- ENG-03
- ENG-04
- ENG-05
must_haves:
truths:
- "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"
artifacts:
- path: "components/mobile/EngagementPeriodChips.tsx"
provides: "3-chip period selector"
exports: ["EngagementPeriodChips", "EngagementPeriodChipsProps"]
- path: "components/mobile/EngagementSummaryCard.tsx"
provides: "Single summary card primitive"
exports: ["EngagementSummaryCard"]
- path: "components/mobile/EngagementHoursSparkline.tsx"
provides: "Custom SVG sparkline"
exports: ["EngagementHoursSparkline"]
- path: "components/mobile/EngagementSortChips.tsx"
provides: "3-chip sort selector"
exports: ["EngagementSortChips", "EngagementSortKey"]
- path: "components/mobile/EngagementSearchInput.tsx"
provides: "Debounced search input"
exports: ["EngagementSearchInput"]
- path: "components/mobile/EngagementUserRow.tsx"
provides: "User row card (avatar + name + hours + bar) wrapped in Link"
exports: ["EngagementUserRow", "getInitials", "EngagementUserRowProps"]
- path: "components/mobile/EngagementUserRowSkeleton.tsx"
provides: "Skeleton matching EngagementUserRow shape"
exports: ["EngagementUserRowSkeleton"]
key_links:
- from: "components/mobile/EngagementUserRow.tsx"
to: "/mobile/engagement/[graphUserId]"
via: "next/link href={\\`/mobile/engagement/${graphUserId}\\`}"
pattern: "/mobile/engagement/\\$\\{"
- from: "components/mobile/EngagementHoursSparkline.tsx"
to: "SparklinePoint type"
via: "import type from '@/app/api/mobile/engagement/trend/route'"
pattern: "from '@/app/api/mobile/engagement/trend/route'"
- from: "components/mobile/EngagementSummaryCard.tsx"
to: "shadcn Card"
via: "@/components/ui/card"
pattern: "from '@/components/ui/card'"
---
<objective>
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`)
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
<interfaces>
<!-- Types this plan consumes from Plan 01 (already created in Wave 1): -->
```ts
// 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;
}
```
```ts
// 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;
}
```
<!-- Component file convention header (Phase 3/4/5/6 pattern): -->
```ts
'use client';
/* ComponentName — phase 07 (ENG-NN).
* Purpose: one-line description.
* Props: ... */
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="false">
<name>Task 1: Build EngagementPeriodChips + EngagementSortChips + EngagementSearchInput (chip + input primitives)</name>
<read_first>
- .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)
</read_first>
<files>components/mobile/EngagementPeriodChips.tsx, components/mobile/EngagementSortChips.tsx, components/mobile/EngagementSearchInput.tsx</files>
<action>
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.
</action>
<verify>
<automated>npx tsc --noEmit --pretty</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<verify>
<automated>npx tsc --noEmit --pretty</automated>
</verify>
<done>
Three files exist with the exact class strings from UI-SPEC. Type-check passes. Components ready for import in Plan 03.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 2: Build EngagementSummaryCard + EngagementHoursSparkline (display primitives)</name>
<read_first>
- .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)
</read_first>
<files>components/mobile/EngagementSummaryCard.tsx, components/mobile/EngagementHoursSparkline.tsx</files>
<action>
Create two `'use client'` display components. The sparkline uses an inline `<svg>` (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.
</action>
<verify>
<automated>npx tsc --noEmit --pretty</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<verify>
<automated>npx tsc --noEmit --pretty</automated>
</verify>
<done>
Both files exist with verbatim UI-SPEC class strings + copy. Sparkline uses inline SVG only (no chart library). Type-check passes.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 3: Build EngagementUserRow + EngagementUserRowSkeleton (with exported getInitials utility)</name>
<read_first>
- .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)
</read_first>
<files>components/mobile/EngagementUserRow.tsx, components/mobile/EngagementUserRowSkeleton.tsx</files>
<action>
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).
</action>
<verify>
<automated>npx tsc --noEmit --pretty</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<verify>
<automated>npx tsc --noEmit --pretty</automated>
</verify>
<done>
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.
</done>
</task>
</tasks>
<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>
<verification>
- 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
</verification>
<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>
<output>
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)
</output>

View file

@ -0,0 +1,703 @@
---
phase: 07-engagement-overview-new
plan: 03
type: execute
wave: 3
depends_on:
- 07-01
- 07-02
files_modified:
- app/mobile/engagement/page.tsx
autonomous: true
requirements:
- ENG-01
- ENG-02
- ENG-03
- ENG-04
- ENG-05
- ENG-09
must_haves:
truths:
- "Tapping the Engagement entry in the More drawer routes to /mobile/engagement and the page renders inside the Phase 2 mobile shell"
- "Page shows H1 'Engagement' (text-sm font-semibold) above a sticky 3-chip period selector (7d / 30d / 90d, default 30d active)"
- "Page renders 4 summary cards stacked single-column: Active users, Total Graph hours, Total Autotask hours, Hours / active user"
- "Page renders one compact hours-trend sparkline card scoped to the selected period at the top of the user list"
- "Page renders sort chips (Hours / Name / Utilization) and a debounced search input above the per-employee list"
- "Per-employee list shows stacked rows (avatar+name+role+hours+hours-bar); tapping a row navigates to /mobile/engagement/[graphUserId]"
- "Changing the period chip refetches summary, trend, and users (page resets to 1)"
- "Changing the sort chip refetches the users list (page resets to 1)"
- "Search input debounces 300ms and filters the loaded user set client-side; 'No matches' inline state with Clear search button when the filter zeros out"
- "Infinite scroll: IntersectionObserver on a sentinel triggers ?page=N+1 when last row enters viewport; 'Load more' fallback button is also present"
- "Initial load shows 4 summary card skeletons + 1 sparkline skeleton + 5 user-row skeletons"
- "Empty state ('No engagement data for this period') renders when summary.activeUsers === 0 AND users.length === 0; period chips remain interactive"
- "When summary.configured === false, a 'Engagement sync not configured' banner replaces the data sections"
- "Fetch errors fire toast.error per failure; Load more button label flips to 'Retry'"
- "BottomNav.tsx is NOT modified (Engagement is reachable from More drawer only — ENG-09)"
- "MoreDrawer.tsx is NOT modified (already routes to /mobile/engagement per Phase 2 DRAWER-03)"
artifacts:
- path: "app/mobile/engagement/page.tsx"
provides: "Mobile engagement overview page (real refactor, not desktop adaptation)"
exports: ["default function MobileEngagementPage"]
min_lines: 200
key_links:
- from: "app/mobile/engagement/page.tsx"
to: "/api/mobile/engagement/summary"
via: "fetch in useEffect"
pattern: "/api/mobile/engagement/summary"
- from: "app/mobile/engagement/page.tsx"
to: "/api/mobile/engagement/trend"
via: "fetch in useEffect"
pattern: "/api/mobile/engagement/trend"
- from: "app/mobile/engagement/page.tsx"
to: "/api/engagement/users"
via: "fetch with period+sort+page params (D-16, D-17)"
pattern: "/api/engagement/users"
- from: "app/mobile/engagement/page.tsx"
to: "components/mobile/Engagement* components"
via: "named imports from @/components/mobile/Engagement*"
pattern: "from '@/components/mobile/Engagement"
- from: "app/mobile/engagement/page.tsx"
to: "MobileEngagementSummary type"
via: "import type from '@/app/api/mobile/engagement/summary/route'"
pattern: "import type.*MobileEngagementSummary"
---
<objective>
Build the mobile Engagement overview page that orchestrates Plan 01's endpoints + Plan 02's
components into the shipping screen. This is the page the manager actually uses: H1 +
sticky period chips + 4 stacked summary cards + 1 compact sparkline + sort chips + search +
the per-employee list with infinite scroll + empty/configured banners + error toasts.
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
# Plan 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
<interfaces>
<!-- Imports the page makes (executors should use these directly): -->
```ts
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';
```
<!-- /api/engagement/users response shape (existing endpoint — DO NOT modify; inline the consumed shape per UI-SPEC API Shape Contract): -->
```ts
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
};
}
```
<!-- Sort key → API param mapping (D-20): -->
```ts
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' },
};
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="false">
<name>Task 1: Create app/mobile/engagement/page.tsx (orchestrates Plan 01 endpoints + Plan 02 components)</name>
<read_first>
- .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)
</read_first>
<files>app/mobile/engagement/page.tsx</files>
<action>
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.
</action>
<verify>
<automated>npx tsc --noEmit --pretty</automated>
</verify>
<acceptance_criteria>
- 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 '<h1 className="text-sm font-semibold">Engagement</h1>' 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<EngagementPeriod>\('D30'\)" app/mobile/engagement/page.tsx`
- Default sort is Hours (D-20): `grep -E "useState<EngagementSortKey>\('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
</acceptance_criteria>
<verify>
<automated>npx tsc --noEmit --pretty</automated>
</verify>
<done>
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.
</done>
</task>
</tasks>
<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 || !hasMore) return`). Most teams have ≤50 staff so users will see one page total. |
</threat_model>
<verification>
- `app/mobile/engagement/page.tsx` exists, `npx tsc --noEmit --pretty` exits 0
- File is `'use client';`
- Visiting `/mobile/engagement` (logged in) renders the full page in the Phase 2 shell
- Period chip change refetches summary + trend + users (verify in browser network panel)
- Sort chip change refetches users only (no summary/trend hits)
- Search input is debounced 300ms and applies a client-side filter (verify by typing fast and watching no fetches fire)
- Empty state: simulate by setting period=D7 with no data — both summary.activeUsers === 0 AND users === [] → empty card renders, period chips remain interactive
- Not-configured: simulate by unsetting MSGRAPH env vars — banner replaces data sections
- Error: kill the trend endpoint (e.g., 500 response) → toast.error fires + Load more flips to "Retry" if a users error
- Linking: tapping a user row navigates to `/mobile/engagement/[graphUserId]` (Phase 8 destination — page may 404 in this phase, that's expected and validates the link wiring)
- Verify Phase 2 reachability: open More drawer, tap "Engagement" → lands on `/mobile/engagement` (DRAWER-03 confirmation)
- Verify ENG-09: Engagement is NOT in the bottom nav (`! grep -F "Engagement" components/mobile/BottomNav.tsx`)
</verification>
<success_criteria>
- One new file: `app/mobile/engagement/page.tsx` (no other files modified)
- `npx tsc --noEmit --pretty` exits 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/users` reused 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>
<output>
After completion, create `.planning/phases/07-engagement-overview-new/07-03-SUMMARY.md` documenting:
- The single file created (`app/mobile/engagement/page.tsx`)
- The orchestration model (3 fetches on mount/period change, 1 fetch on sort change, 0 fetches on search)
- Confirmed reachability via More drawer (DRAWER-03)
- Confirmed Engagement is NOT in BottomNav (ENG-09)
- Inherited risk T-07-12 (IDOR on /api/engagement/users) flagged for STATE.md follow-up
- File length and any deviations from UI-SPEC (none expected)
</output>