diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index c2ac4af..200f958 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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 3–7 (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 | - | --- diff --git a/.planning/phases/07-engagement-overview-new/07-01-PLAN.md b/.planning/phases/07-engagement-overview-new/07-01-PLAN.md new file mode 100644 index 0000000..a006389 --- /dev/null +++ b/.planning/phases/07-engagement-overview-new/07-01-PLAN.md @@ -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" +--- + + +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` + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + + +```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 = { + 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 +} +``` + + + + + + + Task 1: Create /api/mobile/engagement/summary endpoint + + - 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) + + app/api/mobile/engagement/summary/route.ts + + - 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) + + + 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 { + 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 = { 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. + + + 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 + + + - 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) + + + npx tsc --noEmit --pretty + + + 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. + + + + + Task 2: Create /api/mobile/engagement/trend endpoint + + - 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) + + app/api/mobile/engagement/trend/route.ts + + - 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 + + + 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 = { 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 { + 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. + + + 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 + + + - 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) + + + npx tsc --noEmit --pretty + + + 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. + + + + + + +## 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. | + + + +- 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) + + + +- 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 + + + +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) + diff --git a/.planning/phases/07-engagement-overview-new/07-02-PLAN.md b/.planning/phases/07-engagement-overview-new/07-02-PLAN.md new file mode 100644 index 0000000..bb6f3b4 --- /dev/null +++ b/.planning/phases/07-engagement-overview-new/07-02-PLAN.md @@ -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'" +--- + + +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`) + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + +```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; +} +``` + + + +```ts +'use client'; + +/* ComponentName — phase 07 (ENG-NN). + * Purpose: one-line description. + * Props: ... */ +``` + + + + + + + Task 1: Build EngagementPeriodChips + EngagementSortChips + EngagementSearchInput (chip + input primitives) + + - .planning/phases/07-engagement-overview-new/07-UI-SPEC.md (Period Chips, Sort Chips, Search Input sections — typography, color tokens, copy strings, accessibility) + - .planning/phases/07-engagement-overview-new/07-CONTEXT.md (D-04, D-05, D-06, D-20, D-21, D-28, D-29) + - components/mobile/AnalyzerFeedRow.tsx (component file structure + 'use client' + comment header convention) + - components/ui/input.tsx (shadcn Input — confirm props accept className for pl-9) + + components/mobile/EngagementPeriodChips.tsx, components/mobile/EngagementSortChips.tsx, components/mobile/EngagementSearchInput.tsx + + Create three small `'use client'` components, all pure presentational. Use the EXACT class strings from UI-SPEC. No fetches, no toasts. + + --- + + **File 1: `components/mobile/EngagementPeriodChips.tsx`** (per D-04, D-05, D-06) + + ```tsx + 'use client'; + + /* EngagementPeriodChips — phase 07 (ENG-02). + * Purpose: 3-chip period selector (7d/30d/90d) sticky below the page H1. + * Maps 1:1 to data-layer period_type values D7/D30/D90 (D-04). + * Props: period, onPeriodChange. Pure presentational — page owns refetch logic. */ + + export type EngagementPeriod = 'D7' | 'D30' | 'D90'; + + export interface EngagementPeriodChipsProps { + period: EngagementPeriod; + onPeriodChange: (next: EngagementPeriod) => void; + } + + const CHIPS: ReadonlyArray<{ value: EngagementPeriod; label: string }> = [ + { value: 'D7', label: '7d' }, + { value: 'D30', label: '30d' }, + { value: 'D90', label: '90d' }, + ]; + + export function EngagementPeriodChips({ period, onPeriodChange }: EngagementPeriodChipsProps) { + return ( +
+ {CHIPS.map(chip => { + const isActive = chip.value === period; + return ( + + ); + })} +
+ ); + } + ``` + + **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 = ['Hours', 'Name', 'Utilization']; + + export function EngagementSortChips({ activeSort, onSortChange }: EngagementSortChipsProps) { + return ( +
+ {SORTS.map(key => { + const isActive = key === activeSort; + return ( + + ); + })} +
+ ); + } + ``` + + **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(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 ( +
+
+ ); + } + ``` + + **Per D-21:** Server-side search NOT used — the parent applies the filter client-side. This component just emits debounced changes. +
+ + npx tsc --noEmit --pretty + + + - All 3 files exist + - PeriodChips: container has all of `sticky top-0 z-10 bg-background pt-2 pb-3 -mx-4 px-4 flex gap-2`: `grep -E "sticky top-0 z-10 bg-background pt-2 pb-3 -mx-4 px-4" components/mobile/EngagementPeriodChips.tsx` + - PeriodChips: 3 chip values D7/D30/D90 with labels 7d/30d/90d: `grep -E "'D7'.*'7d'|D7.*7d" components/mobile/EngagementPeriodChips.tsx` + - PeriodChips: active and inactive class strings exact (per UI-SPEC + deep_work_rules adjusted to `text-[10px]`): `grep -F "bg-primary text-primary-foreground rounded-full px-3 py-1.5 text-[10px] font-semibold" components/mobile/EngagementPeriodChips.tsx && grep -F "bg-muted text-foreground hover:bg-muted/80 rounded-full px-3 py-1.5 text-[10px] font-semibold" components/mobile/EngagementPeriodChips.tsx` + - PeriodChips: `aria-pressed={isActive}` present on each button: `grep -q "aria-pressed={isActive}" components/mobile/EngagementPeriodChips.tsx` + - PeriodChips: exports `EngagementPeriod` type union: `grep -q "export type EngagementPeriod" components/mobile/EngagementPeriodChips.tsx` + - SortChips: 3 chips with labels Hours / Name / Utilization: `grep -E "Hours.*Name.*Utilization|'Hours'" components/mobile/EngagementSortChips.tsx` + - SortChips: same chip class strings as PeriodChips + - SortChips: exports `EngagementSortKey` type: `grep -q "export type EngagementSortKey" components/mobile/EngagementSortChips.tsx` + - SearchInput: imports `Search` from `lucide-react` and `Input` from shadcn: `grep -q "from 'lucide-react'" components/mobile/EngagementSearchInput.tsx && grep -q "from '@/components/ui/input'" components/mobile/EngagementSearchInput.tsx` + - SearchInput: placeholder copy verbatim: `grep -F 'placeholder="Search by name or email"' components/mobile/EngagementSearchInput.tsx` + - SearchInput: 300ms debounce: `grep -E "300\b" components/mobile/EngagementSearchInput.tsx` + - SearchInput: `aria-label="Search team members by name or email"` present: `grep -F 'aria-label="Search team members by name or email"' components/mobile/EngagementSearchInput.tsx` + - SearchInput: leading icon classes verbatim: `grep -F "absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" components/mobile/EngagementSearchInput.tsx` + - All 3 files have `'use client';` directive at top: `grep -L "^'use client';" components/mobile/EngagementPeriodChips.tsx components/mobile/EngagementSortChips.tsx components/mobile/EngagementSearchInput.tsx | wc -l` === 0 + - `npx tsc --noEmit --pretty` exits 0 + + + npx tsc --noEmit --pretty + + + Three files exist with the exact class strings from UI-SPEC. Type-check passes. Components ready for import in Plan 03. + +
+ + + Task 2: Build EngagementSummaryCard + EngagementHoursSparkline (display primitives) + + - .planning/phases/07-engagement-overview-new/07-UI-SPEC.md (Summary Cards, Hours Trend Sparkline sections — exact class strings, copy strings, period_label mapping) + - .planning/phases/07-engagement-overview-new/07-CONTEXT.md (D-08, D-09, D-10, D-11, D-12, D-13, D-14, D-15, D-29) + - app/api/mobile/engagement/trend/route.ts (Plan 01 — for SparklinePoint import) + - components/ui/card.tsx (shadcn Card primitive) + - components/mobile/AnalyzerFeedRow.tsx (file structure + comment header pattern) + + components/mobile/EngagementSummaryCard.tsx, components/mobile/EngagementHoursSparkline.tsx + + Create two `'use client'` display components. The sparkline uses an inline `` (no recharts — DASH-04 + D-12). + + --- + + **File 1: `components/mobile/EngagementSummaryCard.tsx`** (per D-08, D-10, D-29) + + ```tsx + 'use client'; + + /* EngagementSummaryCard — phase 07 (ENG-03). + * Purpose: Single summary card with big number + label, stacked single-column. + * Used 4× on the page: Active users, Total Graph hours, Total Autotask hours, Hours / active user (D-08). + * Card has no shadow, only border (matches FinanceRow density per D-10). + * Props: value (display string), label (display string). */ + + import { Card, CardContent } from '@/components/ui/card'; + + export interface EngagementSummaryCardProps { + value: string; // pre-formatted: "42", "128.5h", "—" + label: string; // "Active users", "Total Graph hours", etc. + } + + export function EngagementSummaryCard({ value, label }: EngagementSummaryCardProps) { + return ( + + +

{value}

+

{label}

+ + + ); + } + ``` + + **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 = { + 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 ( + + +
+

{`Hours trend · last ${periodLabel}`}

+

{formatLatestValue(points)}

+
+ + {allZero ? ( +

No activity

+ ) : ( + + )} +
+
+ ); + } + ``` + + **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 `

No activity

` and skip the SVG entirely. +
+ + npx tsc --noEmit --pretty + + + - Both files exist + - SummaryCard: imports `Card, CardContent` from `@/components/ui/card`: `grep -q "from '@/components/ui/card'" components/mobile/EngagementSummaryCard.tsx` + - SummaryCard: big number class verbatim: `grep -F "text-2xl font-semibold text-foreground leading-none" components/mobile/EngagementSummaryCard.tsx` + - SummaryCard: label class verbatim: `grep -F "text-xs text-muted-foreground" components/mobile/EngagementSummaryCard.tsx` + - SummaryCard: shadow-none on Card (D-10): `grep -F "shadow-none" components/mobile/EngagementSummaryCard.tsx` + - Sparkline: imports SparklinePoint type from Plan 01: `grep -F "from '@/app/api/mobile/engagement/trend/route'" components/mobile/EngagementHoursSparkline.tsx` + - Sparkline: period_label mapping has all 3 periods with strings "7 days" / "30 days" / "90 days": `grep -E "'7 days'|'30 days'|'90 days'" components/mobile/EngagementHoursSparkline.tsx | wc -l` ≥ 3 + - Sparkline: copy "Hours trend · last": `grep -F "Hours trend · last" components/mobile/EngagementHoursSparkline.tsx` + - Sparkline: no-data copy "No activity": `grep -F "No activity" components/mobile/EngagementHoursSparkline.tsx` + - Sparkline: SVG element with `viewBox`: `grep -E "viewBox=" components/mobile/EngagementHoursSparkline.tsx` + - Sparkline: stroke-primary class: `grep -F "stroke-primary" components/mobile/EngagementHoursSparkline.tsx` + - Sparkline: stroke-2 width: `grep -E 'strokeWidth="2"' components/mobile/EngagementHoursSparkline.tsx` + - Sparkline: 48px tall (`h-12`): `grep -F "h-12 w-full" components/mobile/EngagementHoursSparkline.tsx` + - Sparkline: linear path commands `M` then `L`: `grep -E "'M'|'L'" components/mobile/EngagementHoursSparkline.tsx` + - Sparkline: NO recharts import (DASH-04): `! grep -q "from 'recharts'" components/mobile/EngagementHoursSparkline.tsx` + - `npx tsc --noEmit --pretty` exits 0 + + + npx tsc --noEmit --pretty + + + Both files exist with verbatim UI-SPEC class strings + copy. Sparkline uses inline SVG only (no chart library). Type-check passes. + +
+ + + Task 3: Build EngagementUserRow + EngagementUserRowSkeleton (with exported getInitials utility) + + - .planning/phases/07-engagement-overview-new/07-UI-SPEC.md (User Row, User Row Skeleton sections — full markup with hours bar, exact class strings; "Note on EngagementUserRow extraction" for getInitials utility) + - .planning/phases/07-engagement-overview-new/07-CONTEXT.md (D-19, D-23, D-29; "Claude's Discretion" notes about avatar initials algorithm and Phase 8 reuse) + - components/mobile/AnalyzerFeedRow.tsx (Link wrapper pattern, file header convention) + - components/mobile/AnalyzerRowSkeleton.tsx (skeleton pattern reference) + - components/ui/skeleton.tsx (Skeleton primitive) + + components/mobile/EngagementUserRow.tsx, components/mobile/EngagementUserRowSkeleton.tsx + + Create two files. `EngagementUserRow.tsx` is the single most-load-bearing component on the page — it owns the visual contract for the per-employee list. Mirror the exact markup from UI-SPEC §"User Row". + + --- + + **File 1: `components/mobile/EngagementUserRow.tsx`** (per D-19; getInitials per UI-SPEC §"Note on EngagementUserRow extraction") + + ```tsx + 'use client'; + + /* EngagementUserRow — phase 07 (ENG-04). + * Purpose: Per-employee row card — avatar (initials) + name + role + hours + hours bar. + * Entire row is a Link to /mobile/engagement/[graphUserId] (D-19; Phase 8 owns destination). + * Hours bar width = (billableHours / maxHours) * 100% — bounded at 100%. + * Props: user (EngagementUser shape), maxHours (largest billableHours in current page set; computed by parent). */ + + import Link from 'next/link'; + + export interface EngagementUserRowData { + graphUserId: string; + displayName: string; + userEmail: string; + jobTitle: string | null; + billableHours: number; + hoursWorked: number; + } + + export interface EngagementUserRowProps { + user: EngagementUserRowData; + maxHours: number; // largest billableHours in the loaded set (parent computes) + } + + /** + * getInitials — first letter of first word + first letter of last word of displayName, + * uppercased. E.g. "Jordan Walsh" → "JW", "Alex" → "A". Exported for Phase 8 reuse + * (the user profile header may share the avatar identity block per UI-SPEC). + */ + export function getInitials(displayName: string): string { + const parts = displayName.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return '?'; + if (parts.length === 1) return parts[0]![0]!.toUpperCase(); + const first = parts[0]![0] ?? ''; + const last = parts[parts.length - 1]![0] ?? ''; + return (first + last).toUpperCase(); + } + + export function EngagementUserRow({ user, maxHours }: EngagementUserRowProps) { + const initials = getInitials(user.displayName); + const hoursLabel = `${user.billableHours.toFixed(1)}h`; + const barWidthPct = maxHours > 0 + ? Math.min(100, (user.billableHours / maxHours) * 100) + : 0; + + return ( + + {/* Top line: avatar + identity + hours value */} +
+ +
+ + {user.displayName} + + + {hoursLabel} + +
+
+ + {/* Role line — render only if jobTitle present (UI-SPEC: "render nothing (no empty line) if absent") */} + {user.jobTitle && ( +

+ {user.jobTitle} +

+ )} + + {/* Hours bar */} +
+ + + ); + } + ``` + + **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:** `` — 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 ( +
+
+ +
+ + +
+
+ + +
+ ); + } + ``` + + **Notes:** Mirrors the row shape exactly — avatar circle (32×32), name placeholder, hours placeholder, role placeholder indented past avatar (`ml-11`), hours bar placeholder. UI-SPEC mandates 5 skeleton instances on initial load (rendered by the page). + + + npx tsc --noEmit --pretty + + + - Both files exist + - UserRow: imports `Link` from `next/link`: `grep -q "from 'next/link'" components/mobile/EngagementUserRow.tsx` + - UserRow: Link href targets /mobile/engagement/[graphUserId]: `grep -E "/mobile/engagement/\\\$\\{user\\.graphUserId\\}" components/mobile/EngagementUserRow.tsx` + - UserRow: exports `getInitials` function: `grep -q "export function getInitials" components/mobile/EngagementUserRow.tsx` + - UserRow: getInitials handles empty string + single-word + multi-word (test by inspection — function references parts.length === 0, parts.length === 1, parts.length > 1) + - UserRow: avatar classes verbatim: `grep -F "h-8 w-8 rounded-full bg-muted flex items-center justify-center shrink-0 text-[10px] font-semibold text-foreground" components/mobile/EngagementUserRow.tsx` + - UserRow: row container classes verbatim: `grep -F "block px-4 py-3 hover:bg-muted/50 transition-colors active:bg-muted/50" components/mobile/EngagementUserRow.tsx` + - UserRow: hours bar track verbatim: `grep -F "h-1.5 rounded-full bg-muted overflow-hidden" components/mobile/EngagementUserRow.tsx` + - UserRow: hours bar fill: `grep -F "h-full rounded-full bg-primary transition-all duration-300" components/mobile/EngagementUserRow.tsx` + - UserRow: name uses `text-sm font-semibold truncate flex-1` (D-19): `grep -F "text-sm font-semibold truncate flex-1" components/mobile/EngagementUserRow.tsx` + - UserRow: role line conditional + `text-xs text-muted-foreground truncate`: `grep -F "text-xs text-muted-foreground truncate" components/mobile/EngagementUserRow.tsx` + - UserRow: hours value formatted with `.toFixed(1)`: `grep -E "\.toFixed\(1\)" components/mobile/EngagementUserRow.tsx` + - UserRow: bar width bounded at 100%: `grep -E "Math\.min\(100" components/mobile/EngagementUserRow.tsx` + - UserRow: avatar has `aria-hidden="true"`: `grep -E 'aria-hidden="true"' components/mobile/EngagementUserRow.tsx` + - Skeleton: imports `Skeleton` from shadcn: `grep -q "from '@/components/ui/skeleton'" components/mobile/EngagementUserRowSkeleton.tsx` + - Skeleton: includes `h-8 w-8 rounded-full` (avatar) + `h-4 w-32` (name) + `h-4 w-12` (hours) + `h-3 w-24 ml-11` (role) + `h-1.5 w-full mt-2` (bar): `grep -E "h-8 w-8 rounded-full|h-4 w-32|h-4 w-12|h-3 w-24 ml-11|h-1.5 w-full" components/mobile/EngagementUserRowSkeleton.tsx | wc -l` ≥ 5 + - `npx tsc --noEmit --pretty` exits 0 + + + npx tsc --noEmit --pretty + + + Both files exist. `getInitials` is exported. Row markup verbatim from UI-SPEC. Skeleton mirrors row shape. Type-check passes. Phase 8 can import `getInitials` directly. + + + + + + +## 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). | + + + +- 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 + + + +- 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) + + + +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) + diff --git a/.planning/phases/07-engagement-overview-new/07-03-PLAN.md b/.planning/phases/07-engagement-overview-new/07-03-PLAN.md new file mode 100644 index 0000000..114c373 --- /dev/null +++ b/.planning/phases/07-engagement-overview-new/07-03-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + +```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'; +``` + + + +```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 + }; +} +``` + + + +```ts +const SORT_TO_API: Record = { + Hours: { sort: 'billable_hours', order: 'desc' }, + Name: { sort: 'display_name', order: 'asc' }, + Utilization: { sort: 'billable_hours', order: 'desc' }, +}; +``` + + + + + + + Task 1: Create app/mobile/engagement/page.tsx (orchestrates Plan 01 endpoints + Plan 02 components) + + - .planning/phases/07-engagement-overview-new/07-UI-SPEC.md (full file — Page Layout Order section is the spec; copy strings, accessibility, error toast labels) + - .planning/phases/07-engagement-overview-new/07-CONTEXT.md (D-01, D-03, D-16, D-17, D-18, D-21, D-22, D-23, D-24, D-25, D-26, D-27, D-30, D-31, D-37) + - app/mobile/analyzer/page.tsx (closest pattern reference — IntersectionObserver loop, error/Retry, skeleton render block, useCallback fetch, toast.error) + - app/mobile/tickets/page.tsx lines 1-100 (filter + URL pattern reference; URL syncing NOT used in this phase per D-21 note) + - app/api/engagement/users/route.ts (existing route — confirm response shape `users[]` + `pagination.{page,pageSize,total,totalPages}`) + - components/mobile/BottomNav.tsx (verify no Engagement entry — must NOT be modified per D-02 / ENG-09) + - components/mobile/MoreDrawer.tsx (verify Engagement entry exists — DO NOT modify per D-01) + + app/mobile/engagement/page.tsx + + Create new file `app/mobile/engagement/page.tsx`. Use the EXACT structure below — every section maps to a UI-SPEC layout block. NO router pushes for state (per D-21 note: period/sort/search are component state only, scale doesn't warrant deep-linking). NO SWR / react-query (D-37 / CLAUDE.md). NO Zod (D-38). + + **File header:** + ```tsx + 'use client'; + + /* MobileEngagementPage — phase 07 (ENG-01..05, ENG-09). + * Purpose: Phone-first refactor of /engagement — period chips + 4 stacked summary cards + + * compact sparkline + sortable/searchable per-employee list with infinite scroll. + * Real refactor, not a thin adaptation of the ~1300-line desktop page (ENG-01). + * Reachable from More drawer only — Engagement is NOT on the bottom nav (ENG-09). + * Per D-01..D-31. */ + + import { useEffect, useState, useCallback, useRef, useMemo } from 'react'; + import { Loader2, Users } from 'lucide-react'; + import { toast } from 'sonner'; + + import type { MobileEngagementSummary } from '@/app/api/mobile/engagement/summary/route'; + import type { SparklinePoint, EngagementTrendResponse } from '@/app/api/mobile/engagement/trend/route'; + import { EngagementPeriodChips, type EngagementPeriod } from '@/components/mobile/EngagementPeriodChips'; + import { EngagementSummaryCard } from '@/components/mobile/EngagementSummaryCard'; + import { EngagementHoursSparkline } from '@/components/mobile/EngagementHoursSparkline'; + import { EngagementSortChips, type EngagementSortKey } from '@/components/mobile/EngagementSortChips'; + import { EngagementSearchInput } from '@/components/mobile/EngagementSearchInput'; + import { EngagementUserRow } from '@/components/mobile/EngagementUserRow'; + import { EngagementUserRowSkeleton } from '@/components/mobile/EngagementUserRowSkeleton'; + import { Card, CardContent } from '@/components/ui/card'; + import { Skeleton } from '@/components/ui/skeleton'; + ``` + + **Inline types (existing /api/engagement/users response — D-16):** + + ```tsx + interface EngagementUserApiRow { + graphUserId: string; + displayName: string; + email: string; + jobTitle: string | null; + billableHours: number; + hoursWorked: number; + } + + interface EngagementUsersResponse { + users: EngagementUserApiRow[]; + pagination: { + page: number; + pageSize: number; + total: number; + totalPages?: number; + }; + } + + // Sort key → API param mapping (D-20) + const SORT_TO_API: Record = { + 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('D30'); // D-04 default + const [sortKey, setSortKey] = useState('Hours'); // D-20 default + const [searchQuery, setSearchQuery] = useState(''); // D-21 + + const [summary, setSummary] = useState(null); + const [summaryLoading, setSummaryLoading] = useState(true); + + const [trendPoints, setTrendPoints] = useState([]); + const [trendLoading, setTrendLoading] = useState(true); + + const [users, setUsers] = useState([]); + const [usersLoading, setUsersLoading] = useState(true); + const [currentPage, setCurrentPage] = useState(1); + const [hasMore, setHasMore] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [loadMoreError, setLoadMoreError] = useState(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(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 ( +
{/* D-30 */} + {/* H1 — D-31, scrolls away under the sticky chips */} +

Engagement

{/* D-29 */} + + {/* Sticky period chips — D-05 */} + + + {showNotConfiguredBanner ? ( + // Not-configured banner (D-27) — replaces all data sections +
+

Engagement sync not configured

+

+ Set MSGRAPH_* environment variables and restart. + + Open Admin + +

+
+ ) : ( + <> + {/* Section 3 — Summary cards (D-08, D-23) */} + {summaryLoading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + + + ))} +
+ ) : ( +
+ + + + +
+ )} + + {/* Section 4 — Sparkline (D-11..D-15, D-23) */} + {trendLoading ? ( + + +
+ + +
+ +
+
+ ) : ( + + )} + + {/* Section 5 — Sort + search (D-20, D-21) */} +
+ + +
+ + {/* Section 6 — User list (D-19, D-22, D-23, D-26) */} + {showEmptyState ? ( + // Empty state (D-26) +
+
+ ) : usersLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => )} +
+ ) : filteredUsers.length === 0 && searchQuery.trim() !== '' ? ( + // No-matches inline state (D-22) — preserves the list border + rounding +
+
+

{`No matches for "${searchQuery}"`}

+ +
+
+ ) : ( + <> +
+ {filteredUsers.map(u => ( + + ))} +
+ + {/* Sentinel — D-18 */} + + ); + } + ``` + + **Critical orchestration notes:** + + - **Period change** (D-04): triggers `loadSummary + loadTrend + loadUsersPage1` via the `useEffect([period])`. + - **Sort change** (D-20): triggers `loadUsersPage1` only — summary/trend are period-scoped. + - **Search change** (D-21): NEVER triggers a fetch — filter is purely client-side via `useMemo`. + - **Initial mount**: the period effect runs with default `D30`, kicking off all three fetches simultaneously (no waterfall). + - **No URL syncing** (per D-21 note in CONTEXT.md): period/sort/search live in component state. Mirrors the lower data scale relative to Tickets (Phase 4 deep-links because filters are diverse + URL-shareable; Engagement state is simpler). + - **No router push, no useSearchParams**: Phase 4 uses URL-synced filters, Phase 7 does NOT. + + **Per ENG-09 / D-02:** This page does NOT modify `BottomNav.tsx`. Engagement entry stays in More drawer (Phase 2 already wired DRAWER-03). + + **Per D-01 / D-35:** This page does NOT modify `MoreDrawer.tsx` or any desktop `app/engagement/*` files. + + **Per D-30 / UI-SPEC:** Page container is `px-4 py-4 space-y-4`. The H1 is `text-sm font-semibold` (D-29 — keeps font-size count at 4: `text-sm`, `text-xs`, `text-[10px]`, `text-2xl`). + + **Per D-37 / D-38:** No SWR, no react-query, no Zod. + + + npx tsc --noEmit --pretty + + + - File exists: `test -f app/mobile/engagement/page.tsx` + - Has `'use client';` at top: `head -1 app/mobile/engagement/page.tsx | grep -q "'use client';"` + - Imports all 7 Plan 02 components: `grep -E "EngagementPeriodChips|EngagementSummaryCard|EngagementHoursSparkline|EngagementSortChips|EngagementSearchInput|EngagementUserRow|EngagementUserRowSkeleton" app/mobile/engagement/page.tsx | wc -l` ≥ 7 + - Imports types from Plan 01 endpoints: `grep -F "from '@/app/api/mobile/engagement/summary/route'" app/mobile/engagement/page.tsx && grep -F "from '@/app/api/mobile/engagement/trend/route'" app/mobile/engagement/page.tsx` + - Default export: `grep -E "export default function MobileEngagementPage" app/mobile/engagement/page.tsx` + - Page H1 with copy "Engagement" + class `text-sm font-semibold`: `grep -F '

Engagement

' app/mobile/engagement/page.tsx` + - Page container classes verbatim (D-30): `grep -F 'className="px-4 py-4 space-y-4"' app/mobile/engagement/page.tsx` + - All 4 summary card labels present (D-08): `grep -F "Active users" app/mobile/engagement/page.tsx && grep -F "Total Graph hours" app/mobile/engagement/page.tsx && grep -F "Total Autotask hours" app/mobile/engagement/page.tsx && grep -F "Hours / active user" app/mobile/engagement/page.tsx` + - All 4 toast.error labels (D-25): `grep -F "Failed to load engagement summary" app/mobile/engagement/page.tsx && grep -F "Failed to load hours trend" app/mobile/engagement/page.tsx && grep -F "Failed to load engagement users" app/mobile/engagement/page.tsx && grep -F "Failed to load more team members" app/mobile/engagement/page.tsx` + - Empty state copy (D-26): `grep -F "No engagement data for this period" app/mobile/engagement/page.tsx` + - Not-configured copy (D-27): `grep -F "Engagement sync not configured" app/mobile/engagement/page.tsx` + - No-matches copy (D-22): `grep -F "No matches for" app/mobile/engagement/page.tsx && grep -F "Clear search" app/mobile/engagement/page.tsx` + - Default period is D30 (D-04): `grep -E "useState\('D30'\)" app/mobile/engagement/page.tsx` + - Default sort is Hours (D-20): `grep -E "useState\('Hours'\)" app/mobile/engagement/page.tsx` + - SORT_TO_API mapping present and includes display_name asc + billable_hours desc: `grep -E "display_name.*asc|billable_hours.*desc" app/mobile/engagement/page.tsx | wc -l` ≥ 2 + - Fetches all 3 endpoints: `grep -F "/api/mobile/engagement/summary?period=" app/mobile/engagement/page.tsx && grep -F "/api/mobile/engagement/trend?period=" app/mobile/engagement/page.tsx && grep -F "/api/engagement/users?" app/mobile/engagement/page.tsx` + - IntersectionObserver with rootMargin '200px' (D-18): `grep -F "rootMargin: '200px'" app/mobile/engagement/page.tsx` + - Load more button + classes (D-18): `grep -F 'className="w-full py-3 rounded-xl border text-sm font-semibold hover:bg-muted/50 transition-colors disabled:opacity-50 min-h-[44px]"' app/mobile/engagement/page.tsx` + - Load more aria-label "Load more team members": `grep -F 'aria-label="Load more team members"' app/mobile/engagement/page.tsx` + - Retry / Loading… / Load more labels: `grep -E "'Retry'|'Loading…'|'Load more'" app/mobile/engagement/page.tsx | wc -l` ≥ 3 + - User list container has `divide-y border rounded-xl overflow-hidden`: `grep -F 'divide-y border rounded-xl overflow-hidden' app/mobile/engagement/page.tsx` + - Card 4 zero-users renders "—" (D-08): `grep -E "activeUsers === 0.*'—'" app/mobile/engagement/page.tsx` + - Hours formatted with .toFixed(1): `grep -E "toFixed\(1\)" app/mobile/engagement/page.tsx | wc -l` ≥ 3 + - Search filter is client-side useMemo (D-21): `grep -E "useMemo|filteredUsers" app/mobile/engagement/page.tsx` + - NO Zod, NO SWR, NO react-query (D-37, D-38): `! grep -E "from 'zod'|from 'swr'|from '@tanstack/react-query'" app/mobile/engagement/page.tsx` + - NO router push for state (D-21 note): `! grep -E "router\.push.*setPeriod|router\.push.*sort" app/mobile/engagement/page.tsx` + - NO useSearchParams (period/sort/search are component state only): `! grep -E "useSearchParams|useRouter" app/mobile/engagement/page.tsx` + - BottomNav.tsx is NOT modified (Engagement is NOT a tab — ENG-09): `! grep -F "Engagement" components/mobile/BottomNav.tsx` (confirms current state preserved) + - MoreDrawer.tsx still routes to /mobile/engagement (D-01): `grep -F "/mobile/engagement" components/mobile/MoreDrawer.tsx` + - File length ≥ 200 lines (orchestration + render block is substantive): `wc -l < app/mobile/engagement/page.tsx | awk '{ if ($1 < 200) exit 1; else exit 0 }'` + - `npx tsc --noEmit --pretty` exits 0 +
+ + npx tsc --noEmit --pretty + + + Page file written. Type-check passes. Manually visiting `/mobile/engagement` while logged in shows H1 + sticky chips + 4 stacked summary cards + sparkline + sort/search + user rows. Period chip change refetches all three datasets. Sort chip change refetches users. Search debounces 300ms and filters in place. Empty/error/not-configured states render per UI-SPEC. BottomNav and MoreDrawer remain unchanged. No type errors anywhere in the project. + + + + + + +## 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. | + + + +- `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`) + + + +- 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 + + + +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) +