docs(07): create phase 7 engagement overview plans
This commit is contained in:
parent
71b20612b2
commit
d4841a7eb5
4 changed files with 2048 additions and 2 deletions
|
|
@ -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 | - |
|
||||
|
||||
---
|
||||
|
|
|
|||
558
.planning/phases/07-engagement-overview-new/07-01-PLAN.md
Normal file
558
.planning/phases/07-engagement-overview-new/07-01-PLAN.md
Normal file
|
|
@ -0,0 +1,558 @@
|
|||
---
|
||||
phase: 07-engagement-overview-new
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- app/api/mobile/engagement/summary/route.ts
|
||||
- app/api/mobile/engagement/trend/route.ts
|
||||
autonomous: true
|
||||
requirements:
|
||||
- ENG-03
|
||||
- ENG-05
|
||||
must_haves:
|
||||
truths:
|
||||
- "GET /api/mobile/engagement/summary?period=D30 returns 200 with { configured, activeUsers, totalGraphHours, totalAutotaskHours, hoursPerActiveUser } when authed"
|
||||
- "GET /api/mobile/engagement/trend?period=D30 returns 200 with { points: [{ date, hours }] } where points.length === 30"
|
||||
- "Both endpoints reject unauth requests via requireAuth() (401/redirect)"
|
||||
- "Both endpoints reject period values outside ['D7','D30','D90'] with 400"
|
||||
- "Endpoints export TypeScript interfaces (MobileEngagementSummary, EngagementTrendResponse, SparklinePoint) consumable via `import type`"
|
||||
- "When MSGRAPH not configured, summary returns configured: false with zeroed totals (does not throw)"
|
||||
artifacts:
|
||||
- path: "app/api/mobile/engagement/summary/route.ts"
|
||||
provides: "Mobile engagement summary endpoint (4 totals + configured flag)"
|
||||
exports: ["GET", "MobileEngagementSummary"]
|
||||
- path: "app/api/mobile/engagement/trend/route.ts"
|
||||
provides: "Mobile engagement daily-hours trend endpoint"
|
||||
exports: ["GET", "SparklinePoint", "EngagementTrendResponse"]
|
||||
key_links:
|
||||
- from: "app/api/mobile/engagement/summary/route.ts"
|
||||
to: "lib/auth-utils.ts"
|
||||
via: "requireAuth() at handler entry"
|
||||
pattern: "requireAuth\\("
|
||||
- from: "app/api/mobile/engagement/summary/route.ts"
|
||||
to: "lib/services/msgraph-factory.ts"
|
||||
via: "isMsgraphConfigured()"
|
||||
pattern: "isMsgraphConfigured\\("
|
||||
- from: "app/api/mobile/engagement/summary/route.ts"
|
||||
to: "engagement_snapshots / time_entries"
|
||||
via: "postgresClient.query parameterized SQL"
|
||||
pattern: "postgresClient\\.query"
|
||||
- from: "app/api/mobile/engagement/trend/route.ts"
|
||||
to: "time_entries"
|
||||
via: "daily aggregate join with graph_users + resources"
|
||||
pattern: "time_entries"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the two new mobile engagement API endpoints that the page (Plan 03) consumes. The
|
||||
existing `/api/engagement/summary` returns averages, not the totals ENG-03 specifies, so
|
||||
a thin mobile endpoint is required (D-09). No existing trend endpoint exists, so the
|
||||
sparkline (ENG-05) needs `/api/mobile/engagement/trend` (D-14). Reuse the existing
|
||||
`/api/engagement/users` endpoint as-is for the per-employee list (D-16) — no work here.
|
||||
|
||||
Purpose: Deliver the two read-only data endpoints with `requireAuth()`, period whitelist
|
||||
validation, parameterized SQL, manual snake_case → camelCase transform, and exported
|
||||
TypeScript interfaces (mirrors Phase 4/6 API style). Both endpoints follow CLAUDE.md
|
||||
rules: no Zod (D-38), no ORM, NextResponse.json envelopes.
|
||||
|
||||
Output:
|
||||
- `app/api/mobile/engagement/summary/route.ts` — GET handler, exports `MobileEngagementSummary`
|
||||
- `app/api/mobile/engagement/trend/route.ts` — GET handler, exports `SparklinePoint` and `EngagementTrendResponse`
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/REQUIREMENTS.md
|
||||
@.planning/phases/07-engagement-overview-new/07-CONTEXT.md
|
||||
@.planning/phases/07-engagement-overview-new/07-UI-SPEC.md
|
||||
@CLAUDE.md
|
||||
@app/api/engagement/summary/route.ts
|
||||
@app/api/engagement/users/route.ts
|
||||
@app/api/mobile/analyzer/feed/route.ts
|
||||
@app/api/mobile/tickets/route.ts
|
||||
@migrations/041_create_engagement_tables.sql
|
||||
@migrations/042_add_engagement_calendar_columns.sql
|
||||
@lib/auth-utils.ts
|
||||
@lib/services/msgraph-factory.ts
|
||||
|
||||
<interfaces>
|
||||
<!-- Reference SQL filter from existing /api/engagement/summary/route.ts (DO NOT MODIFY) -->
|
||||
<!-- The "not-automated" filter excludes pure-outbound service accounts. Reuse verbatim. -->
|
||||
|
||||
```ts
|
||||
// In existing app/api/engagement/summary/route.ts (lines 42-49) — copy this filter into the new endpoint:
|
||||
const notAutomatedFilter = `NOT (
|
||||
es.user_email IS NOT NULL
|
||||
AND COALESCE(es.emails_received, 0) = 0
|
||||
AND COALESCE(es.teams_chat_messages, 0) = 0
|
||||
AND COALESCE(es.teams_meetings_attended, 0) = 0
|
||||
AND COALESCE(es.teams_calls, 0) = 0
|
||||
)`;
|
||||
|
||||
// Period to interval map (used by both endpoints, matches existing endpoints):
|
||||
const intervalMap: Record<string, string> = {
|
||||
D7: '7 days',
|
||||
D30: '30 days',
|
||||
D90: '90 days',
|
||||
};
|
||||
```
|
||||
|
||||
```ts
|
||||
// requireAuth signature (lib/auth-utils.ts):
|
||||
export async function requireAuth(): Promise<{ session: Session; error: null } | { session: null; error: NextResponse }>;
|
||||
|
||||
// isMsgraphConfigured (lib/services/msgraph-factory.ts):
|
||||
export function isMsgraphConfigured(): boolean;
|
||||
|
||||
// postgresClient (lib/services/postgres-client.ts):
|
||||
postgresClient.query(sql: string, params?: unknown[]): Promise<{ rows: any[] }>;
|
||||
```
|
||||
|
||||
```ts
|
||||
// Interfaces this plan MUST export (consumed by 07-03 page):
|
||||
|
||||
export interface MobileEngagementSummary {
|
||||
configured: boolean;
|
||||
activeUsers: number;
|
||||
totalGraphHours: number; // 1 decimal, derived from audio + meeting seconds
|
||||
totalAutotaskHours: number; // 1 decimal, sum of time_entries.hours_worked
|
||||
hoursPerActiveUser: number; // totalAutotaskHours / activeUsers (0 if activeUsers === 0)
|
||||
}
|
||||
|
||||
export interface SparklinePoint {
|
||||
date: string; // "YYYY-MM-DD"
|
||||
hours: number; // total Autotask hours for that day across all matched users (0 if no entries)
|
||||
}
|
||||
|
||||
export interface EngagementTrendResponse {
|
||||
points: SparklinePoint[]; // D7→7, D30→30, D90→90 points
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 1: Create /api/mobile/engagement/summary endpoint</name>
|
||||
<read_first>
|
||||
- app/api/engagement/summary/route.ts (existing desktop summary — pattern reference for SQL filters; DO NOT modify, per D-34)
|
||||
- app/api/mobile/analyzer/feed/route.ts (mobile API style: requireAuth, exported interfaces, NextResponse.json)
|
||||
- .planning/phases/07-engagement-overview-new/07-CONTEXT.md (D-08, D-09, D-32, D-33, D-38)
|
||||
- .planning/phases/07-engagement-overview-new/07-UI-SPEC.md (API Shape Contract section, MobileEngagementSummary)
|
||||
- lib/auth-utils.ts (requireAuth signature)
|
||||
- lib/services/msgraph-factory.ts (isMsgraphConfigured)
|
||||
- migrations/041_create_engagement_tables.sql + migrations/042_add_engagement_calendar_columns.sql (column names: audio_duration_seconds, meeting_duration_seconds, period_type)
|
||||
</read_first>
|
||||
<files>app/api/mobile/engagement/summary/route.ts</files>
|
||||
<behavior>
|
||||
- Test 1: GET ?period=D30 (authed) returns 200 + JSON with keys { configured, activeUsers, totalGraphHours, totalAutotaskHours, hoursPerActiveUser }
|
||||
- Test 2: Default period (no param) === D30
|
||||
- Test 3: GET ?period=D7 / D90 also returns 200 with the same shape
|
||||
- Test 4: GET ?period=D1 or ?period=foo returns 400 (whitelist rejection per D-07)
|
||||
- Test 5: When activeUsers === 0, hoursPerActiveUser === 0 (numeric zero, not "—" — UI renders the dash)
|
||||
- Test 6: When MSGRAPH not configured (isMsgraphConfigured() === false), returns 200 with configured: false and zeroed totals (does not throw)
|
||||
- Test 7: Unauth request → handled by requireAuth (returns its NextResponse)
|
||||
</behavior>
|
||||
<action>
|
||||
Create new file `app/api/mobile/engagement/summary/route.ts`. Mirror the structure of `app/api/mobile/analyzer/feed/route.ts` (Phase 6 reference): imports, exported interface, `requireAuth()` gate, period validation, SQL via `postgresClient.query`, manual snake_case → camelCase transform, NextResponse.json envelope. NO Zod (per D-38, CLAUDE.md).
|
||||
|
||||
**Imports (top of file):**
|
||||
```ts
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import { isMsgraphConfigured } from '@/lib/services/msgraph-factory';
|
||||
```
|
||||
|
||||
**Exported interface (must match UI-SPEC API Shape Contract verbatim):**
|
||||
```ts
|
||||
export interface MobileEngagementSummary {
|
||||
configured: boolean;
|
||||
activeUsers: number;
|
||||
totalGraphHours: number;
|
||||
totalAutotaskHours: number;
|
||||
hoursPerActiveUser: number;
|
||||
}
|
||||
```
|
||||
|
||||
**Period whitelist (D-07, D-32, security threat T-07-04):**
|
||||
```ts
|
||||
const ALLOWED_PERIODS = ['D7', 'D30', 'D90'] as const;
|
||||
type AllowedPeriod = typeof ALLOWED_PERIODS[number];
|
||||
|
||||
function parsePeriod(raw: string | null): AllowedPeriod | null {
|
||||
if (!raw) return 'D30'; // default per D-04
|
||||
return (ALLOWED_PERIODS as readonly string[]).includes(raw) ? (raw as AllowedPeriod) : null;
|
||||
}
|
||||
```
|
||||
|
||||
**GET handler shape (per D-09, D-32, D-33):**
|
||||
```ts
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const { error: authError } = await requireAuth();
|
||||
if (authError) return authError;
|
||||
|
||||
const { searchParams } = request.nextUrl;
|
||||
const period = parsePeriod(searchParams.get('period'));
|
||||
if (period === null) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid period', message: "period must be one of 'D7', 'D30', 'D90'" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Latest snapshot date for this period (mirrors existing /api/engagement/summary)
|
||||
const latestResult = await postgresClient.query(
|
||||
`SELECT MAX(period_end) as latest_date FROM engagement_snapshots WHERE period_type = $1`,
|
||||
[period],
|
||||
);
|
||||
const latestDate = latestResult.rows[0]?.latest_date;
|
||||
if (!latestDate) {
|
||||
return NextResponse.json({
|
||||
configured: isMsgraphConfigured(),
|
||||
activeUsers: 0,
|
||||
totalGraphHours: 0,
|
||||
totalAutotaskHours: 0,
|
||||
hoursPerActiveUser: 0,
|
||||
} satisfies MobileEngagementSummary);
|
||||
}
|
||||
|
||||
const intervalMap: Record<AllowedPeriod, string> = { D7: '7 days', D30: '30 days', D90: '90 days' };
|
||||
const interval = intervalMap[period];
|
||||
|
||||
// notAutomatedFilter — copy verbatim from app/api/engagement/summary/route.ts:42-49
|
||||
const notAutomatedFilter = `NOT (
|
||||
es.user_email IS NOT NULL
|
||||
AND COALESCE(es.emails_received, 0) = 0
|
||||
AND COALESCE(es.teams_chat_messages, 0) = 0
|
||||
AND COALESCE(es.teams_meetings_attended, 0) = 0
|
||||
AND COALESCE(es.teams_calls, 0) = 0
|
||||
)`;
|
||||
|
||||
// 2. activeUsers — match the existing summary endpoint's "active this period" query
|
||||
// (count of distinct users with teams meetings/messages/emails activity)
|
||||
const activeResult = await postgresClient.query(
|
||||
`SELECT COUNT(DISTINCT es.user_email) as count
|
||||
FROM engagement_snapshots es
|
||||
JOIN graph_users gu ON LOWER(gu.email) = LOWER(es.user_email)
|
||||
JOIN (
|
||||
SELECT DISTINCT ON (LOWER(email)) id, email
|
||||
FROM resources
|
||||
WHERE (is_deleted = false OR is_deleted IS NULL) AND email IS NOT NULL
|
||||
ORDER BY LOWER(email), id
|
||||
) r ON LOWER(r.email) = LOWER(gu.email)
|
||||
WHERE es.period_type = $1 AND es.period_end = $2
|
||||
AND (es.teams_meetings_attended > 0 OR es.teams_chat_messages > 0 OR es.emails_sent > 0)
|
||||
AND gu.account_enabled = true
|
||||
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'
|
||||
AND LOWER(gu.email) NOT LIKE '%#ext#%'
|
||||
AND ${notAutomatedFilter}`,
|
||||
[period, latestDate],
|
||||
);
|
||||
const activeUsers = parseInt(activeResult.rows[0]?.count ?? '0', 10);
|
||||
|
||||
// 3. totalGraphHours — sum(audio_duration_seconds + meeting_duration_seconds) / 3600 for matched users
|
||||
const graphHoursResult = await postgresClient.query(
|
||||
`SELECT COALESCE(SUM(COALESCE(es.audio_duration_seconds, 0) + COALESCE(es.meeting_duration_seconds, 0)), 0) AS total_seconds
|
||||
FROM engagement_snapshots es
|
||||
JOIN graph_users gu ON LOWER(gu.email) = LOWER(es.user_email)
|
||||
JOIN (
|
||||
SELECT DISTINCT ON (LOWER(email)) id, email
|
||||
FROM resources
|
||||
WHERE (is_deleted = false OR is_deleted IS NULL) AND email IS NOT NULL
|
||||
ORDER BY LOWER(email), id
|
||||
) r ON LOWER(r.email) = LOWER(gu.email)
|
||||
WHERE es.period_type = $1 AND es.period_end = $2
|
||||
AND gu.account_enabled = true
|
||||
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'
|
||||
AND LOWER(gu.email) NOT LIKE '%#ext#%'
|
||||
AND ${notAutomatedFilter}`,
|
||||
[period, latestDate],
|
||||
);
|
||||
const totalGraphSeconds = parseFloat(graphHoursResult.rows[0]?.total_seconds ?? '0');
|
||||
const totalGraphHours = Math.round((totalGraphSeconds / 3600) * 10) / 10;
|
||||
|
||||
// 4. totalAutotaskHours — SUM(time_entries.hours_worked) for matched human resources in interval
|
||||
// NOTE: ${interval} is interpolated NOT parameterized. Safe because period is whitelisted above.
|
||||
const atHoursResult = await postgresClient.query(
|
||||
`SELECT COALESCE(SUM(te.hours_worked), 0) AS total_hours
|
||||
FROM time_entries te
|
||||
JOIN resources r ON r.id = te.resource_id AND (r.is_deleted = false OR r.is_deleted IS NULL)
|
||||
JOIN graph_users gu ON LOWER(gu.email) = LOWER(r.email)
|
||||
WHERE te.entry_date >= NOW() - INTERVAL '${interval}'
|
||||
AND (te.is_deleted = false OR te.is_deleted IS NULL)
|
||||
AND gu.account_enabled = true
|
||||
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'
|
||||
AND LOWER(gu.email) NOT LIKE '%#ext#%'`,
|
||||
);
|
||||
const totalAutotaskHours = Math.round(parseFloat(atHoursResult.rows[0]?.total_hours ?? '0') * 10) / 10;
|
||||
|
||||
// 5. hoursPerActiveUser — totalAutotaskHours / activeUsers (0 if activeUsers === 0; UI renders "—")
|
||||
const hoursPerActiveUser = activeUsers === 0
|
||||
? 0
|
||||
: Math.round((totalAutotaskHours / activeUsers) * 10) / 10;
|
||||
|
||||
return NextResponse.json({
|
||||
configured: isMsgraphConfigured(),
|
||||
activeUsers,
|
||||
totalGraphHours,
|
||||
totalAutotaskHours,
|
||||
hoursPerActiveUser,
|
||||
} satisfies MobileEngagementSummary);
|
||||
} catch (error) {
|
||||
console.error('GET /api/mobile/engagement/summary failed:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch engagement summary', message: error instanceof Error ? error.message : 'unknown' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Per D-09:** This endpoint exists because the existing `/api/engagement/summary` returns averages (avgHoursWorked, avgTeamsMeetings, etc.), not the four totals ENG-03 specifies. We reuse the SAME SQL filters/joins (notAutomatedFilter, account_enabled + email scoping, resources DISTINCT ON dedupe) for consistency.
|
||||
|
||||
**Per D-32:** `requireAuth()` gates every request. **Do not call any DB code before this gate.**
|
||||
|
||||
**Per D-33:** Document inherited risk for the existing `/api/engagement/users` endpoint (which lacks `requireAuth()`) in the threat model — but DO NOT modify the desktop endpoint (out of scope per D-34 and PROJECT.md "Restyling or replacing the desktop pages…").
|
||||
|
||||
**Per D-36:** Read-only consumption — no edits to `lib/services/msgraph-*` or `lib/services/engagement-sync-service.ts`.
|
||||
|
||||
**Per D-38 / CLAUDE.md:** No Zod. The whitelist function above is sufficient input validation.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty 2>&1 | tee /tmp/p07-01-task1-tsc.log; grep -E "app/api/mobile/engagement/summary" /tmp/p07-01-task1-tsc.log && exit 1; exit 0</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- File exists: `test -f app/api/mobile/engagement/summary/route.ts`
|
||||
- Has `requireAuth` import: `grep -q "from '@/lib/auth-utils'" app/api/mobile/engagement/summary/route.ts`
|
||||
- Calls `requireAuth()` BEFORE any `postgresClient.query`: `awk '/postgresClient\.query|requireAuth\(\)/' app/api/mobile/engagement/summary/route.ts | head -1 | grep -q 'requireAuth()'`
|
||||
- Exports the interface: `grep -q "export interface MobileEngagementSummary" app/api/mobile/engagement/summary/route.ts`
|
||||
- All 5 fields present in interface: `grep -E "configured|activeUsers|totalGraphHours|totalAutotaskHours|hoursPerActiveUser" app/api/mobile/engagement/summary/route.ts | wc -l` ≥ 5
|
||||
- Period whitelist present: `grep -E "D7.*D30.*D90|'D7'|'D30'|'D90'" app/api/mobile/engagement/summary/route.ts` matches AND a 400 response path exists: `grep -q "status: 400" app/api/mobile/engagement/summary/route.ts`
|
||||
- Calls `isMsgraphConfigured()`: `grep -q "isMsgraphConfigured()" app/api/mobile/engagement/summary/route.ts`
|
||||
- NO Zod (D-38): `! grep -q "from 'zod'" app/api/mobile/engagement/summary/route.ts`
|
||||
- NO ORM (CLAUDE.md): `! grep -q "prisma\|drizzle" app/api/mobile/engagement/summary/route.ts`
|
||||
- SQL params parameterized (period passed as $1, not interpolated): `grep -E '\$1.*\$2' app/api/mobile/engagement/summary/route.ts`
|
||||
- `npx tsc --noEmit --pretty` exits 0 (no type errors)
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Endpoint file created. `npx tsc --noEmit --pretty` exits 0. Hitting GET /api/mobile/engagement/summary?period=D30 (when authed) returns the 5-field MobileEngagementSummary JSON. Invalid period returns 400.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 2: Create /api/mobile/engagement/trend endpoint</name>
|
||||
<read_first>
|
||||
- app/api/mobile/engagement/summary/route.ts (sibling — created in Task 1; reuse the same period whitelist + notAutomatedFilter pattern)
|
||||
- app/api/engagement/users/route.ts (existing endpoint — pattern reference for time_entries + resources + graph_users join; DO NOT modify)
|
||||
- .planning/phases/07-engagement-overview-new/07-CONTEXT.md (D-11, D-12, D-14, D-32, D-38)
|
||||
- .planning/phases/07-engagement-overview-new/07-UI-SPEC.md (Sparkline section: D7→7 points, D30→30 points, D90→90 points; missing days = 0)
|
||||
- migrations/041_create_engagement_tables.sql (graph_users + engagement_snapshots schema)
|
||||
</read_first>
|
||||
<files>app/api/mobile/engagement/trend/route.ts</files>
|
||||
<behavior>
|
||||
- Test 1: GET ?period=D7 (authed) returns 200 + { points: [...] } where points.length === 7
|
||||
- Test 2: GET ?period=D30 (authed) returns 200 with points.length === 30
|
||||
- Test 3: GET ?period=D90 (authed) returns 200 with points.length === 90
|
||||
- Test 4: GET ?period=foo returns 400 (whitelist rejection per D-07; same pattern as Task 1)
|
||||
- Test 5: Each point has shape { date: "YYYY-MM-DD", hours: number }; days with no time_entries return hours: 0 (NOT omitted — UI-SPEC requires continuous series)
|
||||
- Test 6: Days are in ascending date order (oldest first → most recent last so the SVG renders left-to-right with most recent on the right)
|
||||
- Test 7: Total payload size capped at 90 days max — no unbounded result set (T-07-03 mitigation)
|
||||
- Test 8: Unauth request → handled by requireAuth
|
||||
</behavior>
|
||||
<action>
|
||||
Create new file `app/api/mobile/engagement/trend/route.ts`. Same structural pattern as Task 1: requireAuth gate first, period whitelist validation second, parameterized SQL third, manual transform last. NO Zod (D-38).
|
||||
|
||||
**Imports:**
|
||||
```ts
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
```
|
||||
|
||||
**Exported interfaces (must match UI-SPEC API Shape Contract):**
|
||||
```ts
|
||||
export interface SparklinePoint {
|
||||
date: string; // ISO date string "YYYY-MM-DD"
|
||||
hours: number; // total Autotask hours for that day (0 if no entries)
|
||||
}
|
||||
|
||||
export interface EngagementTrendResponse {
|
||||
points: SparklinePoint[]; // D7 → 7, D30 → 30, D90 → 90 points
|
||||
}
|
||||
```
|
||||
|
||||
**Period whitelist (same as Task 1, copy):**
|
||||
```ts
|
||||
const ALLOWED_PERIODS = ['D7', 'D30', 'D90'] as const;
|
||||
type AllowedPeriod = typeof ALLOWED_PERIODS[number];
|
||||
const PERIOD_DAYS: Record<AllowedPeriod, number> = { D7: 7, D30: 30, D90: 90 };
|
||||
|
||||
function parsePeriod(raw: string | null): AllowedPeriod | null {
|
||||
if (!raw) return 'D30';
|
||||
return (ALLOWED_PERIODS as readonly string[]).includes(raw) ? (raw as AllowedPeriod) : null;
|
||||
}
|
||||
```
|
||||
|
||||
**GET handler — daily aggregate query:**
|
||||
|
||||
The query aggregates `time_entries.hours_worked` per `entry_date` for human resources matching `graph_users` in the period. Unlike Task 1's totals, this produces one row per day so the sparkline can render a continuous line.
|
||||
|
||||
```ts
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const { error: authError } = await requireAuth();
|
||||
if (authError) return authError;
|
||||
|
||||
const { searchParams } = request.nextUrl;
|
||||
const period = parsePeriod(searchParams.get('period'));
|
||||
if (period === null) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid period', message: "period must be one of 'D7', 'D30', 'D90'" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const days = PERIOD_DAYS[period];
|
||||
// T-07-03 mitigation: bounded by whitelisted period (max 90 days). No user-supplied row cap.
|
||||
|
||||
try {
|
||||
// generate_series produces one row per day so days with zero hours are still represented (D-15: continuous line, no gaps).
|
||||
// ${days} is interpolated NOT parameterized — safe because period is whitelisted to 7/30/90.
|
||||
const sql = `
|
||||
WITH day_series AS (
|
||||
SELECT generate_series(
|
||||
(CURRENT_DATE - INTERVAL '${days - 1} days')::date,
|
||||
CURRENT_DATE,
|
||||
INTERVAL '1 day'
|
||||
)::date AS day
|
||||
),
|
||||
daily_hours AS (
|
||||
SELECT te.entry_date::date AS day, COALESCE(SUM(te.hours_worked), 0) AS hours
|
||||
FROM time_entries te
|
||||
JOIN resources r ON r.id = te.resource_id AND (r.is_deleted = false OR r.is_deleted IS NULL)
|
||||
JOIN graph_users gu ON LOWER(gu.email) = LOWER(r.email)
|
||||
WHERE te.entry_date >= CURRENT_DATE - INTERVAL '${days - 1} days'
|
||||
AND te.entry_date <= CURRENT_DATE
|
||||
AND (te.is_deleted = false OR te.is_deleted IS NULL)
|
||||
AND gu.account_enabled = true
|
||||
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'
|
||||
AND LOWER(gu.email) NOT LIKE '%#ext#%'
|
||||
GROUP BY te.entry_date::date
|
||||
)
|
||||
SELECT to_char(ds.day, 'YYYY-MM-DD') AS date,
|
||||
COALESCE(dh.hours, 0)::numeric AS hours
|
||||
FROM day_series ds
|
||||
LEFT JOIN daily_hours dh ON dh.day = ds.day
|
||||
ORDER BY ds.day ASC
|
||||
`;
|
||||
|
||||
const result = await postgresClient.query(sql);
|
||||
|
||||
const points: SparklinePoint[] = result.rows.map(row => ({
|
||||
date: String(row.date),
|
||||
hours: Math.round(parseFloat(row.hours ?? '0') * 10) / 10,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ points } satisfies EngagementTrendResponse);
|
||||
} catch (error) {
|
||||
console.error('GET /api/mobile/engagement/trend failed:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch engagement trend', message: error instanceof Error ? error.message : 'unknown' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Per D-12 / D-15:** Continuous series — `generate_series` ensures every day in the period has a row even when zero hours were logged (no gaps). The UI sparkline draws zero-hour days down to baseline, never breaks the line.
|
||||
|
||||
**Per D-32:** `requireAuth()` is the first call — DB queries only run after auth.
|
||||
|
||||
**Per D-38:** No Zod. Whitelist sufficient.
|
||||
|
||||
**Per D-34, D-36:** No edits to existing engagement endpoints, services, or migrations.
|
||||
|
||||
**Per security threat T-07-03 (rate-limiting/DoS):** Period whitelist caps the date range to ≤90 days; the query has bounded results (one row per day, max 90). No user-supplied row-count parameter.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty 2>&1 | tee /tmp/p07-01-task2-tsc.log; grep -E "app/api/mobile/engagement/trend" /tmp/p07-01-task2-tsc.log && exit 1; exit 0</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- File exists: `test -f app/api/mobile/engagement/trend/route.ts`
|
||||
- Has `requireAuth` import + call BEFORE DB query: `awk '/postgresClient\.query|requireAuth\(\)/' app/api/mobile/engagement/trend/route.ts | head -1 | grep -q 'requireAuth()'`
|
||||
- Exports both interfaces: `grep -q "export interface SparklinePoint" app/api/mobile/engagement/trend/route.ts && grep -q "export interface EngagementTrendResponse" app/api/mobile/engagement/trend/route.ts`
|
||||
- Period whitelist present + 400 path: `grep -q "'D7', 'D30', 'D90'" app/api/mobile/engagement/trend/route.ts && grep -q "status: 400" app/api/mobile/engagement/trend/route.ts`
|
||||
- Uses `generate_series` to ensure continuous days (D-15): `grep -q "generate_series" app/api/mobile/engagement/trend/route.ts`
|
||||
- SQL is bounded by whitelisted period (no user-supplied days param): `! grep -E "searchParams.get\('days'\)|searchParams.get\(\"days\"\)" app/api/mobile/engagement/trend/route.ts`
|
||||
- NO Zod: `! grep -q "from 'zod'" app/api/mobile/engagement/trend/route.ts`
|
||||
- `npx tsc --noEmit --pretty` exits 0 (no type errors)
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Endpoint file created. `npx tsc --noEmit --pretty` exits 0. GET /api/mobile/engagement/trend?period=D30 (authed) returns { points: SparklinePoint[] } with exactly 30 points in ascending date order. Invalid period returns 400.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser → /api/mobile/engagement/* | Authenticated mobile clients send GET requests with cookie-based session; period query param is untrusted input |
|
||||
| Mobile API → Postgres | Endpoints query engagement_snapshots, graph_users, resources, time_entries; SQL parameters supplied by handler (period whitelisted) |
|
||||
| Mobile API → MSGraph factory | Read-only `isMsgraphConfigured()` boolean check; no credential exposure |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-07-01 | Spoofing/AuthN | summary + trend GET handlers | mitigate | Both handlers call `requireAuth()` from `lib/auth-utils.ts` as the FIRST statement; if `authError` is non-null, return immediately. Acceptance criteria check enforces ordering via awk grep. |
|
||||
| T-07-02 | Tampering / Injection | period query parameter | mitigate | Whitelist `['D7','D30','D90']` via `parsePeriod()`. Invalid values return 400 BEFORE any SQL executes. The whitelisted period is then used both as a bound parameter (`$1`) and to look up a static `INTERVAL '7 days' / '30 days' / '90 days'` constant — interpolation is on a fixed map value, not user input. |
|
||||
| T-07-03 | DoS / Resource exhaustion | trend endpoint daily aggregation | mitigate | Whitelist caps the date range to max 90 days. `generate_series` produces at most 90 rows. No user-supplied row-count or page-size parameter. SQL aggregation runs against indexed columns (entry_date, resource_id per existing engagement endpoints — no new index required). |
|
||||
| T-07-04 | Information Disclosure | summary/trend response payloads | mitigate | Both endpoints return ONLY scalars (totals, hours-per-day) — no per-user PII (no display_name, no email, no jobTitle). Sensitive metadata (teams_chat_messages, emails_sent, etc.) is summed, never enumerated. |
|
||||
| T-07-05 | Information Disclosure (inherited) | existing `/api/engagement/users` (NOT modified by this plan) | accept | Existing desktop endpoint lacks `requireAuth()` (CONTEXT.md D-33). Reuse from Plan 03 does not increase exposure since middleware.ts blocks unauth access to `/api/*` not in the public list (verify on first run). Out of scope per D-34 + PROJECT.md ("Restyling or replacing the desktop pages…"). Document follow-up; recommend a future security phase. Mirrors Phase 6 IDOR T-06P03-02 disposition pattern. |
|
||||
| T-07-06 | Repudiation | Both new endpoints | accept | Read-only GET endpoints surfacing aggregate metrics. No state mutation, no audit trail required. Standard `console.error` logging on exception paths. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Both endpoint files exist and pass `npx tsc --noEmit --pretty`
|
||||
- `grep -q "export interface" app/api/mobile/engagement/summary/route.ts && grep -q "export interface" app/api/mobile/engagement/trend/route.ts` (interfaces exported for Plan 03 to import)
|
||||
- `requireAuth()` is called BEFORE any `postgresClient.query` in both files (auth gate ordering)
|
||||
- Period whitelist + 400 path present in both files
|
||||
- Manual run-time check: `curl -s -i http://localhost:3100/api/mobile/engagement/summary?period=D30 | head -3` returns 401 or redirect when not authed; returns 200 with 5-field JSON when authed
|
||||
- Manual run-time check: `curl -s 'http://localhost:3100/api/mobile/engagement/trend?period=foo' | head -1` returns 400 (or middleware redirect; behavior depends on session)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Both files written, both export the documented interfaces
|
||||
- `npx tsc --noEmit --pretty` exits 0
|
||||
- Period whitelist enforced (D7/D30/D90 only) on both endpoints
|
||||
- `requireAuth()` is the first statement in each handler
|
||||
- No Zod, no ORM, no edits outside the two new files
|
||||
- Plan 03 can `import type { MobileEngagementSummary } from '@/app/api/mobile/engagement/summary/route'` and `import type { SparklinePoint, EngagementTrendResponse } from '@/app/api/mobile/engagement/trend/route'` without errors
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/07-engagement-overview-new/07-01-SUMMARY.md` documenting:
|
||||
- Endpoint paths + exported interfaces
|
||||
- Period whitelist values
|
||||
- Any deviations from UI-SPEC API Shape Contract (none expected)
|
||||
- Confirmed inherited risk for `/api/engagement/users` (per D-33; out-of-scope to fix)
|
||||
</output>
|
||||
782
.planning/phases/07-engagement-overview-new/07-02-PLAN.md
Normal file
782
.planning/phases/07-engagement-overview-new/07-02-PLAN.md
Normal file
|
|
@ -0,0 +1,782 @@
|
|||
---
|
||||
phase: 07-engagement-overview-new
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- 07-01
|
||||
files_modified:
|
||||
- components/mobile/EngagementPeriodChips.tsx
|
||||
- components/mobile/EngagementSummaryCard.tsx
|
||||
- components/mobile/EngagementHoursSparkline.tsx
|
||||
- components/mobile/EngagementSortChips.tsx
|
||||
- components/mobile/EngagementSearchInput.tsx
|
||||
- components/mobile/EngagementUserRow.tsx
|
||||
- components/mobile/EngagementUserRowSkeleton.tsx
|
||||
autonomous: true
|
||||
requirements:
|
||||
- ENG-02
|
||||
- ENG-03
|
||||
- ENG-04
|
||||
- ENG-05
|
||||
must_haves:
|
||||
truths:
|
||||
- "EngagementPeriodChips renders three chips '7d' / '30d' / '90d' with active styling derived from prop"
|
||||
- "EngagementSummaryCard renders big number (text-2xl font-semibold) + label (text-xs text-muted-foreground)"
|
||||
- "EngagementHoursSparkline renders an inline SVG path when given non-empty points; renders 'No activity' fallback when empty/all-zero"
|
||||
- "EngagementSortChips renders three chips 'Hours' / 'Name' / 'Utilization' with active state"
|
||||
- "EngagementSearchInput renders shadcn Input with leading Search icon and 300ms debounce on onChange"
|
||||
- "EngagementUserRow renders a Link with avatar (initials)+name+role+hours+hours-bar; tap navigates to /mobile/engagement/[graphUserId]"
|
||||
- "EngagementUserRowSkeleton mirrors EngagementUserRow's shape"
|
||||
- "Module-level utility getInitials(displayName) is exported from EngagementUserRow.tsx for Phase 8 reuse"
|
||||
artifacts:
|
||||
- path: "components/mobile/EngagementPeriodChips.tsx"
|
||||
provides: "3-chip period selector"
|
||||
exports: ["EngagementPeriodChips", "EngagementPeriodChipsProps"]
|
||||
- path: "components/mobile/EngagementSummaryCard.tsx"
|
||||
provides: "Single summary card primitive"
|
||||
exports: ["EngagementSummaryCard"]
|
||||
- path: "components/mobile/EngagementHoursSparkline.tsx"
|
||||
provides: "Custom SVG sparkline"
|
||||
exports: ["EngagementHoursSparkline"]
|
||||
- path: "components/mobile/EngagementSortChips.tsx"
|
||||
provides: "3-chip sort selector"
|
||||
exports: ["EngagementSortChips", "EngagementSortKey"]
|
||||
- path: "components/mobile/EngagementSearchInput.tsx"
|
||||
provides: "Debounced search input"
|
||||
exports: ["EngagementSearchInput"]
|
||||
- path: "components/mobile/EngagementUserRow.tsx"
|
||||
provides: "User row card (avatar + name + hours + bar) wrapped in Link"
|
||||
exports: ["EngagementUserRow", "getInitials", "EngagementUserRowProps"]
|
||||
- path: "components/mobile/EngagementUserRowSkeleton.tsx"
|
||||
provides: "Skeleton matching EngagementUserRow shape"
|
||||
exports: ["EngagementUserRowSkeleton"]
|
||||
key_links:
|
||||
- from: "components/mobile/EngagementUserRow.tsx"
|
||||
to: "/mobile/engagement/[graphUserId]"
|
||||
via: "next/link href={\\`/mobile/engagement/${graphUserId}\\`}"
|
||||
pattern: "/mobile/engagement/\\$\\{"
|
||||
- from: "components/mobile/EngagementHoursSparkline.tsx"
|
||||
to: "SparklinePoint type"
|
||||
via: "import type from '@/app/api/mobile/engagement/trend/route'"
|
||||
pattern: "from '@/app/api/mobile/engagement/trend/route'"
|
||||
- from: "components/mobile/EngagementSummaryCard.tsx"
|
||||
to: "shadcn Card"
|
||||
via: "@/components/ui/card"
|
||||
pattern: "from '@/components/ui/card'"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the seven phone-first components that the Engagement page (Plan 03) composes:
|
||||
period chips, summary card, hours sparkline (custom SVG, no recharts per DASH-04),
|
||||
sort chips, search input, user row, and user row skeleton. All components are pure
|
||||
presentational primitives (no fetches, no toasts) — they receive data via props and
|
||||
emit events via callbacks. The page in Plan 03 owns all orchestration.
|
||||
|
||||
Purpose: Lock the visual contract from UI-SPEC into reusable components. Every
|
||||
Tailwind class string in this plan is copied verbatim from `07-UI-SPEC.md`. This is
|
||||
where typography (4 sizes, 2 weights), spacing, and color tokens become code.
|
||||
|
||||
Output:
|
||||
- 7 new files under `components/mobile/`
|
||||
- Exports the `getInitials(displayName)` utility (Phase 8 reuse, per UI-SPEC §"Note on EngagementUserRow extraction")
|
||||
- Imports types from Plan 01's API routes (`SparklinePoint`)
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/REQUIREMENTS.md
|
||||
@.planning/phases/07-engagement-overview-new/07-CONTEXT.md
|
||||
@.planning/phases/07-engagement-overview-new/07-UI-SPEC.md
|
||||
@CLAUDE.md
|
||||
@DESIGN.md
|
||||
@app/api/mobile/engagement/trend/route.ts
|
||||
@components/mobile/AnalyzerFeedRow.tsx
|
||||
@components/mobile/AnalyzerRowSkeleton.tsx
|
||||
@components/mobile/FinanceRow.tsx
|
||||
@components/ui/card.tsx
|
||||
@components/ui/input.tsx
|
||||
@components/ui/skeleton.tsx
|
||||
@components/ui/badge.tsx
|
||||
|
||||
<interfaces>
|
||||
<!-- Types this plan consumes from Plan 01 (already created in Wave 1): -->
|
||||
|
||||
```ts
|
||||
// From app/api/mobile/engagement/trend/route.ts (created by Plan 01):
|
||||
import type { SparklinePoint } from '@/app/api/mobile/engagement/trend/route';
|
||||
|
||||
export interface SparklinePoint {
|
||||
date: string; // "YYYY-MM-DD"
|
||||
hours: number;
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// EngagementUser shape — used by EngagementUserRow. The existing /api/engagement/users
|
||||
// route does NOT export types, so define inline (mirrors UI-SPEC API Shape Contract):
|
||||
export interface EngagementUser {
|
||||
graphUserId: string;
|
||||
displayName: string;
|
||||
userEmail: string; // existing endpoint returns `email` — Plan 03 maps this
|
||||
jobTitle: string | null;
|
||||
billableHours: number;
|
||||
hoursWorked: number;
|
||||
}
|
||||
```
|
||||
|
||||
<!-- Component file convention header (Phase 3/4/5/6 pattern): -->
|
||||
|
||||
```ts
|
||||
'use client';
|
||||
|
||||
/* ComponentName — phase 07 (ENG-NN).
|
||||
* Purpose: one-line description.
|
||||
* Props: ... */
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 1: Build EngagementPeriodChips + EngagementSortChips + EngagementSearchInput (chip + input primitives)</name>
|
||||
<read_first>
|
||||
- .planning/phases/07-engagement-overview-new/07-UI-SPEC.md (Period Chips, Sort Chips, Search Input sections — typography, color tokens, copy strings, accessibility)
|
||||
- .planning/phases/07-engagement-overview-new/07-CONTEXT.md (D-04, D-05, D-06, D-20, D-21, D-28, D-29)
|
||||
- components/mobile/AnalyzerFeedRow.tsx (component file structure + 'use client' + comment header convention)
|
||||
- components/ui/input.tsx (shadcn Input — confirm props accept className for pl-9)
|
||||
</read_first>
|
||||
<files>components/mobile/EngagementPeriodChips.tsx, components/mobile/EngagementSortChips.tsx, components/mobile/EngagementSearchInput.tsx</files>
|
||||
<action>
|
||||
Create three small `'use client'` components, all pure presentational. Use the EXACT class strings from UI-SPEC. No fetches, no toasts.
|
||||
|
||||
---
|
||||
|
||||
**File 1: `components/mobile/EngagementPeriodChips.tsx`** (per D-04, D-05, D-06)
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
/* EngagementPeriodChips — phase 07 (ENG-02).
|
||||
* Purpose: 3-chip period selector (7d/30d/90d) sticky below the page H1.
|
||||
* Maps 1:1 to data-layer period_type values D7/D30/D90 (D-04).
|
||||
* Props: period, onPeriodChange. Pure presentational — page owns refetch logic. */
|
||||
|
||||
export type EngagementPeriod = 'D7' | 'D30' | 'D90';
|
||||
|
||||
export interface EngagementPeriodChipsProps {
|
||||
period: EngagementPeriod;
|
||||
onPeriodChange: (next: EngagementPeriod) => void;
|
||||
}
|
||||
|
||||
const CHIPS: ReadonlyArray<{ value: EngagementPeriod; label: string }> = [
|
||||
{ value: 'D7', label: '7d' },
|
||||
{ value: 'D30', label: '30d' },
|
||||
{ value: 'D90', label: '90d' },
|
||||
];
|
||||
|
||||
export function EngagementPeriodChips({ period, onPeriodChange }: EngagementPeriodChipsProps) {
|
||||
return (
|
||||
<div className="sticky top-0 z-10 bg-background pt-2 pb-3 -mx-4 px-4 flex gap-2 min-h-[44px]">
|
||||
{CHIPS.map(chip => {
|
||||
const isActive = chip.value === period;
|
||||
return (
|
||||
<button
|
||||
key={chip.value}
|
||||
type="button"
|
||||
role="button"
|
||||
aria-pressed={isActive}
|
||||
onClick={() => { if (!isActive) onPeriodChange(chip.value); }}
|
||||
className={
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground rounded-full px-3 py-1.5 text-[10px] font-semibold'
|
||||
: 'bg-muted text-foreground hover:bg-muted/80 rounded-full px-3 py-1.5 text-[10px] font-semibold'
|
||||
}
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Notes (D-29 typography fix):** Chip text is `text-[10px]` (NOT `text-xs`) to satisfy UI-SPEC's typography table where "Badge / caption" uses `text-[10px]` for chip labels. The 4-size cap is preserved.
|
||||
|
||||
---
|
||||
|
||||
**File 2: `components/mobile/EngagementSortChips.tsx`** (per D-20)
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
/* EngagementSortChips — phase 07 (ENG-04).
|
||||
* Purpose: 3-chip sort selector (Hours/Name/Utilization). Same chip styling as period chips.
|
||||
* Maps to /api/engagement/users sort/order params per D-20.
|
||||
* Props: activeSort, onSortChange. Pure presentational. */
|
||||
|
||||
export type EngagementSortKey = 'Hours' | 'Name' | 'Utilization';
|
||||
|
||||
export interface EngagementSortChipsProps {
|
||||
activeSort: EngagementSortKey;
|
||||
onSortChange: (next: EngagementSortKey) => void;
|
||||
}
|
||||
|
||||
const SORTS: ReadonlyArray<EngagementSortKey> = ['Hours', 'Name', 'Utilization'];
|
||||
|
||||
export function EngagementSortChips({ activeSort, onSortChange }: EngagementSortChipsProps) {
|
||||
return (
|
||||
<div className="flex gap-2 items-center min-h-[44px]">
|
||||
{SORTS.map(key => {
|
||||
const isActive = key === activeSort;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="button"
|
||||
aria-pressed={isActive}
|
||||
onClick={() => { if (!isActive) onSortChange(key); }}
|
||||
className={
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground rounded-full px-3 py-1.5 text-[10px] font-semibold'
|
||||
: 'bg-muted text-foreground hover:bg-muted/80 rounded-full px-3 py-1.5 text-[10px] font-semibold'
|
||||
}
|
||||
>
|
||||
{key}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Per D-20 mapping (consumed by Plan 03):**
|
||||
- `'Hours'` → `sort=billable_hours&order=desc`
|
||||
- `'Name'` → `sort=display_name&order=asc`
|
||||
- `'Utilization'` → `sort=billable_hours&order=desc` (same API sort; visual label differs)
|
||||
|
||||
The mapping itself is owned by the page (Plan 03), NOT this component.
|
||||
|
||||
---
|
||||
|
||||
**File 3: `components/mobile/EngagementSearchInput.tsx`** (per D-21)
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
/* EngagementSearchInput — phase 07 (ENG-04).
|
||||
* Purpose: Search input with leading Search icon. Debounced 300ms before emitting onChange.
|
||||
* Page applies the filter client-side on loaded users (D-21).
|
||||
* Props: value (controlled string), onChange (debounced callback). */
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
export interface EngagementSearchInputProps {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
}
|
||||
|
||||
export function EngagementSearchInput({ value, onChange }: EngagementSearchInputProps) {
|
||||
// Local immediate state for the input; debounce flushes to onChange
|
||||
const [local, setLocal] = useState<string>(value);
|
||||
|
||||
// Keep local state synced when the parent resets (e.g. "Clear search" CTA on no-matches state)
|
||||
useEffect(() => {
|
||||
setLocal(value);
|
||||
}, [value]);
|
||||
|
||||
// 300ms debounce per D-21
|
||||
useEffect(() => {
|
||||
if (local === value) return;
|
||||
const id = setTimeout(() => { onChange(local); }, 300);
|
||||
return () => clearTimeout(id);
|
||||
}, [local, value, onChange]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search by name or email"
|
||||
aria-label="Search team members by name or email"
|
||||
className="pl-9"
|
||||
value={local}
|
||||
onChange={(e) => setLocal(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Per D-21:** Server-side search NOT used — the parent applies the filter client-side. This component just emits debounced changes.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- All 3 files exist
|
||||
- PeriodChips: container has all of `sticky top-0 z-10 bg-background pt-2 pb-3 -mx-4 px-4 flex gap-2`: `grep -E "sticky top-0 z-10 bg-background pt-2 pb-3 -mx-4 px-4" components/mobile/EngagementPeriodChips.tsx`
|
||||
- PeriodChips: 3 chip values D7/D30/D90 with labels 7d/30d/90d: `grep -E "'D7'.*'7d'|D7.*7d" components/mobile/EngagementPeriodChips.tsx`
|
||||
- PeriodChips: active and inactive class strings exact (per UI-SPEC + deep_work_rules adjusted to `text-[10px]`): `grep -F "bg-primary text-primary-foreground rounded-full px-3 py-1.5 text-[10px] font-semibold" components/mobile/EngagementPeriodChips.tsx && grep -F "bg-muted text-foreground hover:bg-muted/80 rounded-full px-3 py-1.5 text-[10px] font-semibold" components/mobile/EngagementPeriodChips.tsx`
|
||||
- PeriodChips: `aria-pressed={isActive}` present on each button: `grep -q "aria-pressed={isActive}" components/mobile/EngagementPeriodChips.tsx`
|
||||
- PeriodChips: exports `EngagementPeriod` type union: `grep -q "export type EngagementPeriod" components/mobile/EngagementPeriodChips.tsx`
|
||||
- SortChips: 3 chips with labels Hours / Name / Utilization: `grep -E "Hours.*Name.*Utilization|'Hours'" components/mobile/EngagementSortChips.tsx`
|
||||
- SortChips: same chip class strings as PeriodChips
|
||||
- SortChips: exports `EngagementSortKey` type: `grep -q "export type EngagementSortKey" components/mobile/EngagementSortChips.tsx`
|
||||
- SearchInput: imports `Search` from `lucide-react` and `Input` from shadcn: `grep -q "from 'lucide-react'" components/mobile/EngagementSearchInput.tsx && grep -q "from '@/components/ui/input'" components/mobile/EngagementSearchInput.tsx`
|
||||
- SearchInput: placeholder copy verbatim: `grep -F 'placeholder="Search by name or email"' components/mobile/EngagementSearchInput.tsx`
|
||||
- SearchInput: 300ms debounce: `grep -E "300\b" components/mobile/EngagementSearchInput.tsx`
|
||||
- SearchInput: `aria-label="Search team members by name or email"` present: `grep -F 'aria-label="Search team members by name or email"' components/mobile/EngagementSearchInput.tsx`
|
||||
- SearchInput: leading icon classes verbatim: `grep -F "absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" components/mobile/EngagementSearchInput.tsx`
|
||||
- All 3 files have `'use client';` directive at top: `grep -L "^'use client';" components/mobile/EngagementPeriodChips.tsx components/mobile/EngagementSortChips.tsx components/mobile/EngagementSearchInput.tsx | wc -l` === 0
|
||||
- `npx tsc --noEmit --pretty` exits 0
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Three files exist with the exact class strings from UI-SPEC. Type-check passes. Components ready for import in Plan 03.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 2: Build EngagementSummaryCard + EngagementHoursSparkline (display primitives)</name>
|
||||
<read_first>
|
||||
- .planning/phases/07-engagement-overview-new/07-UI-SPEC.md (Summary Cards, Hours Trend Sparkline sections — exact class strings, copy strings, period_label mapping)
|
||||
- .planning/phases/07-engagement-overview-new/07-CONTEXT.md (D-08, D-09, D-10, D-11, D-12, D-13, D-14, D-15, D-29)
|
||||
- app/api/mobile/engagement/trend/route.ts (Plan 01 — for SparklinePoint import)
|
||||
- components/ui/card.tsx (shadcn Card primitive)
|
||||
- components/mobile/AnalyzerFeedRow.tsx (file structure + comment header pattern)
|
||||
</read_first>
|
||||
<files>components/mobile/EngagementSummaryCard.tsx, components/mobile/EngagementHoursSparkline.tsx</files>
|
||||
<action>
|
||||
Create two `'use client'` display components. The sparkline uses an inline `<svg>` (no recharts — DASH-04 + D-12).
|
||||
|
||||
---
|
||||
|
||||
**File 1: `components/mobile/EngagementSummaryCard.tsx`** (per D-08, D-10, D-29)
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
/* EngagementSummaryCard — phase 07 (ENG-03).
|
||||
* Purpose: Single summary card with big number + label, stacked single-column.
|
||||
* Used 4× on the page: Active users, Total Graph hours, Total Autotask hours, Hours / active user (D-08).
|
||||
* Card has no shadow, only border (matches FinanceRow density per D-10).
|
||||
* Props: value (display string), label (display string). */
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
export interface EngagementSummaryCardProps {
|
||||
value: string; // pre-formatted: "42", "128.5h", "—"
|
||||
label: string; // "Active users", "Total Graph hours", etc.
|
||||
}
|
||||
|
||||
export function EngagementSummaryCard({ value, label }: EngagementSummaryCardProps) {
|
||||
return (
|
||||
<Card className="py-0 shadow-none">
|
||||
<CardContent className="px-4 py-4">
|
||||
<p className="text-2xl font-semibold text-foreground leading-none">{value}</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">{label}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Per D-10 / UI-SPEC:** Big number `text-2xl font-semibold`. Label `text-xs text-muted-foreground`. No shadow, border only.
|
||||
|
||||
**Value formatting is owned by the page (Plan 03).** This card receives a pre-formatted string. Page passes:
|
||||
- Card 1 ("Active users"): `String(activeUsers)` (e.g. "42")
|
||||
- Card 2 ("Total Graph hours"): `${totalGraphHours.toFixed(1)}h` (e.g. "128.5h")
|
||||
- Card 3 ("Total Autotask hours"): `${totalAutotaskHours.toFixed(1)}h`
|
||||
- Card 4 ("Hours / active user"): `activeUsers === 0 ? '—' : `${hoursPerActiveUser.toFixed(1)}h`` (per D-08 "render '—' (em dash) for card 4")
|
||||
|
||||
---
|
||||
|
||||
**File 2: `components/mobile/EngagementHoursSparkline.tsx`** (per D-11..D-15)
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
/* EngagementHoursSparkline — phase 07 (ENG-05).
|
||||
* Purpose: Custom inline SVG sparkline for daily hours trend over the selected period.
|
||||
* One series, no axes, no tooltips, no animation. DASH-04 (no recharts on mobile).
|
||||
* Renders the sparkline card per UI-SPEC: label row (left + right) + 48px-tall SVG.
|
||||
* Props: points (Plan 01 SparklinePoint[]), period (D7|D30|D90 — drives the period_label). */
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import type { SparklinePoint } from '@/app/api/mobile/engagement/trend/route';
|
||||
|
||||
export interface EngagementHoursSparklineProps {
|
||||
points: SparklinePoint[];
|
||||
period: 'D7' | 'D30' | 'D90';
|
||||
}
|
||||
|
||||
const PERIOD_LABEL: Record<EngagementHoursSparklineProps['period'], string> = {
|
||||
D7: '7 days',
|
||||
D30: '30 days',
|
||||
D90: '90 days',
|
||||
};
|
||||
|
||||
function formatLatestValue(points: SparklinePoint[]): string {
|
||||
// Find last point with hours > 0
|
||||
let latest: SparklinePoint | null = null;
|
||||
for (let i = points.length - 1; i >= 0; i--) {
|
||||
if (points[i].hours > 0) { latest = points[i]; break; }
|
||||
}
|
||||
if (!latest) return '—';
|
||||
|
||||
const todayIso = new Date().toISOString().slice(0, 10); // "YYYY-MM-DD" UTC
|
||||
const isToday = latest.date === todayIso;
|
||||
const hoursLabel = `${latest.hours.toFixed(1)}h`;
|
||||
if (isToday) return `${hoursLabel} today`;
|
||||
|
||||
// shortDate: "May 2"
|
||||
const [y, m, d] = latest.date.split('-').map(Number);
|
||||
const dt = new Date(Date.UTC(y, m - 1, d));
|
||||
const short = dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });
|
||||
return `${hoursLabel} ${short}`;
|
||||
}
|
||||
|
||||
function buildPath(points: SparklinePoint[], svgWidth: number, svgHeight: number): string {
|
||||
if (points.length === 0) return '';
|
||||
const maxHours = Math.max(...points.map(p => p.hours), 0);
|
||||
const yScale = maxHours === 0 ? 0 : (svgHeight - 8) / maxHours; // 4px top + 4px bottom margin
|
||||
const xStep = points.length === 1 ? 0 : svgWidth / (points.length - 1);
|
||||
|
||||
return points.map((pt, i) => {
|
||||
const x = points.length === 1 ? svgWidth / 2 : i * xStep;
|
||||
const y = svgHeight - 4 - (pt.hours * yScale); // baseline 4px above bottom
|
||||
return `${i === 0 ? 'M' : 'L'} ${x.toFixed(2)},${y.toFixed(2)}`;
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
export function EngagementHoursSparkline({ points, period }: EngagementHoursSparklineProps) {
|
||||
const periodLabel = PERIOD_LABEL[period];
|
||||
const allZero = points.length === 0 || points.every(p => p.hours === 0);
|
||||
const SVG_W = 300;
|
||||
const SVG_H = 48;
|
||||
|
||||
return (
|
||||
<Card className="py-0 shadow-none">
|
||||
<CardContent className="px-4 py-3">
|
||||
<div className="flex justify-between items-center mb-2 gap-2">
|
||||
<p className="text-xs text-muted-foreground">{`Hours trend · last ${periodLabel}`}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatLatestValue(points)}</p>
|
||||
</div>
|
||||
|
||||
{allZero ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-3">No activity</p>
|
||||
) : (
|
||||
<svg
|
||||
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
|
||||
preserveAspectRatio="none"
|
||||
className="h-12 w-full"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Baseline at y=46 (2px from bottom) per UI-SPEC */}
|
||||
<line
|
||||
x1="0"
|
||||
y1={SVG_H - 2}
|
||||
x2={SVG_W}
|
||||
y2={SVG_H - 2}
|
||||
className="text-muted-foreground/20"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
fill="none"
|
||||
/>
|
||||
{/* Series path — stroke-primary stroke-2 fill-none */}
|
||||
<path
|
||||
d={buildPath(points, SVG_W, SVG_H)}
|
||||
className="stroke-primary"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Per D-12 / UI-SPEC sparkline contract:**
|
||||
- Linear interpolation only (`M x0,y0 L x1,y1 L x2,y2 ...`)
|
||||
- Missing-day handling: zero hours draw to baseline, NEVER gap (the trend endpoint already returns continuous days via `generate_series`)
|
||||
- No animation, no dots, no tooltips
|
||||
- `stroke-primary` Tailwind token (resolves to `--primary` CSS variable per UI-SPEC color contract)
|
||||
- `vectorEffect="non-scaling-stroke"` keeps the line at 2px width even with `preserveAspectRatio="none"` stretching the viewBox
|
||||
|
||||
**Per D-13:** Label row left text "Hours trend · last 30 days" (note the middle dot `·`, U+00B7). Right text "X.Xh today" or "X.Xh May 2" or "—".
|
||||
|
||||
**Per D-15:** No-data fallback when `points.length === 0` or all-zero — render `<p className="text-xs text-muted-foreground text-center py-3">No activity</p>` and skip the SVG entirely.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Both files exist
|
||||
- SummaryCard: imports `Card, CardContent` from `@/components/ui/card`: `grep -q "from '@/components/ui/card'" components/mobile/EngagementSummaryCard.tsx`
|
||||
- SummaryCard: big number class verbatim: `grep -F "text-2xl font-semibold text-foreground leading-none" components/mobile/EngagementSummaryCard.tsx`
|
||||
- SummaryCard: label class verbatim: `grep -F "text-xs text-muted-foreground" components/mobile/EngagementSummaryCard.tsx`
|
||||
- SummaryCard: shadow-none on Card (D-10): `grep -F "shadow-none" components/mobile/EngagementSummaryCard.tsx`
|
||||
- Sparkline: imports SparklinePoint type from Plan 01: `grep -F "from '@/app/api/mobile/engagement/trend/route'" components/mobile/EngagementHoursSparkline.tsx`
|
||||
- Sparkline: period_label mapping has all 3 periods with strings "7 days" / "30 days" / "90 days": `grep -E "'7 days'|'30 days'|'90 days'" components/mobile/EngagementHoursSparkline.tsx | wc -l` ≥ 3
|
||||
- Sparkline: copy "Hours trend · last": `grep -F "Hours trend · last" components/mobile/EngagementHoursSparkline.tsx`
|
||||
- Sparkline: no-data copy "No activity": `grep -F "No activity" components/mobile/EngagementHoursSparkline.tsx`
|
||||
- Sparkline: SVG element with `viewBox`: `grep -E "viewBox=" components/mobile/EngagementHoursSparkline.tsx`
|
||||
- Sparkline: stroke-primary class: `grep -F "stroke-primary" components/mobile/EngagementHoursSparkline.tsx`
|
||||
- Sparkline: stroke-2 width: `grep -E 'strokeWidth="2"' components/mobile/EngagementHoursSparkline.tsx`
|
||||
- Sparkline: 48px tall (`h-12`): `grep -F "h-12 w-full" components/mobile/EngagementHoursSparkline.tsx`
|
||||
- Sparkline: linear path commands `M` then `L`: `grep -E "'M'|'L'" components/mobile/EngagementHoursSparkline.tsx`
|
||||
- Sparkline: NO recharts import (DASH-04): `! grep -q "from 'recharts'" components/mobile/EngagementHoursSparkline.tsx`
|
||||
- `npx tsc --noEmit --pretty` exits 0
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Both files exist with verbatim UI-SPEC class strings + copy. Sparkline uses inline SVG only (no chart library). Type-check passes.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 3: Build EngagementUserRow + EngagementUserRowSkeleton (with exported getInitials utility)</name>
|
||||
<read_first>
|
||||
- .planning/phases/07-engagement-overview-new/07-UI-SPEC.md (User Row, User Row Skeleton sections — full markup with hours bar, exact class strings; "Note on EngagementUserRow extraction" for getInitials utility)
|
||||
- .planning/phases/07-engagement-overview-new/07-CONTEXT.md (D-19, D-23, D-29; "Claude's Discretion" notes about avatar initials algorithm and Phase 8 reuse)
|
||||
- components/mobile/AnalyzerFeedRow.tsx (Link wrapper pattern, file header convention)
|
||||
- components/mobile/AnalyzerRowSkeleton.tsx (skeleton pattern reference)
|
||||
- components/ui/skeleton.tsx (Skeleton primitive)
|
||||
</read_first>
|
||||
<files>components/mobile/EngagementUserRow.tsx, components/mobile/EngagementUserRowSkeleton.tsx</files>
|
||||
<action>
|
||||
Create two files. `EngagementUserRow.tsx` is the single most-load-bearing component on the page — it owns the visual contract for the per-employee list. Mirror the exact markup from UI-SPEC §"User Row".
|
||||
|
||||
---
|
||||
|
||||
**File 1: `components/mobile/EngagementUserRow.tsx`** (per D-19; getInitials per UI-SPEC §"Note on EngagementUserRow extraction")
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
/* EngagementUserRow — phase 07 (ENG-04).
|
||||
* Purpose: Per-employee row card — avatar (initials) + name + role + hours + hours bar.
|
||||
* Entire row is a Link to /mobile/engagement/[graphUserId] (D-19; Phase 8 owns destination).
|
||||
* Hours bar width = (billableHours / maxHours) * 100% — bounded at 100%.
|
||||
* Props: user (EngagementUser shape), maxHours (largest billableHours in current page set; computed by parent). */
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
export interface EngagementUserRowData {
|
||||
graphUserId: string;
|
||||
displayName: string;
|
||||
userEmail: string;
|
||||
jobTitle: string | null;
|
||||
billableHours: number;
|
||||
hoursWorked: number;
|
||||
}
|
||||
|
||||
export interface EngagementUserRowProps {
|
||||
user: EngagementUserRowData;
|
||||
maxHours: number; // largest billableHours in the loaded set (parent computes)
|
||||
}
|
||||
|
||||
/**
|
||||
* getInitials — first letter of first word + first letter of last word of displayName,
|
||||
* uppercased. E.g. "Jordan Walsh" → "JW", "Alex" → "A". Exported for Phase 8 reuse
|
||||
* (the user profile header may share the avatar identity block per UI-SPEC).
|
||||
*/
|
||||
export function getInitials(displayName: string): string {
|
||||
const parts = displayName.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return '?';
|
||||
if (parts.length === 1) return parts[0]![0]!.toUpperCase();
|
||||
const first = parts[0]![0] ?? '';
|
||||
const last = parts[parts.length - 1]![0] ?? '';
|
||||
return (first + last).toUpperCase();
|
||||
}
|
||||
|
||||
export function EngagementUserRow({ user, maxHours }: EngagementUserRowProps) {
|
||||
const initials = getInitials(user.displayName);
|
||||
const hoursLabel = `${user.billableHours.toFixed(1)}h`;
|
||||
const barWidthPct = maxHours > 0
|
||||
? Math.min(100, (user.billableHours / maxHours) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/mobile/engagement/${user.graphUserId}`}
|
||||
className="block px-4 py-3 hover:bg-muted/50 transition-colors active:bg-muted/50"
|
||||
>
|
||||
{/* Top line: avatar + identity + hours value */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-8 w-8 rounded-full bg-muted flex items-center justify-center shrink-0 text-[10px] font-semibold text-foreground"
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0 flex items-baseline gap-2">
|
||||
<span className="text-sm font-semibold truncate flex-1">
|
||||
{user.displayName}
|
||||
</span>
|
||||
<span className="text-sm font-semibold shrink-0 text-right">
|
||||
{hoursLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role line — render only if jobTitle present (UI-SPEC: "render nothing (no empty line) if absent") */}
|
||||
{user.jobTitle && (
|
||||
<p className="text-xs text-muted-foreground truncate pl-11 mt-0.5">
|
||||
{user.jobTitle}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Hours bar */}
|
||||
<div className="mt-2">
|
||||
<div className="h-1.5 rounded-full bg-muted overflow-hidden" role="presentation" aria-hidden="true">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${barWidthPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Avatar text uses `text-[10px]` per UI-SPEC typography table (badge/caption size). The avatar background is `bg-muted` (no per-user color hashing per UI-SPEC §Avatar Color).
|
||||
- Role indentation: UI-SPEC says `px-[44px]` but with the row's `px-4` (16px) page padding, the avatar (32px) + gap (12px) sums to 44px — so the inner indent is `pl-11` (44px) which aligns the role text under the name (avatar baseline).
|
||||
- `getInitials` is a top-level **named export** so Phase 8 can `import { getInitials } from '@/components/mobile/EngagementUserRow'`. Algorithm per CONTEXT.md "Claude's Discretion": first letter of first word + first letter of last word, uppercased.
|
||||
- Hours bar transition: `transition-all duration-300` keeps the bar smooth when the page set changes (e.g., after sort).
|
||||
|
||||
**Per D-19 link:** `<Link href={\`/mobile/engagement/${user.graphUserId}\`}>` — Phase 8 (a future phase) builds the destination page. Phase 7 just wires the link.
|
||||
|
||||
---
|
||||
|
||||
**File 2: `components/mobile/EngagementUserRowSkeleton.tsx`** (per D-23)
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
/* EngagementUserRowSkeleton — phase 07 (D-23).
|
||||
* Purpose: Skeleton placeholder matching EngagementUserRow shape. Renders 5 instances on initial load.
|
||||
* Props: none — purely presentational. */
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
export function EngagementUserRowSkeleton() {
|
||||
return (
|
||||
<div className="px-4 py-3 space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<div className="flex-1 flex items-center justify-between gap-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-12" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-3 w-24 ml-11" />
|
||||
<Skeleton className="h-1.5 w-full mt-2" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:** Mirrors the row shape exactly — avatar circle (32×32), name placeholder, hours placeholder, role placeholder indented past avatar (`ml-11`), hours bar placeholder. UI-SPEC mandates 5 skeleton instances on initial load (rendered by the page).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Both files exist
|
||||
- UserRow: imports `Link` from `next/link`: `grep -q "from 'next/link'" components/mobile/EngagementUserRow.tsx`
|
||||
- UserRow: Link href targets /mobile/engagement/[graphUserId]: `grep -E "/mobile/engagement/\\\$\\{user\\.graphUserId\\}" components/mobile/EngagementUserRow.tsx`
|
||||
- UserRow: exports `getInitials` function: `grep -q "export function getInitials" components/mobile/EngagementUserRow.tsx`
|
||||
- UserRow: getInitials handles empty string + single-word + multi-word (test by inspection — function references parts.length === 0, parts.length === 1, parts.length > 1)
|
||||
- UserRow: avatar classes verbatim: `grep -F "h-8 w-8 rounded-full bg-muted flex items-center justify-center shrink-0 text-[10px] font-semibold text-foreground" components/mobile/EngagementUserRow.tsx`
|
||||
- UserRow: row container classes verbatim: `grep -F "block px-4 py-3 hover:bg-muted/50 transition-colors active:bg-muted/50" components/mobile/EngagementUserRow.tsx`
|
||||
- UserRow: hours bar track verbatim: `grep -F "h-1.5 rounded-full bg-muted overflow-hidden" components/mobile/EngagementUserRow.tsx`
|
||||
- UserRow: hours bar fill: `grep -F "h-full rounded-full bg-primary transition-all duration-300" components/mobile/EngagementUserRow.tsx`
|
||||
- UserRow: name uses `text-sm font-semibold truncate flex-1` (D-19): `grep -F "text-sm font-semibold truncate flex-1" components/mobile/EngagementUserRow.tsx`
|
||||
- UserRow: role line conditional + `text-xs text-muted-foreground truncate`: `grep -F "text-xs text-muted-foreground truncate" components/mobile/EngagementUserRow.tsx`
|
||||
- UserRow: hours value formatted with `.toFixed(1)`: `grep -E "\.toFixed\(1\)" components/mobile/EngagementUserRow.tsx`
|
||||
- UserRow: bar width bounded at 100%: `grep -E "Math\.min\(100" components/mobile/EngagementUserRow.tsx`
|
||||
- UserRow: avatar has `aria-hidden="true"`: `grep -E 'aria-hidden="true"' components/mobile/EngagementUserRow.tsx`
|
||||
- Skeleton: imports `Skeleton` from shadcn: `grep -q "from '@/components/ui/skeleton'" components/mobile/EngagementUserRowSkeleton.tsx`
|
||||
- Skeleton: includes `h-8 w-8 rounded-full` (avatar) + `h-4 w-32` (name) + `h-4 w-12` (hours) + `h-3 w-24 ml-11` (role) + `h-1.5 w-full mt-2` (bar): `grep -E "h-8 w-8 rounded-full|h-4 w-32|h-4 w-12|h-3 w-24 ml-11|h-1.5 w-full" components/mobile/EngagementUserRowSkeleton.tsx | wc -l` ≥ 5
|
||||
- `npx tsc --noEmit --pretty` exits 0
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Both files exist. `getInitials` is exported. Row markup verbatim from UI-SPEC. Skeleton mirrors row shape. Type-check passes. Phase 8 can import `getInitials` directly.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Component props → DOM | All seven components receive typed props from the page. No fetch calls, no localStorage access, no untrusted serialization. Display strings come from API data already validated by Plan 01. |
|
||||
| User input → onChange callbacks | EngagementSearchInput emits debounced strings; PeriodChips/SortChips emit typed enum values. The page (Plan 03) consumes these — no DB writes, no URL injection from this layer. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-07-07 | XSS / Tampering | EngagementUserRow display values | mitigate | All user-supplied strings (`displayName`, `jobTitle`) rendered via React JSX text interpolation — auto-escaped by React. No `dangerouslySetInnerHTML`, no innerHTML, no `eval`. The hours bar width style is computed from a clamped numeric (`Math.min(100, …)`), not user input. |
|
||||
| T-07-08 | XSS via SVG injection | EngagementHoursSparkline path d attribute | mitigate | Path string is built from numeric coordinates only (toFixed(2) on each); no user-supplied strings flow into the SVG path. The `viewBox` and stroke classes are hardcoded constants. |
|
||||
| T-07-09 | Information Disclosure | EngagementUserRow → Link href | accept | Link href contains the `graphUserId` (Azure AD object ID). This is the same URL surface Phase 8 builds; not considered sensitive (similar to `/mobile/tickets/[id]` exposing ticket ids). The destination page enforces auth via middleware. |
|
||||
| T-07-10 | DoS / Re-render storms | EngagementSearchInput debounce | mitigate | 300ms `setTimeout` debounce + cleanup on unmount + dependency-tracked effect. Prevents per-keystroke re-renders propagating to the parent's filter logic. Local input state remains responsive (no debounce on the field itself). |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- All 7 component files exist under `components/mobile/`
|
||||
- `npx tsc --noEmit --pretty` exits 0 with no errors in any new file
|
||||
- `getInitials` exported from `EngagementUserRow.tsx` (Phase 8 reuse)
|
||||
- All chip/row class strings present verbatim (per acceptance criteria grep checks)
|
||||
- Sparkline contains NO `recharts` import; uses inline SVG only
|
||||
- All components have `'use client'` directive at top
|
||||
- Plan 03 can import all 7 components without errors
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 7 component files written
|
||||
- `npx tsc --noEmit --pretty` exits 0
|
||||
- All UI-SPEC class strings present verbatim (period chip active/inactive, summary card big number, hours bar track/fill, avatar)
|
||||
- All UI-SPEC copy strings present verbatim ("Search by name or email", "Hours trend · last", "No activity", "7 days"/"30 days"/"90 days", chip labels)
|
||||
- `getInitials` is a named export from `EngagementUserRow.tsx`
|
||||
- No fetches, no toasts, no router calls, no useEffect data loaders inside any component (orchestration is owned by Plan 03)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/07-engagement-overview-new/07-02-SUMMARY.md` documenting:
|
||||
- 7 components and their public exports
|
||||
- The exported `getInitials` utility (referenced by Phase 8)
|
||||
- Confirmation that no recharts is used
|
||||
- Class strings copied verbatim from UI-SPEC (D-29 typography count maintained: text-sm, text-xs, text-[10px], text-2xl)
|
||||
</output>
|
||||
703
.planning/phases/07-engagement-overview-new/07-03-PLAN.md
Normal file
703
.planning/phases/07-engagement-overview-new/07-03-PLAN.md
Normal file
|
|
@ -0,0 +1,703 @@
|
|||
---
|
||||
phase: 07-engagement-overview-new
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on:
|
||||
- 07-01
|
||||
- 07-02
|
||||
files_modified:
|
||||
- app/mobile/engagement/page.tsx
|
||||
autonomous: true
|
||||
requirements:
|
||||
- ENG-01
|
||||
- ENG-02
|
||||
- ENG-03
|
||||
- ENG-04
|
||||
- ENG-05
|
||||
- ENG-09
|
||||
must_haves:
|
||||
truths:
|
||||
- "Tapping the Engagement entry in the More drawer routes to /mobile/engagement and the page renders inside the Phase 2 mobile shell"
|
||||
- "Page shows H1 'Engagement' (text-sm font-semibold) above a sticky 3-chip period selector (7d / 30d / 90d, default 30d active)"
|
||||
- "Page renders 4 summary cards stacked single-column: Active users, Total Graph hours, Total Autotask hours, Hours / active user"
|
||||
- "Page renders one compact hours-trend sparkline card scoped to the selected period at the top of the user list"
|
||||
- "Page renders sort chips (Hours / Name / Utilization) and a debounced search input above the per-employee list"
|
||||
- "Per-employee list shows stacked rows (avatar+name+role+hours+hours-bar); tapping a row navigates to /mobile/engagement/[graphUserId]"
|
||||
- "Changing the period chip refetches summary, trend, and users (page resets to 1)"
|
||||
- "Changing the sort chip refetches the users list (page resets to 1)"
|
||||
- "Search input debounces 300ms and filters the loaded user set client-side; 'No matches' inline state with Clear search button when the filter zeros out"
|
||||
- "Infinite scroll: IntersectionObserver on a sentinel triggers ?page=N+1 when last row enters viewport; 'Load more' fallback button is also present"
|
||||
- "Initial load shows 4 summary card skeletons + 1 sparkline skeleton + 5 user-row skeletons"
|
||||
- "Empty state ('No engagement data for this period') renders when summary.activeUsers === 0 AND users.length === 0; period chips remain interactive"
|
||||
- "When summary.configured === false, a 'Engagement sync not configured' banner replaces the data sections"
|
||||
- "Fetch errors fire toast.error per failure; Load more button label flips to 'Retry'"
|
||||
- "BottomNav.tsx is NOT modified (Engagement is reachable from More drawer only — ENG-09)"
|
||||
- "MoreDrawer.tsx is NOT modified (already routes to /mobile/engagement per Phase 2 DRAWER-03)"
|
||||
artifacts:
|
||||
- path: "app/mobile/engagement/page.tsx"
|
||||
provides: "Mobile engagement overview page (real refactor, not desktop adaptation)"
|
||||
exports: ["default function MobileEngagementPage"]
|
||||
min_lines: 200
|
||||
key_links:
|
||||
- from: "app/mobile/engagement/page.tsx"
|
||||
to: "/api/mobile/engagement/summary"
|
||||
via: "fetch in useEffect"
|
||||
pattern: "/api/mobile/engagement/summary"
|
||||
- from: "app/mobile/engagement/page.tsx"
|
||||
to: "/api/mobile/engagement/trend"
|
||||
via: "fetch in useEffect"
|
||||
pattern: "/api/mobile/engagement/trend"
|
||||
- from: "app/mobile/engagement/page.tsx"
|
||||
to: "/api/engagement/users"
|
||||
via: "fetch with period+sort+page params (D-16, D-17)"
|
||||
pattern: "/api/engagement/users"
|
||||
- from: "app/mobile/engagement/page.tsx"
|
||||
to: "components/mobile/Engagement* components"
|
||||
via: "named imports from @/components/mobile/Engagement*"
|
||||
pattern: "from '@/components/mobile/Engagement"
|
||||
- from: "app/mobile/engagement/page.tsx"
|
||||
to: "MobileEngagementSummary type"
|
||||
via: "import type from '@/app/api/mobile/engagement/summary/route'"
|
||||
pattern: "import type.*MobileEngagementSummary"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the mobile Engagement overview page that orchestrates Plan 01's endpoints + Plan 02's
|
||||
components into the shipping screen. This is the page the manager actually uses: H1 +
|
||||
sticky period chips + 4 stacked summary cards + 1 compact sparkline + sort chips + search +
|
||||
the per-employee list with infinite scroll + empty/configured banners + error toasts.
|
||||
|
||||
Per CONTEXT.md ENG-01, this is a "real refactor, not a thin adaptation of the ~1300-line
|
||||
desktop page." Build phone-first against the data sources, do NOT port desktop
|
||||
`app/engagement/page.tsx` (D-35: untouched).
|
||||
|
||||
Per ENG-09, Engagement is NOT on the bottom nav — it's reached from the More drawer
|
||||
which Phase 2 already wired (D-01, D-02).
|
||||
|
||||
Purpose: Wire all the pieces into the shipping page. Match Phase 4 / Phase 6 mobile-page
|
||||
structure (`'use client'`, `useState` + `useEffect` + `fetch`, IntersectionObserver, sonner
|
||||
toasts on error, no SWR/react-query per CLAUDE.md + D-37, no Zod per D-38).
|
||||
|
||||
Output: `app/mobile/engagement/page.tsx` — single new file.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/REQUIREMENTS.md
|
||||
@.planning/phases/07-engagement-overview-new/07-CONTEXT.md
|
||||
@.planning/phases/07-engagement-overview-new/07-UI-SPEC.md
|
||||
@CLAUDE.md
|
||||
@DESIGN.md
|
||||
|
||||
# Plan 01 endpoints (Wave 1) — types imported from these
|
||||
@app/api/mobile/engagement/summary/route.ts
|
||||
@app/api/mobile/engagement/trend/route.ts
|
||||
|
||||
# Existing endpoint reused as-is (D-16, D-34 — DO NOT MODIFY)
|
||||
@app/api/engagement/users/route.ts
|
||||
|
||||
# Plan 02 components (Wave 2) — imported by name
|
||||
@components/mobile/EngagementPeriodChips.tsx
|
||||
@components/mobile/EngagementSummaryCard.tsx
|
||||
@components/mobile/EngagementHoursSparkline.tsx
|
||||
@components/mobile/EngagementSortChips.tsx
|
||||
@components/mobile/EngagementSearchInput.tsx
|
||||
@components/mobile/EngagementUserRow.tsx
|
||||
@components/mobile/EngagementUserRowSkeleton.tsx
|
||||
|
||||
# Phase shell (DO NOT MODIFY) — verify Engagement reachability after page lands
|
||||
@app/mobile/layout.tsx
|
||||
@components/mobile/BottomNav.tsx
|
||||
@components/mobile/MoreDrawer.tsx
|
||||
|
||||
# Pattern references (page-level orchestration)
|
||||
@app/mobile/analyzer/page.tsx
|
||||
@app/mobile/tickets/page.tsx
|
||||
|
||||
<interfaces>
|
||||
<!-- Imports the page makes (executors should use these directly): -->
|
||||
|
||||
```ts
|
||||
import type { MobileEngagementSummary } from '@/app/api/mobile/engagement/summary/route';
|
||||
import type { SparklinePoint, EngagementTrendResponse } from '@/app/api/mobile/engagement/trend/route';
|
||||
import type { EngagementPeriod } from '@/components/mobile/EngagementPeriodChips';
|
||||
import type { EngagementSortKey } from '@/components/mobile/EngagementSortChips';
|
||||
import { EngagementPeriodChips } from '@/components/mobile/EngagementPeriodChips';
|
||||
import { EngagementSummaryCard } from '@/components/mobile/EngagementSummaryCard';
|
||||
import { EngagementHoursSparkline } from '@/components/mobile/EngagementHoursSparkline';
|
||||
import { EngagementSortChips } from '@/components/mobile/EngagementSortChips';
|
||||
import { EngagementSearchInput } from '@/components/mobile/EngagementSearchInput';
|
||||
import { EngagementUserRow } from '@/components/mobile/EngagementUserRow';
|
||||
import { EngagementUserRowSkeleton } from '@/components/mobile/EngagementUserRowSkeleton';
|
||||
```
|
||||
|
||||
<!-- /api/engagement/users response shape (existing endpoint — DO NOT modify; inline the consumed shape per UI-SPEC API Shape Contract): -->
|
||||
|
||||
```ts
|
||||
interface EngagementUserApiRow {
|
||||
graphUserId: string;
|
||||
displayName: string;
|
||||
email: string; // existing endpoint returns 'email', not 'userEmail'
|
||||
jobTitle: string | null;
|
||||
billableHours: number;
|
||||
hoursWorked: number;
|
||||
// ...other fields the page does NOT consume
|
||||
}
|
||||
|
||||
interface EngagementUsersResponse {
|
||||
users: EngagementUserApiRow[];
|
||||
pagination: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number; // present when total > 0; existing endpoint returns this
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
<!-- Sort key → API param mapping (D-20): -->
|
||||
|
||||
```ts
|
||||
const SORT_TO_API: Record<EngagementSortKey, { sort: string; order: 'asc' | 'desc' }> = {
|
||||
Hours: { sort: 'billable_hours', order: 'desc' },
|
||||
Name: { sort: 'display_name', order: 'asc' },
|
||||
Utilization: { sort: 'billable_hours', order: 'desc' },
|
||||
};
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 1: Create app/mobile/engagement/page.tsx (orchestrates Plan 01 endpoints + Plan 02 components)</name>
|
||||
<read_first>
|
||||
- .planning/phases/07-engagement-overview-new/07-UI-SPEC.md (full file — Page Layout Order section is the spec; copy strings, accessibility, error toast labels)
|
||||
- .planning/phases/07-engagement-overview-new/07-CONTEXT.md (D-01, D-03, D-16, D-17, D-18, D-21, D-22, D-23, D-24, D-25, D-26, D-27, D-30, D-31, D-37)
|
||||
- app/mobile/analyzer/page.tsx (closest pattern reference — IntersectionObserver loop, error/Retry, skeleton render block, useCallback fetch, toast.error)
|
||||
- app/mobile/tickets/page.tsx lines 1-100 (filter + URL pattern reference; URL syncing NOT used in this phase per D-21 note)
|
||||
- app/api/engagement/users/route.ts (existing route — confirm response shape `users[]` + `pagination.{page,pageSize,total,totalPages}`)
|
||||
- components/mobile/BottomNav.tsx (verify no Engagement entry — must NOT be modified per D-02 / ENG-09)
|
||||
- components/mobile/MoreDrawer.tsx (verify Engagement entry exists — DO NOT modify per D-01)
|
||||
</read_first>
|
||||
<files>app/mobile/engagement/page.tsx</files>
|
||||
<action>
|
||||
Create new file `app/mobile/engagement/page.tsx`. Use the EXACT structure below — every section maps to a UI-SPEC layout block. NO router pushes for state (per D-21 note: period/sort/search are component state only, scale doesn't warrant deep-linking). NO SWR / react-query (D-37 / CLAUDE.md). NO Zod (D-38).
|
||||
|
||||
**File header:**
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
/* MobileEngagementPage — phase 07 (ENG-01..05, ENG-09).
|
||||
* Purpose: Phone-first refactor of /engagement — period chips + 4 stacked summary cards +
|
||||
* compact sparkline + sortable/searchable per-employee list with infinite scroll.
|
||||
* Real refactor, not a thin adaptation of the ~1300-line desktop page (ENG-01).
|
||||
* Reachable from More drawer only — Engagement is NOT on the bottom nav (ENG-09).
|
||||
* Per D-01..D-31. */
|
||||
|
||||
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { Loader2, Users } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { MobileEngagementSummary } from '@/app/api/mobile/engagement/summary/route';
|
||||
import type { SparklinePoint, EngagementTrendResponse } from '@/app/api/mobile/engagement/trend/route';
|
||||
import { EngagementPeriodChips, type EngagementPeriod } from '@/components/mobile/EngagementPeriodChips';
|
||||
import { EngagementSummaryCard } from '@/components/mobile/EngagementSummaryCard';
|
||||
import { EngagementHoursSparkline } from '@/components/mobile/EngagementHoursSparkline';
|
||||
import { EngagementSortChips, type EngagementSortKey } from '@/components/mobile/EngagementSortChips';
|
||||
import { EngagementSearchInput } from '@/components/mobile/EngagementSearchInput';
|
||||
import { EngagementUserRow } from '@/components/mobile/EngagementUserRow';
|
||||
import { EngagementUserRowSkeleton } from '@/components/mobile/EngagementUserRowSkeleton';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
```
|
||||
|
||||
**Inline types (existing /api/engagement/users response — D-16):**
|
||||
|
||||
```tsx
|
||||
interface EngagementUserApiRow {
|
||||
graphUserId: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
jobTitle: string | null;
|
||||
billableHours: number;
|
||||
hoursWorked: number;
|
||||
}
|
||||
|
||||
interface EngagementUsersResponse {
|
||||
users: EngagementUserApiRow[];
|
||||
pagination: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Sort key → API param mapping (D-20)
|
||||
const SORT_TO_API: Record<EngagementSortKey, { sort: string; order: 'asc' | 'desc' }> = {
|
||||
Hours: { sort: 'billable_hours', order: 'desc' },
|
||||
Name: { sort: 'display_name', order: 'asc' },
|
||||
Utilization: { sort: 'billable_hours', order: 'desc' },
|
||||
};
|
||||
|
||||
const SUMMARY_LABELS = {
|
||||
activeUsers: 'Active users',
|
||||
totalGraphHours: 'Total Graph hours',
|
||||
totalAutotaskHours: 'Total Autotask hours',
|
||||
hoursPerActiveUser: 'Hours / active user',
|
||||
} as const;
|
||||
```
|
||||
|
||||
**Component body (state + fetches + render):**
|
||||
|
||||
```tsx
|
||||
export default function MobileEngagementPage() {
|
||||
// ── State ─────────────────────────────────────────────────────────────
|
||||
const [period, setPeriod] = useState<EngagementPeriod>('D30'); // D-04 default
|
||||
const [sortKey, setSortKey] = useState<EngagementSortKey>('Hours'); // D-20 default
|
||||
const [searchQuery, setSearchQuery] = useState<string>(''); // D-21
|
||||
|
||||
const [summary, setSummary] = useState<MobileEngagementSummary | null>(null);
|
||||
const [summaryLoading, setSummaryLoading] = useState<boolean>(true);
|
||||
|
||||
const [trendPoints, setTrendPoints] = useState<SparklinePoint[]>([]);
|
||||
const [trendLoading, setTrendLoading] = useState<boolean>(true);
|
||||
|
||||
const [users, setUsers] = useState<EngagementUserApiRow[]>([]);
|
||||
const [usersLoading, setUsersLoading] = useState<boolean>(true);
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const [hasMore, setHasMore] = useState<boolean>(false);
|
||||
const [loadingMore, setLoadingMore] = useState<boolean>(false);
|
||||
const [loadMoreError, setLoadMoreError] = useState<boolean>(false);
|
||||
|
||||
// ── Fetchers ──────────────────────────────────────────────────────────
|
||||
const loadSummary = useCallback(async (p: EngagementPeriod) => {
|
||||
setSummaryLoading(true);
|
||||
try {
|
||||
const r = await fetch(`/api/mobile/engagement/summary?period=${p}`);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const data: MobileEngagementSummary = await r.json();
|
||||
setSummary(data);
|
||||
} catch (e) {
|
||||
console.error('Engagement summary fetch failed:', e);
|
||||
toast.error('Failed to load engagement summary'); // D-25
|
||||
} finally {
|
||||
setSummaryLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadTrend = useCallback(async (p: EngagementPeriod) => {
|
||||
setTrendLoading(true);
|
||||
try {
|
||||
const r = await fetch(`/api/mobile/engagement/trend?period=${p}`);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const data: EngagementTrendResponse = await r.json();
|
||||
setTrendPoints(data.points);
|
||||
} catch (e) {
|
||||
console.error('Engagement trend fetch failed:', e);
|
||||
toast.error('Failed to load hours trend'); // D-25
|
||||
} finally {
|
||||
setTrendLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadUsersPage1 = useCallback(async (p: EngagementPeriod, sk: EngagementSortKey) => {
|
||||
setUsersLoading(true);
|
||||
setLoadMoreError(false);
|
||||
try {
|
||||
const { sort, order } = SORT_TO_API[sk];
|
||||
const sp = new URLSearchParams({ period: p, sort, order, page: '1' });
|
||||
const r = await fetch(`/api/engagement/users?${sp.toString()}`);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const data: EngagementUsersResponse = await r.json();
|
||||
setUsers(data.users);
|
||||
setCurrentPage(1);
|
||||
const totalPages = data.pagination.totalPages
|
||||
?? Math.ceil(data.pagination.total / data.pagination.pageSize);
|
||||
setHasMore(1 < totalPages);
|
||||
} catch (e) {
|
||||
console.error('Engagement users fetch failed:', e);
|
||||
toast.error('Failed to load engagement users'); // D-25
|
||||
} finally {
|
||||
setUsersLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadMoreUsers = useCallback(async () => {
|
||||
if (loadingMore || !hasMore) return;
|
||||
setLoadingMore(true);
|
||||
setLoadMoreError(false);
|
||||
try {
|
||||
const next = currentPage + 1;
|
||||
const { sort, order } = SORT_TO_API[sortKey];
|
||||
const sp = new URLSearchParams({ period, sort, order, page: String(next) });
|
||||
const r = await fetch(`/api/engagement/users?${sp.toString()}`);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const data: EngagementUsersResponse = await r.json();
|
||||
setUsers(prev => [...prev, ...data.users]);
|
||||
setCurrentPage(next);
|
||||
const totalPages = data.pagination.totalPages
|
||||
?? Math.ceil(data.pagination.total / data.pagination.pageSize);
|
||||
setHasMore(next < totalPages);
|
||||
} catch (e) {
|
||||
console.error('Engagement users load-more fetch failed:', e);
|
||||
toast.error('Failed to load more team members'); // D-25
|
||||
setLoadMoreError(true);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [loadingMore, hasMore, currentPage, sortKey, period]);
|
||||
|
||||
// ── Effects ───────────────────────────────────────────────────────────
|
||||
|
||||
// Period change: refetch all three (summary + trend + users page 1) — D-04
|
||||
useEffect(() => {
|
||||
void loadSummary(period);
|
||||
void loadTrend(period);
|
||||
void loadUsersPage1(period, sortKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [period]);
|
||||
|
||||
// Sort change: refetch users only (D-20: summary/trend are period-scoped, not sort-scoped)
|
||||
useEffect(() => {
|
||||
void loadUsersPage1(period, sortKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sortKey]);
|
||||
|
||||
// IntersectionObserver — D-18 (mirrors Phase 4/6 pattern, rootMargin '200px')
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
const node = sentinelRef.current;
|
||||
if (!node) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && hasMore && !loadingMore && !usersLoading) {
|
||||
void loadMoreUsers();
|
||||
}
|
||||
},
|
||||
{ rootMargin: '200px' },
|
||||
);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, loadingMore, usersLoading, loadMoreUsers]);
|
||||
|
||||
// ── Derived data ──────────────────────────────────────────────────────
|
||||
|
||||
// Client-side search filter (D-21): applies on displayName + email of loaded users
|
||||
const filteredUsers = useMemo(() => {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
if (!q) return users;
|
||||
return users.filter(u =>
|
||||
u.displayName.toLowerCase().includes(q) || u.email.toLowerCase().includes(q),
|
||||
);
|
||||
}, [users, searchQuery]);
|
||||
|
||||
// maxHours for hours bar normalization (UI-SPEC: largest billableHours in current page set)
|
||||
const maxHours = useMemo(() => {
|
||||
return filteredUsers.reduce((m, u) => Math.max(m, u.billableHours), 0);
|
||||
}, [filteredUsers]);
|
||||
|
||||
// Empty-state predicate (D-26): both summary.activeUsers AND users.length === 0
|
||||
const showEmptyState = !summaryLoading && !usersLoading
|
||||
&& summary !== null && summary.activeUsers === 0 && users.length === 0;
|
||||
|
||||
// Not-configured banner predicate (D-27)
|
||||
const showNotConfiguredBanner = !summaryLoading && summary !== null && summary.configured === false;
|
||||
|
||||
// Summary card values (formatted strings — Plan 02's SummaryCard accepts pre-formatted)
|
||||
const summaryCard1 = summary ? String(summary.activeUsers) : '0';
|
||||
const summaryCard2 = summary ? `${summary.totalGraphHours.toFixed(1)}h` : '0.0h';
|
||||
const summaryCard3 = summary ? `${summary.totalAutotaskHours.toFixed(1)}h` : '0.0h';
|
||||
const summaryCard4 = summary
|
||||
? (summary.activeUsers === 0 ? '—' : `${summary.hoursPerActiveUser.toFixed(1)}h`) // D-08
|
||||
: '—';
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4 space-y-4"> {/* D-30 */}
|
||||
{/* H1 — D-31, scrolls away under the sticky chips */}
|
||||
<h1 className="text-sm font-semibold">Engagement</h1> {/* D-29 */}
|
||||
|
||||
{/* Sticky period chips — D-05 */}
|
||||
<EngagementPeriodChips period={period} onPeriodChange={setPeriod} />
|
||||
|
||||
{showNotConfiguredBanner ? (
|
||||
// Not-configured banner (D-27) — replaces all data sections
|
||||
<div className="rounded-xl border bg-card px-4 py-4 space-y-1">
|
||||
<p className="text-sm font-semibold">Engagement sync not configured</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Set MSGRAPH_* environment variables and restart.
|
||||
<a
|
||||
href="/admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Open Admin on desktop"
|
||||
className="underline ml-1"
|
||||
>
|
||||
Open Admin
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Section 3 — Summary cards (D-08, D-23) */}
|
||||
{summaryLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i} className="py-0 shadow-none">
|
||||
<CardContent className="px-4 py-4 space-y-2">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<Skeleton className="h-3 w-28" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<EngagementSummaryCard value={summaryCard1} label={SUMMARY_LABELS.activeUsers} />
|
||||
<EngagementSummaryCard value={summaryCard2} label={SUMMARY_LABELS.totalGraphHours} />
|
||||
<EngagementSummaryCard value={summaryCard3} label={SUMMARY_LABELS.totalAutotaskHours} />
|
||||
<EngagementSummaryCard value={summaryCard4} label={SUMMARY_LABELS.hoursPerActiveUser} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section 4 — Sparkline (D-11..D-15, D-23) */}
|
||||
{trendLoading ? (
|
||||
<Card className="py-0 shadow-none">
|
||||
<CardContent className="px-4 py-3 space-y-2">
|
||||
<div className="flex justify-between gap-2">
|
||||
<Skeleton className="h-3 w-32" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
<Skeleton className="h-12 w-full mt-2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<EngagementHoursSparkline points={trendPoints} period={period} />
|
||||
)}
|
||||
|
||||
{/* Section 5 — Sort + search (D-20, D-21) */}
|
||||
<div className="space-y-2">
|
||||
<EngagementSortChips activeSort={sortKey} onSortChange={setSortKey} />
|
||||
<EngagementSearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
</div>
|
||||
|
||||
{/* Section 6 — User list (D-19, D-22, D-23, D-26) */}
|
||||
{showEmptyState ? (
|
||||
// Empty state (D-26)
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center space-y-3">
|
||||
<Users className="h-8 w-8 text-muted-foreground/50" aria-hidden="true" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold">No engagement data for this period</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Try a different period or trigger a sync from
|
||||
<a
|
||||
href="/admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Open Admin on desktop"
|
||||
className="underline ml-1"
|
||||
>Admin</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : usersLoading ? (
|
||||
<div className="divide-y border rounded-xl overflow-hidden">
|
||||
{Array.from({ length: 5 }).map((_, i) => <EngagementUserRowSkeleton key={i} />)}
|
||||
</div>
|
||||
) : filteredUsers.length === 0 && searchQuery.trim() !== '' ? (
|
||||
// No-matches inline state (D-22) — preserves the list border + rounding
|
||||
<div className="border rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-6 text-center space-y-2">
|
||||
<p className="text-sm text-muted-foreground">{`No matches for "${searchQuery}"`}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="text-xs font-semibold text-primary underline"
|
||||
>
|
||||
Clear search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="divide-y border rounded-xl overflow-hidden">
|
||||
{filteredUsers.map(u => (
|
||||
<EngagementUserRow
|
||||
key={u.graphUserId}
|
||||
user={{
|
||||
graphUserId: u.graphUserId,
|
||||
displayName: u.displayName,
|
||||
userEmail: u.email,
|
||||
jobTitle: u.jobTitle,
|
||||
billableHours: u.billableHours,
|
||||
hoursWorked: u.hoursWorked,
|
||||
}}
|
||||
maxHours={maxHours}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Sentinel — D-18 */}
|
||||
<div ref={sentinelRef} aria-hidden="true" />
|
||||
|
||||
{/* Loading-more spinner — D-24 */}
|
||||
{loadingMore && (
|
||||
<div className="flex justify-center py-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more fallback button — D-18, D-25 */}
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadMoreUsers()}
|
||||
disabled={loadingMore}
|
||||
aria-label="Load more team members"
|
||||
className="w-full py-3 rounded-xl border text-sm font-semibold hover:bg-muted/50 transition-colors disabled:opacity-50 min-h-[44px]"
|
||||
>
|
||||
{loadMoreError ? 'Retry' : loadingMore ? 'Loading…' : 'Load more'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Critical orchestration notes:**
|
||||
|
||||
- **Period change** (D-04): triggers `loadSummary + loadTrend + loadUsersPage1` via the `useEffect([period])`.
|
||||
- **Sort change** (D-20): triggers `loadUsersPage1` only — summary/trend are period-scoped.
|
||||
- **Search change** (D-21): NEVER triggers a fetch — filter is purely client-side via `useMemo`.
|
||||
- **Initial mount**: the period effect runs with default `D30`, kicking off all three fetches simultaneously (no waterfall).
|
||||
- **No URL syncing** (per D-21 note in CONTEXT.md): period/sort/search live in component state. Mirrors the lower data scale relative to Tickets (Phase 4 deep-links because filters are diverse + URL-shareable; Engagement state is simpler).
|
||||
- **No router push, no useSearchParams**: Phase 4 uses URL-synced filters, Phase 7 does NOT.
|
||||
|
||||
**Per ENG-09 / D-02:** This page does NOT modify `BottomNav.tsx`. Engagement entry stays in More drawer (Phase 2 already wired DRAWER-03).
|
||||
|
||||
**Per D-01 / D-35:** This page does NOT modify `MoreDrawer.tsx` or any desktop `app/engagement/*` files.
|
||||
|
||||
**Per D-30 / UI-SPEC:** Page container is `px-4 py-4 space-y-4`. The H1 is `text-sm font-semibold` (D-29 — keeps font-size count at 4: `text-sm`, `text-xs`, `text-[10px]`, `text-2xl`).
|
||||
|
||||
**Per D-37 / D-38:** No SWR, no react-query, no Zod.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- File exists: `test -f app/mobile/engagement/page.tsx`
|
||||
- Has `'use client';` at top: `head -1 app/mobile/engagement/page.tsx | grep -q "'use client';"`
|
||||
- Imports all 7 Plan 02 components: `grep -E "EngagementPeriodChips|EngagementSummaryCard|EngagementHoursSparkline|EngagementSortChips|EngagementSearchInput|EngagementUserRow|EngagementUserRowSkeleton" app/mobile/engagement/page.tsx | wc -l` ≥ 7
|
||||
- Imports types from Plan 01 endpoints: `grep -F "from '@/app/api/mobile/engagement/summary/route'" app/mobile/engagement/page.tsx && grep -F "from '@/app/api/mobile/engagement/trend/route'" app/mobile/engagement/page.tsx`
|
||||
- Default export: `grep -E "export default function MobileEngagementPage" app/mobile/engagement/page.tsx`
|
||||
- Page H1 with copy "Engagement" + class `text-sm font-semibold`: `grep -F '<h1 className="text-sm font-semibold">Engagement</h1>' app/mobile/engagement/page.tsx`
|
||||
- Page container classes verbatim (D-30): `grep -F 'className="px-4 py-4 space-y-4"' app/mobile/engagement/page.tsx`
|
||||
- All 4 summary card labels present (D-08): `grep -F "Active users" app/mobile/engagement/page.tsx && grep -F "Total Graph hours" app/mobile/engagement/page.tsx && grep -F "Total Autotask hours" app/mobile/engagement/page.tsx && grep -F "Hours / active user" app/mobile/engagement/page.tsx`
|
||||
- All 4 toast.error labels (D-25): `grep -F "Failed to load engagement summary" app/mobile/engagement/page.tsx && grep -F "Failed to load hours trend" app/mobile/engagement/page.tsx && grep -F "Failed to load engagement users" app/mobile/engagement/page.tsx && grep -F "Failed to load more team members" app/mobile/engagement/page.tsx`
|
||||
- Empty state copy (D-26): `grep -F "No engagement data for this period" app/mobile/engagement/page.tsx`
|
||||
- Not-configured copy (D-27): `grep -F "Engagement sync not configured" app/mobile/engagement/page.tsx`
|
||||
- No-matches copy (D-22): `grep -F "No matches for" app/mobile/engagement/page.tsx && grep -F "Clear search" app/mobile/engagement/page.tsx`
|
||||
- Default period is D30 (D-04): `grep -E "useState<EngagementPeriod>\('D30'\)" app/mobile/engagement/page.tsx`
|
||||
- Default sort is Hours (D-20): `grep -E "useState<EngagementSortKey>\('Hours'\)" app/mobile/engagement/page.tsx`
|
||||
- SORT_TO_API mapping present and includes display_name asc + billable_hours desc: `grep -E "display_name.*asc|billable_hours.*desc" app/mobile/engagement/page.tsx | wc -l` ≥ 2
|
||||
- Fetches all 3 endpoints: `grep -F "/api/mobile/engagement/summary?period=" app/mobile/engagement/page.tsx && grep -F "/api/mobile/engagement/trend?period=" app/mobile/engagement/page.tsx && grep -F "/api/engagement/users?" app/mobile/engagement/page.tsx`
|
||||
- IntersectionObserver with rootMargin '200px' (D-18): `grep -F "rootMargin: '200px'" app/mobile/engagement/page.tsx`
|
||||
- Load more button + classes (D-18): `grep -F 'className="w-full py-3 rounded-xl border text-sm font-semibold hover:bg-muted/50 transition-colors disabled:opacity-50 min-h-[44px]"' app/mobile/engagement/page.tsx`
|
||||
- Load more aria-label "Load more team members": `grep -F 'aria-label="Load more team members"' app/mobile/engagement/page.tsx`
|
||||
- Retry / Loading… / Load more labels: `grep -E "'Retry'|'Loading…'|'Load more'" app/mobile/engagement/page.tsx | wc -l` ≥ 3
|
||||
- User list container has `divide-y border rounded-xl overflow-hidden`: `grep -F 'divide-y border rounded-xl overflow-hidden' app/mobile/engagement/page.tsx`
|
||||
- Card 4 zero-users renders "—" (D-08): `grep -E "activeUsers === 0.*'—'" app/mobile/engagement/page.tsx`
|
||||
- Hours formatted with .toFixed(1): `grep -E "toFixed\(1\)" app/mobile/engagement/page.tsx | wc -l` ≥ 3
|
||||
- Search filter is client-side useMemo (D-21): `grep -E "useMemo|filteredUsers" app/mobile/engagement/page.tsx`
|
||||
- NO Zod, NO SWR, NO react-query (D-37, D-38): `! grep -E "from 'zod'|from 'swr'|from '@tanstack/react-query'" app/mobile/engagement/page.tsx`
|
||||
- NO router push for state (D-21 note): `! grep -E "router\.push.*setPeriod|router\.push.*sort" app/mobile/engagement/page.tsx`
|
||||
- NO useSearchParams (period/sort/search are component state only): `! grep -E "useSearchParams|useRouter" app/mobile/engagement/page.tsx`
|
||||
- BottomNav.tsx is NOT modified (Engagement is NOT a tab — ENG-09): `! grep -F "Engagement" components/mobile/BottomNav.tsx` (confirms current state preserved)
|
||||
- MoreDrawer.tsx still routes to /mobile/engagement (D-01): `grep -F "/mobile/engagement" components/mobile/MoreDrawer.tsx`
|
||||
- File length ≥ 200 lines (orchestration + render block is substantive): `wc -l < app/mobile/engagement/page.tsx | awk '{ if ($1 < 200) exit 1; else exit 0 }'`
|
||||
- `npx tsc --noEmit --pretty` exits 0
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Page file written. Type-check passes. Manually visiting `/mobile/engagement` while logged in shows H1 + sticky chips + 4 stacked summary cards + sparkline + sort/search + user rows. Period chip change refetches all three datasets. Sort chip change refetches users. Search debounces 300ms and filters in place. Empty/error/not-configured states render per UI-SPEC. BottomNav and MoreDrawer remain unchanged. No type errors anywhere in the project.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Authenticated browser session → /api/mobile/engagement/* | Page issues GET fetches with cookie-based session; new endpoints already gated by `requireAuth()` (Plan 01) |
|
||||
| Authenticated browser session → /api/engagement/users (existing) | Reused as-is per D-16; this endpoint does NOT call `requireAuth()` (D-33 inherited risk) — middleware.ts is the only auth gate |
|
||||
| User input (search query) → DOM | Rendered via React JSX text interpolation (auto-escaped); no DB writes, no URL injection |
|
||||
| Page state → Link href | `graphUserId` flows into `next/link` href; not user-controlled (comes from API response) |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-07-11 | Spoofing/AuthN | new mobile endpoints | mitigate | Plan 01's two new endpoints call `requireAuth()` first. middleware.ts also blocks unauth access to `/api/*` not in the public list. Verify with curl: GET /api/mobile/engagement/summary unauthenticated returns 401 / redirect. |
|
||||
| T-07-12 | Information Disclosure (inherited) | reuse of `/api/engagement/users` (D-16, D-33) | accept | Existing desktop endpoint lacks `requireAuth()` (D-33). Reuse from this mobile page does NOT introduce new exposure: middleware.ts already requires session for `/api/*` (the endpoint is not in the public list per CLAUDE.md). Out of scope to fix per D-34 + PROJECT.md ("Restyling or replacing the desktop pages…"). Mirrors Phase 6 IDOR T-06P03-02 disposition pattern. **Document as a STATE.md follow-up; recommend a future security phase for `/api/engagement/*` `requireAuth()` retrofit.** |
|
||||
| T-07-13 | XSS / DOM injection | search query, user displayName/email/jobTitle, sparkline values | mitigate | All user-supplied strings rendered via React JSX text interpolation (auto-escaped). Search query embedded in copy via template literal (`No matches for "${searchQuery}"`) — React escapes the closing tags. No `dangerouslySetInnerHTML`, no innerHTML, no eval. |
|
||||
| T-07-14 | Tampering / Open redirect | "Open Admin" + "Admin" links in banner/empty-state | mitigate | Both links use `target="_blank"` + `rel="noopener noreferrer"` + `aria-label`. The href is a hardcoded relative path `/admin` (not user-controlled). |
|
||||
| T-07-15 | DoS / Re-render storm | period/sort change cancellation | accept | Period/sort change does not cancel inflight fetches — the latest setState wins because `useEffect` re-runs and React renders the latest state. A user thrashing chips fires multiple fetches but the result of the most recent setState is what renders. Acceptable for the data scale (≤50 staff, period in {7/30/90}). Documented as accept; revisit if perf measurements warrant `AbortController`. |
|
||||
| T-07-16 | DoS / Unbounded list | infinite scroll | mitigate | Page-based pagination with size 50 from existing endpoint; `hasMore` derived from `pagination.totalPages`. Sentinel triggers ONE page-advance request per intersection (guard: `if (loadingMore || !hasMore) return`). Most teams have ≤50 staff so users will see one page total. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `app/mobile/engagement/page.tsx` exists, `npx tsc --noEmit --pretty` exits 0
|
||||
- File is `'use client';`
|
||||
- Visiting `/mobile/engagement` (logged in) renders the full page in the Phase 2 shell
|
||||
- Period chip change refetches summary + trend + users (verify in browser network panel)
|
||||
- Sort chip change refetches users only (no summary/trend hits)
|
||||
- Search input is debounced 300ms and applies a client-side filter (verify by typing fast and watching no fetches fire)
|
||||
- Empty state: simulate by setting period=D7 with no data — both summary.activeUsers === 0 AND users === [] → empty card renders, period chips remain interactive
|
||||
- Not-configured: simulate by unsetting MSGRAPH env vars — banner replaces data sections
|
||||
- Error: kill the trend endpoint (e.g., 500 response) → toast.error fires + Load more flips to "Retry" if a users error
|
||||
- Linking: tapping a user row navigates to `/mobile/engagement/[graphUserId]` (Phase 8 destination — page may 404 in this phase, that's expected and validates the link wiring)
|
||||
- Verify Phase 2 reachability: open More drawer, tap "Engagement" → lands on `/mobile/engagement` (DRAWER-03 confirmation)
|
||||
- Verify ENG-09: Engagement is NOT in the bottom nav (`! grep -F "Engagement" components/mobile/BottomNav.tsx`)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- One new file: `app/mobile/engagement/page.tsx` (no other files modified)
|
||||
- `npx tsc --noEmit --pretty` exits 0
|
||||
- Page renders inside Phase 2 mobile shell at `/mobile/engagement`
|
||||
- All Plan 02 components consumed
|
||||
- All Plan 01 endpoints called with `period={D7|D30|D90}` query param
|
||||
- Existing `/api/engagement/users` reused as-is (no modifications to that file)
|
||||
- BottomNav.tsx and MoreDrawer.tsx unchanged
|
||||
- All UI-SPEC copy strings present verbatim
|
||||
- All UI-SPEC class strings on inline elements present verbatim
|
||||
- Loading / empty / not-configured / error / no-matches states all wired
|
||||
- Infinite scroll + Load more fallback both functional
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/07-engagement-overview-new/07-03-SUMMARY.md` documenting:
|
||||
- The single file created (`app/mobile/engagement/page.tsx`)
|
||||
- The orchestration model (3 fetches on mount/period change, 1 fetch on sort change, 0 fetches on search)
|
||||
- Confirmed reachability via More drawer (DRAWER-03)
|
||||
- Confirmed Engagement is NOT in BottomNav (ENG-09)
|
||||
- Inherited risk T-07-12 (IDOR on /api/engagement/users) flagged for STATE.md follow-up
|
||||
- File length and any deviations from UI-SPEC (none expected)
|
||||
</output>
|
||||
Loading…
Add table
Add a link
Reference in a new issue