From 77073bac977ab17b6d016b99df2bd43c0f82e6d4 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sun, 3 May 2026 17:53:50 -0400 Subject: [PATCH] docs(04): create phase plan --- .planning/ROADMAP.md | 7 +- .../phases/04-tickets-restyle/04-01-PLAN.md | 583 +++++++++++++++++ .../phases/04-tickets-restyle/04-02-PLAN.md | 590 ++++++++++++++++++ .../phases/04-tickets-restyle/04-03-PLAN.md | 218 +++++++ 4 files changed, 1396 insertions(+), 2 deletions(-) create mode 100644 .planning/phases/04-tickets-restyle/04-01-PLAN.md create mode 100644 .planning/phases/04-tickets-restyle/04-02-PLAN.md create mode 100644 .planning/phases/04-tickets-restyle/04-03-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index ef227ea..d742777 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -86,7 +86,10 @@ Decimal phases appear between their surrounding integers in numeric order. 3. Single-tapping a row navigates to `/mobile/tickets/[id]` 4. Scrolling to the bottom of the list automatically loads the next ~25 rows (no Next button); a "Load more" fallback button is also visible/focusable for accessibility 5. The detail page header uses the new shell styling (Wulf mark, breadcrumb back) while the body remains largely unchanged -**Plans**: TBD +**Plans**: 3 plans +- [ ] 04-01-PLAN.md — /api/mobile/tickets cursor rewrite + TicketFilterStrip + TicketRowSkeleton components (TICK-01, TICK-02, TICK-05) +- [ ] 04-02-PLAN.md — Replace app/mobile/tickets/page.tsx with URL-synced filters, priority-stripe rows, IntersectionObserver infinite scroll (TICK-01..TICK-06) +- [ ] 04-03-PLAN.md — Reskin in-page header of app/mobile/tickets/[id]/page.tsx (back chevron + breadcrumb + ExternalLink) (TICK-07) **UI hint**: yes ### Phase 5: Finance Restyle @@ -146,7 +149,7 @@ Phases execute in numeric order. Phase 2 unblocks Phases 3–7 (any order, paral | 1. PWA Scaffolding | 1/2 | Executing | - | | 2. Mobile Shell + More Drawer | 0/TBD | Not started | - | | 3. Dashboard Restyle | 0/2 | Not started | - | -| 4. Tickets Restyle | 0/TBD | Not started | - | +| 4. Tickets Restyle | 0/3 | Not started | - | | 5. Finance Restyle | 0/TBD | Not started | - | | 6. Analyzer Feed | 0/TBD | Not started | - | | 7. Engagement Overview | 0/TBD | Not started | - | diff --git a/.planning/phases/04-tickets-restyle/04-01-PLAN.md b/.planning/phases/04-tickets-restyle/04-01-PLAN.md new file mode 100644 index 0000000..a3ea316 --- /dev/null +++ b/.planning/phases/04-tickets-restyle/04-01-PLAN.md @@ -0,0 +1,583 @@ +--- +phase: 04 +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - app/api/mobile/tickets/route.ts + - components/mobile/TicketRowSkeleton.tsx + - components/mobile/TicketFilterStrip.tsx +autonomous: true +requirements: [TICK-01, TICK-02, TICK-05] +must_haves: + truths: + - "GET /api/mobile/tickets accepts a base64 cursor and returns { tickets, nextCursor, hasMore } shape" + - "API caps page size at 25 server-side regardless of caller's limit param" + - "Default status filter (no status param) returns Open + In Progress + Waiting tickets (status IN (1, 8, 7)) — matches the legacy t.status != 5 default" + - "TicketFilterStrip renders the search input, ticket count line, and Collapsible toggle row with the four filter controls when expanded" + - "TicketRowSkeleton renders five placeholder rows that visually match the priority-stripe ticket-row layout" + artifacts: + - path: "app/api/mobile/tickets/route.ts" + provides: "Cursor-paginated list endpoint exporting MobileTicket and MobileTicketListResponse interfaces" + exports: ["GET", "MobileTicket", "MobileTicketListResponse"] + contains: "nextCursor" + - path: "components/mobile/TicketFilterStrip.tsx" + provides: "Collapsible filter strip presentational component" + exports: ["TicketFilterStrip", "TicketFilterValue", "QueueOption"] + - path: "components/mobile/TicketRowSkeleton.tsx" + provides: "Skeleton placeholder row matching ticket row layout" + exports: ["TicketRowSkeleton"] + key_links: + - from: "app/api/mobile/tickets/route.ts" + to: "kiosk_settings table via getMobileCompanyFilter()" + via: "preserved helper, unchanged" + pattern: "getMobileCompanyFilter" + - from: "app/api/mobile/tickets/route.ts" + to: "tickets / companies / queues / resources tables" + via: "parameterized SQL, last_activity_date DESC NULLS LAST, id DESC tie-breaker" + pattern: "ORDER BY.*last_activity_date.*DESC.*id.*DESC" + - from: "components/mobile/TicketFilterStrip.tsx" + to: "components/ui/collapsible.tsx" + via: "shadcn primitive import" + pattern: "from ['\"]@/components/ui/collapsible['\"]" +--- + + +Reshape `/api/mobile/tickets` from page-based pagination (`?page=N&limit=30`) to opaque-cursor pagination (`?cursor=&limit=25`) returning a typed `{ tickets, nextCursor, hasMore }` envelope, AND ship the two new presentational components (`TicketFilterStrip`, `TicketRowSkeleton`) the page (Plan 04-02) will consume. + +Purpose: TICK-05 mandates cursor-based ~25/page infinite scroll; the page can't be wired without the new API shape. Co-locating the filter strip and skeleton component here keeps Plan 02 focused on wiring rather than building presentational primitives. Mirror the Phase 3 pattern of exporting TypeScript interfaces from the route file so Plan 02 can `import type` them directly. + +Output: +- Rewritten `app/api/mobile/tickets/route.ts` (cursor-based, exported interfaces, capped limit, default-status fallback) +- New `components/mobile/TicketFilterStrip.tsx` (Collapsible filter strip, search + status/priority/queue/mine controls) +- New `components/mobile/TicketRowSkeleton.tsx` (5-row skeleton shape matching priority-stripe row layout) + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/REQUIREMENTS.md +@.planning/phases/04-tickets-restyle/04-CONTEXT.md +@.planning/phases/04-tickets-restyle/04-UI-SPEC.md +@.planning/phases/03-dashboard-restyle/03-01-SUMMARY.md +@CLAUDE.md +@app/api/mobile/tickets/route.ts +@components/ui/collapsible.tsx + + + +From app/api/mobile/tickets/route.ts (lines 4-26): +```typescript +async function getMobileCompanyFilter(): Promise<{ join: string; condition: string }> { + // reads kiosk_settings: mobile_company_category_ids, mobile_excluded_company_ids + // returns { join, condition } where condition is a SQL fragment for tickets table alias `t` +} +``` + +From components/ui/collapsible.tsx: +```typescript +export { Collapsible, CollapsibleTrigger, CollapsibleContent } +// Wrappers around @radix-ui/react-collapsible +// Props: open, onOpenChange — control state externally for URL sync +``` + +From components/ui/select.tsx (shadcn): +```typescript +export { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } +``` + +From components/ui/switch.tsx (shadcn): +```typescript +export { Switch } // controlled via checked + onCheckedChange +``` + +From components/ui/skeleton.tsx (shadcn): +```typescript +export { Skeleton } // div with bg-muted animate-pulse rounded +``` + +From components/ui/input.tsx (shadcn): +```typescript +export { Input } +``` + +From components/ui/button.tsx (shadcn): +```typescript +export { Button } // accepts variant: 'default'|'ghost'|..., size: 'sm'|'default'|'lg' +``` + +Pattern from Phase 3 (`app/api/mobile/dashboard/route.ts`): +- `import postgresClient from '@/lib/services/postgres-client'` (default import) +- `import { requireAuth } from '@/lib/auth-utils'` then `const { session, error } = await requireAuth(); if (error) return error;` +- Single `Promise.all` of queries +- Manual snake_case → camelCase NOT done in Phase 3 dashboard (kept snake_case in JSON) — for tickets the existing route returns snake_case so we KEEP snake_case to avoid breaking the page contract + + + + + + + Task 1: Rewrite /api/mobile/tickets to cursor-paginated shape with exported interfaces + app/api/mobile/tickets/route.ts + + - app/api/mobile/tickets/route.ts (current 90-line implementation — preserve getMobileCompanyFilter verbatim) + - app/api/mobile/dashboard/route.ts (Phase 3 reference for requireAuth + default postgresClient import + interface export pattern) + - .planning/phases/04-tickets-restyle/04-CONTEXT.md decisions D-08 through D-11 (cursor model, page size 25) + - .planning/phases/04-tickets-restyle/04-UI-SPEC.md "API Shape Contract" section + + + Rewrite `app/api/mobile/tickets/route.ts` end-to-end. The new file: + + 1. **Imports** — keep `NextRequest`, `NextResponse` from `next/server`. Switch to default import: `import postgresClient from '@/lib/services/postgres-client'` (matches Phase 3 dashboard route convention). Add `import { requireAuth } from '@/lib/auth-utils'`. + + 2. **Preserve `getMobileCompanyFilter()` helper VERBATIM** — copy lines 4-26 of the current file unchanged. Do NOT regress the kiosk_settings lookup or the `c.company_category_id = 1` fallback. The function signature and body must be byte-identical to the current implementation. + + 3. **Export TypeScript interfaces** at the top of the module (mirrors Phase 3 03-01-SUMMARY.md pattern): + ```typescript + export interface MobileTicket { + id: number; + ticket_number: string; + title: string; + status: number; + priority: number; + create_date: string; + last_activity_date: string; + due_date_time: string | null; + queue_id: number; + queue_label: string; + company_name: string; + assigned_to: string; + } + + export interface MobileTicketListResponse { + tickets: MobileTicket[]; + nextCursor: string | null; + hasMore: boolean; + } + ``` + + 4. **Cursor encode/decode helpers** (inline, NOT exported — D-09): + ```typescript + interface CursorPayload { last_activity_date: string; id: number; } + function encodeCursor(p: CursorPayload): string { + return Buffer.from(JSON.stringify(p), 'utf8').toString('base64'); + } + function decodeCursor(raw: string | null): CursorPayload | null { + if (!raw) return null; + try { + const parsed = JSON.parse(Buffer.from(raw, 'base64').toString('utf8')); + if (typeof parsed?.last_activity_date === 'string' && typeof parsed?.id === 'number') { + return parsed as CursorPayload; + } + return null; + } catch { return null; } + } + ``` + + 5. **`GET` handler** — `export async function GET(request: NextRequest): Promise`: + - Auth gate first: `const { error: authError } = await requireAuth(); if (authError) return authError;` + - Parse query params: + - `q` (search, may be empty) + - `status` — comma-separated ints; if absent, default to `[1, 8, 7]` (Open + In Progress + Waiting per UI-SPEC "Default behavior when no URL params"); accept `''` as "no filter — explicit clear"; treat empty array same as default + - `priority` — comma-separated ints; if absent, no filter + - `queue` — single int; if absent, no filter + - `mine` — `'1'` means filter by current user; pull email from `session.user.email` then resolve to `resources.email = $X` + - `limit` — parseInt, clamped: `Math.min(25, Math.max(1, parseInt(searchParams.get('limit') ?? '25')))` + - `cursor` — decode via `decodeCursor()`; null if missing or malformed + - Build conditions array starting with `t.is_deleted = false`, the company-scope condition from `getMobileCompanyFilter()`, and the status filter (default-or-supplied list → `t.status = ANY($N::int[])`). + - Search: keep the existing pattern — `(t.title ILIKE $N OR t.ticket_number ILIKE $N OR c.company_name ILIKE $N)` with single `%search%` param. + - Cursor seek predicate (only if cursor decoded): `(t.last_activity_date, t.id) < ($N::timestamp, $M::int)` — this is the standard keyset pagination form for `ORDER BY last_activity_date DESC, id DESC`. + - Use `requireAuth()`'s session for `mine`: `params.push(session.user.email); conditions.push('LOWER(r.email) = LOWER($N))` — but ONLY if you have access to session here; since `requireAuth()` already returned `{ session, error }`, capture `session` from the call (`const { session, error: authError } = await requireAuth();`). + + SQL: + ```sql + SELECT t.id, t.ticket_number, t.title, t.status, t.priority, + t.create_date, t.last_activity_date, t.due_date_time, + t.queue_id, q.label AS queue_label, + c.company_name, + COALESCE(r.first_name || ' ' || r.last_name, '') AS assigned_to + FROM tickets t + INNER JOIN companies c ON c.id = t.company_id + LEFT JOIN queues q ON q.value = t.queue_id + LEFT JOIN resources r ON r.id = t.assigned_resource_id + WHERE + ORDER BY t.last_activity_date DESC NULLS LAST, t.id DESC + LIMIT + ``` + + Fetch `limit + 1` rows to detect `hasMore` without a second COUNT query. If `rows.length > limit`, slice to `limit` and `hasMore = true`; the next-cursor's `last_activity_date` and `id` come from the last kept row (`rows[limit - 1]`). + + Build response: + ```typescript + const tickets: MobileTicket[] = sliced.map(/* row → MobileTicket; preserve snake_case keys exactly as the interface declares */); + const nextCursor = hasMore ? encodeCursor({ last_activity_date: tickets[tickets.length - 1].last_activity_date, id: tickets[tickets.length - 1].id }) : null; + return NextResponse.json({ tickets, nextCursor, hasMore } satisfies MobileTicketListResponse); + ``` + + 6. **Error handling** — wrap the body in `try/catch`; on error log `console.error('GET /api/mobile/tickets failed:', error)` and return `NextResponse.json({ error: 'Failed to fetch tickets', message: error instanceof Error ? error.message : 'unknown' }, { status: 500 })` per CLAUDE.md API route convention. + + Anti-patterns (do NOT do): + - Do NOT add Zod validation here (CLAUDE.md: "No Zod validation in route handlers today"). + - Do NOT change the snake_case keys of MobileTicket (the original page used `ticket_number`, `last_activity_date`, etc. — Plan 02 expects these names). + - Do NOT remove `requireAuth()` once added — it's a security gate. Note: the legacy file did NOT have `requireAuth()`; this is intentional hardening per Phase 3 pattern. + - Do NOT touch `app/api/mobile/tickets/[id]/timeline/route.ts` — that's the detail endpoint, out of scope. + + Per D-09 the cursor encodes `{ last_activity_date, id }` exactly — do not rename to `lastActivityDate` (would break decode round-trip). + + + npx tsc --noEmit --pretty 2>&1 | grep -E "(app/api/mobile/tickets/route|components/mobile/TicketFilterStrip|components/mobile/TicketRowSkeleton)" || echo "OK: no type errors in target files" + + + - `grep -q "export interface MobileTicket" app/api/mobile/tickets/route.ts` (interface exported) + - `grep -q "export interface MobileTicketListResponse" app/api/mobile/tickets/route.ts` (envelope interface exported) + - `grep -q "nextCursor" app/api/mobile/tickets/route.ts` (cursor field present) + - `grep -q "hasMore" app/api/mobile/tickets/route.ts` (hasMore field present) + - `grep -q "getMobileCompanyFilter" app/api/mobile/tickets/route.ts` (helper preserved) + - `grep -q "requireAuth" app/api/mobile/tickets/route.ts` (auth gate added) + - `grep -qE "Math\.min\(25" app/api/mobile/tickets/route.ts` (limit cap of 25 — D-11) + - `grep -qE "ORDER BY.*last_activity_date.*DESC" app/api/mobile/tickets/route.ts` (keyset order) + - `grep -qE "t\.id DESC" app/api/mobile/tickets/route.ts` (tie-breaker on id — D-09) + - `! grep -q "OFFSET" app/api/mobile/tickets/route.ts` (no page-based offset remains) + - `! grep -q "?page=" app/api/mobile/tickets/route.ts` (no page param consumed) + - `grep -q "is_deleted" app/api/mobile/tickets/route.ts` (deleted filter preserved) + - `npx tsc --noEmit --pretty 2>&1` does not report errors for `app/api/mobile/tickets/route.ts` + + The route file compiles cleanly, exports `MobileTicket` and `MobileTicketListResponse`, returns `{ tickets, nextCursor, hasMore }`, caps limit at 25, applies the `[1, 8, 7]` default status filter when no `status` param is supplied, preserves `getMobileCompanyFilter()` verbatim, and gates with `requireAuth()`. No OFFSET-based pagination remains. The legacy `?page=N` shape is fully replaced. + + + + Task 2: Create TicketRowSkeleton and TicketFilterStrip presentational components + components/mobile/TicketRowSkeleton.tsx, components/mobile/TicketFilterStrip.tsx + + - .planning/phases/04-tickets-restyle/04-UI-SPEC.md "Filter Strip" and "Skeleton Loading State" sections + - .planning/phases/04-tickets-restyle/04-CONTEXT.md decisions D-01 through D-04 (filter strip), D-21 (skeleton) + - components/mobile/KpiCardMobile.tsx (Phase 3 component for the comment-block pattern reference) + - components/ui/collapsible.tsx (Collapsible API surface) + - components/ui/skeleton.tsx (Skeleton primitive) + + + Create two new files. Both use the Phase 3 mobile component comment-block convention: + ``` + /* ComponentName — phase 04 (TICK-NN). + * Purpose: one-line description. + * Props: ... */ + ``` + + --- + + **File 1: `components/mobile/TicketRowSkeleton.tsx`** (D-21) + + ```typescript + 'use client'; + + /* TicketRowSkeleton — phase 04 (TICK-05/D-21). + * Purpose: skeleton placeholder row that matches the priority-stripe ticket row layout + * for the initial-load state of /mobile/tickets. + * Props: none — purely presentational. */ + + import { Skeleton } from '@/components/ui/skeleton'; + + export function TicketRowSkeleton() { + return ( +
+ + +
+ + +
+
+ ); + } + ``` + + Match UI-SPEC §"Skeleton Loading State" exactly. The wrapping container in Plan 02 will render `Array.from({ length: 5 }).map((_, i) => )`. + + --- + + **File 2: `components/mobile/TicketFilterStrip.tsx`** (D-01..D-04) + + Headers and exports: + ```typescript + 'use client'; + + /* TicketFilterStrip — phase 04 (TICK-01, TICK-02). + * Purpose: sticky search input + Collapsible filter panel (status, priority, queue, mine) + * with controlled values; URL sync is the parent page's responsibility. + * Props: value, onChange, queueOptions, openTotal, isFiltered, onClearAll. */ + + import { useState } from 'react'; + import { Search, X, SlidersHorizontal, ChevronDown, ChevronUp } from 'lucide-react'; + import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; + import { Button } from '@/components/ui/button'; + import { Input } from '@/components/ui/input'; + import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, + } from '@/components/ui/select'; + import { Switch } from '@/components/ui/switch'; + + export interface QueueOption { + id: number; + label: string; + } + + export interface TicketFilterValue { + q: string; + status: number[]; // [] = no filter — caller treats default elsewhere + priority: number[]; // [] = no filter + queue: number | null; + mine: boolean; + } + + export interface TicketFilterStripProps { + value: TicketFilterValue; + onChange: (next: TicketFilterValue) => void; + queueOptions: QueueOption[]; + openTotal: number; + isFiltered: boolean; // true when ≥1 non-default filter is active (excludes default status) + onClearAll: () => void; + } + + const STATUS_OPTIONS: Array<{ id: number; label: string }> = [ + { id: 1, label: 'Open' }, + { id: 8, label: 'In Progress' }, + { id: 7, label: 'Waiting' }, + ]; + + const PRIORITY_OPTIONS: Array<{ id: number; label: string }> = [ + { id: 1, label: 'Critical' }, + { id: 2, label: 'High' }, + { id: 3, label: 'Medium' }, + { id: 4, label: 'Low' }, + ]; + + function chipClass(active: boolean): string { + const base = 'shrink-0 px-3 py-1 rounded-full text-xs font-semibold border transition-colors min-h-[32px]'; + return active + ? `${base} bg-primary text-primary-foreground border-primary` + : `${base} border-border hover:bg-muted/50`; + } + + function toggleInArray(arr: number[], id: number): number[] { + return arr.includes(id) ? arr.filter(x => x !== id) : [...arr, id]; + } + + export function TicketFilterStrip(props: TicketFilterStripProps) { + const { value, onChange, queueOptions, openTotal, isFiltered, onClearAll } = props; + const [open, setOpen] = useState(false); + + const activeCount = + (value.status.length > 0 && !(value.status.length === 3 && value.status.includes(1) && value.status.includes(7) && value.status.includes(8)) ? 1 : 0) + + (value.priority.length > 0 ? 1 : 0) + + (value.queue !== null ? 1 : 0) + + (value.mine ? 1 : 0); + + return ( +
+ {/* Search row — always visible (D-03) */} +
+
+ + {/* Toggle row — always visible (D-01) */} + +
+

{openTotal} open tickets

+ + + +
+ + + {/* Status (D-02) */} +
+

Status

+
+ {STATUS_OPTIONS.map((s) => ( + + ))} +
+
+ + {/* Priority (D-02) */} +
+

Priority

+
+ {PRIORITY_OPTIONS.map((p) => ( + + ))} +
+
+ + {/* Queue (D-02) */} +
+

Queue

+ +
+ + {/* Assigned to me (D-02) */} +
+ + onChange({ ...value, mine: checked })} + /> +
+ + {/* Clear all — only when isFiltered (D-04) */} + {isFiltered && ( + + )} +
+
+
+ ); + } + ``` + + Notes on behavior: + - The component is purely controlled (no internal filter state besides the open/closed Collapsible toggle). URL sync lives in the parent page. + - `activeCount` excludes the default `[1, 7, 8]` status set so the badge only counts user-selected modifications. The parent decides what "default" means and passes `isFiltered` accordingly. + - The `min-h-[32px]` on chips meets the touch-target guidance with `py-1` baseline; the entire chip area is tappable. (UI-SPEC notes 44px for primary controls; chips are secondary and use a relaxed target consistent with the existing `py-1 rounded-full` pattern.) + + Anti-patterns (do NOT do): + - Do NOT introduce SWR / react-query / Zustand (CLAUDE.md: "No additional state libraries"). + - Do NOT fetch queue options inside this component; the parent passes `queueOptions` (queue list comes from a future endpoint or from the existing tickets API; Plan 02 will decide). For Plan 01 we only define the prop contract. + - Do NOT render the priority dot — the row stripe replaces it (D-17). This component only handles filters; the row itself is built in Plan 02. +
+ + npx tsc --noEmit --pretty 2>&1 | grep -E "components/mobile/(TicketRowSkeleton|TicketFilterStrip)" || echo "OK: no type errors in new components" + + + - `test -f components/mobile/TicketRowSkeleton.tsx` (file exists) + - `test -f components/mobile/TicketFilterStrip.tsx` (file exists) + - `grep -q "export function TicketRowSkeleton" components/mobile/TicketRowSkeleton.tsx` (named export) + - `grep -q "export function TicketFilterStrip" components/mobile/TicketFilterStrip.tsx` (named export) + - `grep -q "export interface TicketFilterValue" components/mobile/TicketFilterStrip.tsx` (filter value interface exported) + - `grep -q "export interface QueueOption" components/mobile/TicketFilterStrip.tsx` (queue option interface exported) + - `grep -q "border-l-4 border-muted" components/mobile/TicketRowSkeleton.tsx` (4px stripe per UI-SPEC) + - `grep -q "from '@/components/ui/collapsible'" components/mobile/TicketFilterStrip.tsx` (uses Collapsible primitive — D-01) + - `grep -q "sticky top-0" components/mobile/TicketFilterStrip.tsx` (sticky positioning per UI-SPEC viewport contract) + - `grep -q "Clear all" components/mobile/TicketFilterStrip.tsx` (D-04 copy) + - `grep -qE "(Open|In Progress|Waiting)" components/mobile/TicketFilterStrip.tsx` (status options — D-02) + - `grep -qE "(Critical|High|Medium|Low)" components/mobile/TicketFilterStrip.tsx` (priority options — D-02) + - `grep -q "Assigned to me" components/mobile/TicketFilterStrip.tsx` (mine toggle label — D-02) + - `grep -q "phase 04" components/mobile/TicketRowSkeleton.tsx && grep -q "phase 04" components/mobile/TicketFilterStrip.tsx` (Phase 3 comment-block convention) + - `! grep -q "useSWR\|@tanstack/react-query\|zustand" components/mobile/TicketFilterStrip.tsx` (no forbidden state libraries) + - `npx tsc --noEmit --pretty 2>&1` does not report errors for either new file + + Both component files exist, type-check cleanly, expose the documented prop interfaces, follow the Phase 3 comment-block convention, and contain the exact UI-SPEC class strings for the priority skeleton stripe (`border-l-4 border-muted`) and the sticky filter strip container (`sticky top-0 bg-background z-10 border-b px-4 pt-4 pb-3 space-y-2`). Plan 02 can `import { TicketFilterStrip, type TicketFilterValue, type QueueOption } from '@/components/mobile/TicketFilterStrip'` and `import { TicketRowSkeleton } from '@/components/mobile/TicketRowSkeleton'` without further changes. +
+ +
+ + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| client → /api/mobile/tickets | Authenticated user supplies q/status/priority/queue/cursor — must be validated and parameterised | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-04-01 | Tampering | cursor query param | mitigate | `decodeCursor()` returns null on JSON parse failure, missing fields, or wrong types — falls back to "no cursor" rather than throwing or trusting parsed data; the cursor only affects ordering, never company scope. | +| T-04-02 | Information Disclosure | company-scope bypass via SQL injection | mitigate | All user input flows through parameterised queries via `postgresClient.query(sql, params)`; `getMobileCompanyFilter()` interpolates only ints filtered through `parseInt + isNaN` guards (existing helper, preserved verbatim). | +| T-04-03 | Information Disclosure | unauthenticated access to ticket list | mitigate | Add `await requireAuth()` at the top of GET — the legacy route lacked this, Phase 4 hardens it (matches Phase 3 dashboard route). | +| T-04-04 | Denial of Service | unbounded `limit` param | mitigate | Server-side cap: `Math.min(25, Math.max(1, parseInt(limit ?? '25')))` — caller cannot request more than 25 rows. | +| T-04-05 | Information Disclosure | `mine` filter using session.user.email | mitigate | Email comes from the verified session, never from the query string; SQL uses `LOWER(r.email) = LOWER($N)` parameterised. | +| T-04-06 | Spoofing | filter chips submit forged status/priority ids | accept | Status/priority are foreign keys to `tickets`; non-existent ids simply return zero rows. No data exfiltration risk; legacy route had the same model. | + + + +After both tasks complete, run: + +1. `npx tsc --noEmit --pretty` — must pass with no new errors in `app/api/mobile/tickets/route.ts`, `components/mobile/TicketRowSkeleton.tsx`, or `components/mobile/TicketFilterStrip.tsx`. +2. `grep -c "export interface" app/api/mobile/tickets/route.ts` — must be `>= 2` (MobileTicket + MobileTicketListResponse). +3. `grep -c "export function" components/mobile/TicketFilterStrip.tsx` — must be `>= 1`. +4. Hand-execute one curl for sanity (developer terminal): `curl -sS 'http://localhost:3100/api/mobile/tickets?limit=5' -b "" | jq '.tickets | length, .nextCursor, .hasMore'` — should return `5`, an opaque base64 string (or null if fewer than 5 tickets), and a boolean. NOTE: this is for the dev's smoke check; not part of the automated gate (it requires a live dev server). + + + +- `app/api/mobile/tickets/route.ts` rewritten with cursor pagination, `requireAuth()`, exported `MobileTicket` and `MobileTicketListResponse` interfaces, server-side limit cap of 25, default-status fallback `[1, 8, 7]`, and `getMobileCompanyFilter()` preserved verbatim. +- `components/mobile/TicketRowSkeleton.tsx` exists with the exact UI-SPEC skeleton shape (4px muted stripe + 3 skeleton lines + metadata row). +- `components/mobile/TicketFilterStrip.tsx` exists with the controlled prop contract, Collapsible-driven panel, all four filter controls, "Clear all" button, and active-filter count badge. +- TypeScript clean (`npx tsc --noEmit --pretty` passes for these files). +- No legacy `?page=` / `OFFSET` paths remain in the route. +- All TICK-01 (filter strip), TICK-02 (URL sync — interface contract ready for Plan 02), TICK-05 (cursor API) requirements substantially landed (the page wiring closes them in Plan 02). + + + +After completion, create `.planning/phases/04-tickets-restyle/04-01-SUMMARY.md` documenting: +- The exported interface signatures (so Plan 02 can `import type` directly). +- The cursor encoding format (`base64(JSON({ last_activity_date, id }))`). +- Default status filter when no `status` URL param is supplied (`[1, 8, 7]`). +- Server-side limit cap (25). +- That `getMobileCompanyFilter()` was preserved byte-identical. +- The two new component file paths and their exported names. + diff --git a/.planning/phases/04-tickets-restyle/04-02-PLAN.md b/.planning/phases/04-tickets-restyle/04-02-PLAN.md new file mode 100644 index 0000000..9a54c63 --- /dev/null +++ b/.planning/phases/04-tickets-restyle/04-02-PLAN.md @@ -0,0 +1,590 @@ +--- +phase: 04 +plan: 02 +type: execute +wave: 2 +depends_on: ["04-01"] +files_modified: + - app/mobile/tickets/page.tsx +autonomous: false +requirements: [TICK-01, TICK-02, TICK-03, TICK-04, TICK-05, TICK-06] +must_haves: + truths: + - "Opening /mobile/tickets renders the filter strip in collapsed state with the search input and Filters toggle visible" + - "Tapping the Filters toggle expands the Collapsible to show status, priority, queue, and Assigned-to-me controls" + - "Changing any filter updates the URL query string in place (router.replace) without adding history entries" + - "Reloading the page with ?status=1&priority=2&queue=15&mine=1&q=foo hydrates filter state from those params" + - "Each list row has a 4px-wide left-edge color stripe matching the ticket priority" + - "Tapping any list row navigates to /mobile/tickets/[id]" + - "Scrolling to the bottom automatically loads the next ~25 rows via IntersectionObserver" + - "A focusable Load more button is present below the sentinel until hasMore is false" + - "When zero tickets match active filters, the page shows 'No tickets match your filters' with a Clear filters button" + artifacts: + - path: "app/mobile/tickets/page.tsx" + provides: "Mobile tickets list page wired to TicketFilterStrip + cursor-based /api/mobile/tickets" + contains: "TicketFilterStrip" + key_links: + - from: "app/mobile/tickets/page.tsx" + to: "/api/mobile/tickets" + via: "fetch with cursor + filter URL params" + pattern: "fetch\\(.*api/mobile/tickets" + - from: "app/mobile/tickets/page.tsx" + to: "components/mobile/TicketFilterStrip.tsx" + via: "named import" + pattern: "from ['\"]@/components/mobile/TicketFilterStrip['\"]" + - from: "app/mobile/tickets/page.tsx" + to: "components/mobile/TicketRowSkeleton.tsx" + via: "named import" + pattern: "from ['\"]@/components/mobile/TicketRowSkeleton['\"]" + - from: "app/mobile/tickets/page.tsx" + to: "MobileTicketListResponse type" + via: "import type from route file (Phase 3 pattern)" + pattern: "import type.*from.*api/mobile/tickets/route" +--- + + +Replace the body of `app/mobile/tickets/page.tsx` with the new shell-aligned implementation: Collapsible URL-synced filter strip, priority-stripe rows, IntersectionObserver-driven infinite scroll with a Load more fallback, skeleton loading state, and empty-state copy per D-20. + +Purpose: closes TICK-01 through TICK-06 — the only requirements left after Plan 01 ships the API + presentational components. URL sync via `useSearchParams()` + `router.replace()` is the deep-link contract. + +Output: +- Rewritten `app/mobile/tickets/page.tsx` consuming `MobileTicketListResponse` from the new route, the `TicketFilterStrip` and `TicketRowSkeleton` components from `components/mobile/`, with infinite scroll + URL sync + priority stripes. +- One human-verify checkpoint after the rewrite to confirm visual + interaction behavior on a real device. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/REQUIREMENTS.md +@.planning/phases/04-tickets-restyle/04-CONTEXT.md +@.planning/phases/04-tickets-restyle/04-UI-SPEC.md +@.planning/phases/03-dashboard-restyle/03-02-SUMMARY.md +@CLAUDE.md +@app/mobile/tickets/page.tsx + + + + +From `app/api/mobile/tickets/route.ts`: +```typescript +export interface MobileTicket { + id: number; + ticket_number: string; + title: string; + status: number; + priority: number; // 1=Critical, 2=High, 3=Medium, 4=Low + create_date: string; + last_activity_date: string; + due_date_time: string | null; + queue_id: number; + queue_label: string; + company_name: string; + assigned_to: string; +} + +export interface MobileTicketListResponse { + tickets: MobileTicket[]; + nextCursor: string | null; // base64 cursor; null when list is exhausted + hasMore: boolean; +} +``` + +From `components/mobile/TicketFilterStrip.tsx`: +```typescript +export interface QueueOption { id: number; label: string; } +export interface TicketFilterValue { + q: string; + status: number[]; // [] = treated as default by parent + priority: number[]; + queue: number | null; + mine: boolean; +} +export interface TicketFilterStripProps { + value: TicketFilterValue; + onChange: (next: TicketFilterValue) => void; + queueOptions: QueueOption[]; + openTotal: number; + isFiltered: boolean; + onClearAll: () => void; +} +export function TicketFilterStrip(props: TicketFilterStripProps): JSX.Element; +``` + +From `components/mobile/TicketRowSkeleton.tsx`: +```typescript +export function TicketRowSkeleton(): JSX.Element; +``` + +Helpers preserved from current page (line 29-37): +```typescript +function relTime(ts: string | null): string; // "5m ago" | "3h ago" | "2d ago" | "—" +``` + + + + + + + Task 1: Rewrite app/mobile/tickets/page.tsx with URL-synced filters, priority-stripe rows, and IntersectionObserver infinite scroll + app/mobile/tickets/page.tsx + + - app/mobile/tickets/page.tsx (current 164-line implementation — preserve relTime helper) + - .planning/phases/04-tickets-restyle/04-UI-SPEC.md (entire file — class strings and structure are load-bearing) + - .planning/phases/04-tickets-restyle/04-CONTEXT.md decisions D-05 through D-21 + - components/mobile/TicketFilterStrip.tsx (the prop contract Plan 01 ships) + - components/mobile/TicketRowSkeleton.tsx (the skeleton Plan 01 ships) + - app/mobile/dashboard/page.tsx (Phase 3 mobile page pattern: 'use client', single load function, useEffect once, error block + Retry) + + + Replace `app/mobile/tickets/page.tsx` end-to-end. The new file structure (target ≤ 220 lines): + + 1. **Header** — `'use client';` then imports: + ```typescript + import { useEffect, useState, useCallback, useRef, useMemo, Suspense } from 'react'; + import Link from 'next/link'; + import { useRouter, useSearchParams } from 'next/navigation'; + import { ChevronRight, Clock, Loader2, RefreshCw } from 'lucide-react'; + import { Button } from '@/components/ui/button'; + import { TicketFilterStrip, type TicketFilterValue, type QueueOption } from '@/components/mobile/TicketFilterStrip'; + import { TicketRowSkeleton } from '@/components/mobile/TicketRowSkeleton'; + import type { MobileTicket, MobileTicketListResponse } from '@/app/api/mobile/tickets/route'; + ``` + + 2. **Constants** (top of module, outside component): + ```typescript + const PRIORITY_BORDER: Record = { + 1: 'border-red-500', + 2: 'border-orange-400', + 3: 'border-amber-400', + 4: 'border-slate-300', + }; + const DEFAULT_STATUS: number[] = [1, 8, 7]; // Open + In Progress + Waiting (matches API default) + ``` + These exact class strings are LOCKED by D-15 / UI-SPEC §"Priority Stripe Colors". Do NOT use `border-yellow-400` (the legacy code's medium dot) — D-15 specifies `border-amber-400` for priority 3. + + 3. **`relTime` helper** — copy verbatim from the current file (lines 29-37). Do NOT inline-replace with a library; D-17 says "Keep `relTime()` helper as-is". + + 4. **URL <-> filter state helpers** (module-scope pure functions): + ```typescript + function parseFilterFromSearch(sp: URLSearchParams): TicketFilterValue { + const parseIntList = (raw: string | null): number[] => { + if (raw === null) return []; + if (raw === '') return []; // explicit empty + return raw.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n)); + }; + const statusParam = sp.get('status'); + return { + q: sp.get('q') ?? '', + // when 'status' is absent entirely, fall through to DEFAULT_STATUS so the visible state matches what the API will return + status: statusParam === null ? [...DEFAULT_STATUS] : parseIntList(statusParam), + priority: parseIntList(sp.get('priority')), + queue: sp.get('queue') ? parseInt(sp.get('queue')!, 10) : null, + mine: sp.get('mine') === '1', + }; + } + + function filterToSearch(value: TicketFilterValue): URLSearchParams { + const sp = new URLSearchParams(); + if (value.q) sp.set('q', value.q); + // Only include status param when it differs from default — keeps URL clean for unfiltered visits (D-06) + const isDefaultStatus = value.status.length === DEFAULT_STATUS.length + && DEFAULT_STATUS.every(s => value.status.includes(s)); + if (!isDefaultStatus && value.status.length > 0) sp.set('status', value.status.join(',')); + if (value.status.length === 0) sp.set('status', ''); // explicit "no status filter" + if (value.priority.length > 0) sp.set('priority', value.priority.join(',')); + if (value.queue !== null) sp.set('queue', String(value.queue)); + if (value.mine) sp.set('mine', '1'); + return sp; + } + + function isFilterModified(value: TicketFilterValue): boolean { + const isDefaultStatus = value.status.length === DEFAULT_STATUS.length + && DEFAULT_STATUS.every(s => value.status.includes(s)); + return Boolean(value.q) + || !isDefaultStatus + || value.priority.length > 0 + || value.queue !== null + || value.mine; + } + ``` + + 5. **Suspense wrapper** — Next.js 16 requires `useSearchParams()` to be inside a Suspense boundary. Pattern (matches CLAUDE.md "Build Notes" memory): + ```typescript + export default function MobileTicketsPage() { + return ( + }> + + + ); + } + ``` + The actual page logic lives in `MobileTicketsInner`. + + 6. **`MobileTicketsInner` component** — the full page state machine: + ```typescript + function MobileTicketsInner() { + const router = useRouter(); + const searchParams = useSearchParams(); + + // Filter state — initial value from URL (deep-link hydration per D-06) + const initialFilter = useMemo(() => parseFilterFromSearch(new URLSearchParams(searchParams.toString())), []); + const [filter, setFilter] = useState(initialFilter); + + // Debounced search — separate from filter so other filters update immediately + const [debouncedQ, setDebouncedQ] = useState(initialFilter.q); + useEffect(() => { + const t = setTimeout(() => setDebouncedQ(filter.q), 400); + return () => clearTimeout(t); + }, [filter.q]); + + // List state + const [tickets, setTickets] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + const [hasMore, setHasMore] = useState(false); + const [openTotal, setOpenTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(null); + const [queueOptions, setQueueOptions] = useState([]); + + // Build URL search params for the API call given a filter and optional cursor + const buildApiParams = useCallback((f: TicketFilterValue, q: string, cursor: string | null): URLSearchParams => { + const sp = new URLSearchParams(); + if (q) sp.set('q', q); + if (f.status.length > 0) sp.set('status', f.status.join(',')); + else sp.set('status', ''); // explicit no-status (vs. omit = use default on server) + if (f.priority.length > 0) sp.set('priority', f.priority.join(',')); + if (f.queue !== null) sp.set('queue', String(f.queue)); + if (f.mine) sp.set('mine', '1'); + if (cursor) sp.set('cursor', cursor); + sp.set('limit', '25'); + return sp; + }, []); + + // Fetch first page (filters changed) + const loadFirst = useCallback(async (f: TicketFilterValue, q: string) => { + setLoading(true); + setError(null); + try { + const sp = buildApiParams(f, q, null); + const r = await fetch(`/api/mobile/tickets?${sp.toString()}`); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const data: MobileTicketListResponse = await r.json(); + setTickets(data.tickets); + setNextCursor(data.nextCursor); + setHasMore(data.hasMore); + // Approximate "open total" from first page until a count endpoint exists; keep tickets.length when hasMore=false + setOpenTotal(data.tickets.length + (data.hasMore ? 1 : 0)); + // Derive queue options from the first page so the Select shows real labels (best-effort; deduped by id) + setQueueOptions(prev => { + const seen = new Map(); + for (const opt of prev) seen.set(opt.id, opt); + for (const t of data.tickets) { + if (t.queue_id && t.queue_label && !seen.has(t.queue_id)) { + seen.set(t.queue_id, { id: t.queue_id, label: t.queue_label }); + } + } + return Array.from(seen.values()).sort((a, b) => a.label.localeCompare(b.label)); + }); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load tickets'); + } finally { + setLoading(false); + } + }, [buildApiParams]); + + // Fetch next page (cursor advance) + const loadMore = useCallback(async () => { + if (loadingMore || !hasMore || !nextCursor) return; + setLoadingMore(true); + setError(null); + try { + const sp = buildApiParams(filter, debouncedQ, nextCursor); + const r = await fetch(`/api/mobile/tickets?${sp.toString()}`); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const data: MobileTicketListResponse = await r.json(); + setTickets(prev => [...prev, ...data.tickets]); + setNextCursor(data.nextCursor); + setHasMore(data.hasMore); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load more tickets'); + } finally { + setLoadingMore(false); + } + }, [loadingMore, hasMore, nextCursor, filter, debouncedQ, buildApiParams]); + + // Reload first page when filter or debounced search changes (D-05/D-06: also push URL) + useEffect(() => { + const next = filterToSearch({ ...filter, q: debouncedQ }); + const nextStr = next.toString(); + if (nextStr !== searchParams.toString()) { + router.replace(`/mobile/tickets${nextStr ? `?${nextStr}` : ''}`, { scroll: false }); + } + void loadFirst(filter, debouncedQ); + // Intentionally exclude searchParams from deps to prevent loop with router.replace + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedQ, filter.status, filter.priority, filter.queue, filter.mine, loadFirst, router]); + + // IntersectionObserver — infinite scroll trigger (D-12, D-13) + const sentinelRef = useRef(null); + useEffect(() => { + const node = sentinelRef.current; + if (!node) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && hasMore && !loadingMore && !loading) { + void loadMore(); + } + }, + { rootMargin: '200px' }, + ); + observer.observe(node); + return () => observer.disconnect(); + }, [hasMore, loadingMore, loading, loadMore]); + + // Clear all (D-04, D-20 empty-state CTA) + const clearAll = useCallback(() => { + setFilter({ q: '', status: [...DEFAULT_STATUS], priority: [], queue: null, mine: false }); + }, []); + + const filtered = isFilterModified(filter); + + // ───── Render ───── + return ( +
+ + +
+ {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => )} +
+ ) : tickets.length === 0 ? ( + // Empty state (D-20) +
+ {filtered ? ( + <> +

No tickets match your filters

+ + + ) : ( + <> +

No tickets to triage right now

+ + + )} +
+ ) : ( + <> +
+ {tickets.map((t) => ( + +
+
+

{t.title}

+
+

{t.company_name}

+
+ {t.ticket_number} + {t.queue_label && ( + {t.queue_label} + )} + {t.assigned_to && ( + + {t.assigned_to.split(' ').map(s => s[0]).filter(Boolean).slice(0, 2).join('').toUpperCase() || '·'} + + )} + + +
+
+ + ))} +
+ + {/* Sentinel — IntersectionObserver target (D-12) */} + +
+ ); + } + ``` + + Key behaviors / locked decisions: + - `router.replace()` not `router.push()` (D-05). + - Cursor is NOT in URL (D-07) — only `q`, `status`, `priority`, `queue`, `mine`. + - `border-l-4` + exact UI-SPEC class strings: `border-red-500`, `border-orange-400`, `border-amber-400`, `border-slate-300` (D-15). + - No priority dot rendered (D-17 — the legacy `
` is removed). + - Sentinel `aria-hidden="true"` (UI-SPEC accessibility section). + - Load more button has `aria-label="Load more tickets"` and is always rendered when `hasMore` so screen-reader users have a focusable control even after the IntersectionObserver triggers (TICK-06 / D-14). + - Skeleton state for initial load only — subsequent `loadingMore` shows the small spinner above Load more (D-21). + - Title uses `truncate` (1-line, per UI-SPEC "Row title — 1-line truncate") — the legacy code used `line-clamp-2`; switch to `truncate` to match locked spec. + - Use `` wrapper because `useSearchParams()` requires it in Next.js 16 (CLAUDE.md memory entry). + + Anti-patterns (do NOT do): + - Do NOT introduce SWR / react-query (CLAUDE.md). + - Do NOT use `router.push()` for filter updates (D-05). + - Do NOT persist `cursor` to the URL (D-07). + - Do NOT add a separate count endpoint — Plan 02 deliberately uses `tickets.length + hasMore ? 1 : 0` as a "≥N" approximation; revisit only if the exact count is needed (out of scope this phase). + - Do NOT reintroduce the priority dot — the stripe replaces it (D-17). + - Do NOT use Tailwind class `border-yellow-400` for priority 3 (legacy used yellow; UI-SPEC locked it to `border-amber-400`). + - Do NOT call `router.push()` on every keystroke — the debounced effect handles URL sync once the search settles. + + Discretionary choices made (per "Claude's Discretion" in 04-CONTEXT.md): + - Assignee initials avatar: `inline-flex items-center justify-center h-5 w-5 rounded-full bg-primary/15 text-primary text-[10px] font-semibold` rendering up to 2 initials, falling back to `·`. + - Queue option list is derived from the first page's tickets — no separate `/api/mobile/queues` endpoint. Acceptable for v1; the Select still works because the parent always passes the most recent set after the first load. + - "Open total" approximated as `tickets.length + (hasMore ? 1 : 0)` — visible label reads "N open tickets"; precision deferred until a count endpoint exists. + + + npx tsc --noEmit --pretty 2>&1 | grep -E "app/mobile/tickets/page\.tsx" || echo "OK: page typechecks" + + + - `grep -q "'use client'" app/mobile/tickets/page.tsx` (client component declaration) + - `grep -q "Suspense" app/mobile/tickets/page.tsx` (Suspense wrapper for useSearchParams — Next.js 16 requirement) + - `grep -q "useSearchParams" app/mobile/tickets/page.tsx` (URL hydration) + - `grep -q "router\.replace" app/mobile/tickets/page.tsx` (D-05 — replace not push) + - `! grep -q "router\.push" app/mobile/tickets/page.tsx` (no push for filter updates) + - `grep -q "TicketFilterStrip" app/mobile/tickets/page.tsx` (uses Plan 01 component) + - `grep -q "TicketRowSkeleton" app/mobile/tickets/page.tsx` (uses Plan 01 skeleton) + - `grep -q "import type.*MobileTicketListResponse" app/mobile/tickets/page.tsx` (typed response — Phase 3 pattern) + - `grep -q "IntersectionObserver" app/mobile/tickets/page.tsx` (D-12) + - `grep -q "rootMargin: '200px'" app/mobile/tickets/page.tsx` (D-12 — exact margin) + - `grep -q "border-red-500" app/mobile/tickets/page.tsx` (priority 1 — D-15) + - `grep -q "border-orange-400" app/mobile/tickets/page.tsx` (priority 2 — D-15) + - `grep -q "border-amber-400" app/mobile/tickets/page.tsx` (priority 3 — D-15, NOT yellow) + - `grep -q "border-slate-300" app/mobile/tickets/page.tsx` (priority 4 — D-15) + - `grep -q "border-l-4" app/mobile/tickets/page.tsx` (4px stripe — D-15) + - `grep -q "Load more" app/mobile/tickets/page.tsx` (TICK-06 fallback) + - `grep -q 'aria-label="Load more tickets"' app/mobile/tickets/page.tsx` (a11y) + - `grep -q 'aria-hidden="true"' app/mobile/tickets/page.tsx` (sentinel a11y) + - `grep -q "No tickets match your filters" app/mobile/tickets/page.tsx` (D-20 empty state — filtered) + - `grep -q "No tickets to triage right now" app/mobile/tickets/page.tsx` (D-20 empty state — unfiltered) + - `grep -q "Clear filters" app/mobile/tickets/page.tsx` (D-20 CTA) + - `grep -q "function relTime" app/mobile/tickets/page.tsx` (D-17 helper preserved) + - `! grep -q "PRIORITY_DOT" app/mobile/tickets/page.tsx` (D-17 — dot removed) + - `! grep -q "border-yellow-400" app/mobile/tickets/page.tsx` (legacy yellow replaced by amber) + - `! grep -qE "useSWR|@tanstack/react-query|zustand" app/mobile/tickets/page.tsx` (CLAUDE.md — no forbidden libs) + - `! grep -q "?page=" app/mobile/tickets/page.tsx` (no legacy page param) + - `npx tsc --noEmit --pretty 2>&1` reports no errors for `app/mobile/tickets/page.tsx` + + The rewritten page hydrates filters from `useSearchParams()` inside a Suspense boundary, calls `router.replace()` to sync filter changes back to the URL, fetches the cursor-paginated API on first load and on filter changes, advances via cursor on IntersectionObserver intersection (with a Load more fallback), renders priority-stripe rows using the four locked Tailwind border classes, shows skeleton rows on initial load and a small spinner during cursor advances, and renders the two distinct empty-state copies. TypeScript compiles cleanly. + + + + Task 2: Verify the new tickets list end-to-end on a real device or simulator + app/mobile/tickets/page.tsx (verifying — not modifying) + Human verification only — see below for the 14-step checklist. No code changes. Pause execution and wait for the user to confirm the new list page behaves per spec on a phone-width viewport. + echo "Manual checkpoint — see resume-signal" + User confirms all 14 checklist items pass on a phone-width viewport (real device or DevTools iPhone 15 Pro emulation), or describes precisely which step failed and why. + + The mobile Tickets list page now uses the new shell-aligned layout: Collapsible filter strip, URL-synced filter state, priority-stripe rows, IntersectionObserver-driven infinite scroll, Load more fallback button, skeleton loading state, and the two D-20 empty-state copies. The detail page link target (`/mobile/tickets/[id]`) is unchanged — that page's header reskin is shipped by Plan 04-03 in parallel. + + + Start the dev server (`npm run dev` → http://localhost:3100) and sign in. Then on a phone-width viewport (or Chrome DevTools iPhone 15 Pro emulation): + + 1. **Initial load + skeleton** — Navigate to `/mobile/tickets`. You should briefly see 5 skeleton rows (each with a muted left stripe + 3 placeholder lines), then the real tickets render. + 2. **Default state** — Filter strip is COLLAPSED. Search input visible. "Filters" button visible. Count line shows "N open tickets". Each row has a 4px colored left stripe (red / orange / amber / slate) — no dot. + 3. **Single-tap row** — Tap any row → routes to `/mobile/tickets/[id]` (existing detail page; header reskin from Plan 04-03 may or may not be live yet — body should render either way). + 4. **Filter strip expands** — Tap "Filters". Panel reveals four controls: status chips (Open / In Progress / Waiting), priority chips (Critical / High / Medium / Low), queue Select, "Assigned to me" Switch. + 5. **URL deep-link — set filters** — Tap "High" priority chip. URL updates IN PLACE to include `?priority=2` (no new history entry — back button takes you OUT of `/mobile/tickets`, not to a previous filter state). + 6. **URL deep-link — reload** — Reload the page with the URL still showing `?priority=2`. Filter strip hydrates with "High" already selected; list shows only priority-2 tickets. + 7. **Search debounce** — Type in the search box. URL updates ~400ms after you stop typing, not on every keystroke. + 8. **Clear all** — Tap "Clear all". Status returns to default (Open + In Progress + Waiting), priority/queue/mine reset, URL params clear. + 9. **Infinite scroll** — Scroll to the bottom of the list. The next ~25 rows append automatically (small spinner appears briefly above "Load more"). The page does NOT navigate to a new URL. + 10. **Load more button** — Confirm the "Load more" button is visible and focusable (Tab to it). Clicking it also advances the list. When the list is exhausted, the button disappears. + 11. **Empty state with filters** — Set filters that return no rows (e.g., a non-existent search term). Page shows "No tickets match your filters" + a "Clear filters" button. Tapping it restores defaults. + 12. **No charts / no recharts imports** — Sanity check: open DevTools network tab and confirm only `/api/mobile/tickets` is called (no extra count or queue endpoints). + 13. **Priority colors** — A row with `priority=1` has `border-red-500`, `priority=2` `border-orange-400`, `priority=3` `border-amber-400`, `priority=4` `border-slate-300`. These are direct Tailwind palette references per UI-SPEC §"Priority Stripe Colors". + 14. **Detail back nav (Plan 04-03 dependency)** — From a detail page, the device back gesture returns you to the list at the same scroll position with filters intact. (Plan 04-03 reskins the in-page back chevron — UX should still work without it.) + + Type "approved" if all 14 checks pass. If any fail, describe the failure precisely (which step, what you saw vs. expected). + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| URL search params → component state | A user-supplied URL (incl. shared deep links) populates filter state and is fed into API calls | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-04-07 | Tampering | URL params (`status`, `priority`, `queue`, `mine`, `q`) | mitigate | `parseFilterFromSearch()` runs `parseInt + isNaN` filter on every numeric value; non-numeric tokens silently dropped. The page only forwards values to the API, which itself parameterises and validates. | +| T-04-08 | Information Disclosure | search query reflected in URL | accept | Query parameters appear in browser history and any logging — same risk as the existing implementation; users searching for sensitive terms is a userland concern. | +| T-04-09 | Denial of Service | rapid filter changes flood the API | mitigate | Search input debounced 400ms (D-03). Other filters are discrete user actions (chip tap, dropdown change) — already rate-limited by human input speed. | +| T-04-10 | Repudiation | mobile actions are read-only | accept | This page is read-only; no audit logging needed. Detail page comments/edits are out of scope. | + + + +1. `npx tsc --noEmit --pretty` passes — no type errors in `app/mobile/tickets/page.tsx`. +2. All `` grep checks for Task 1 return success. +3. Human-verify checklist (Task 2) reaches "approved". +4. Phase-level smoke: visit `/mobile/tickets`, then `/mobile/tickets?priority=1`, then `/mobile/tickets?status=&priority=&q=zzz_no_match` — three different rendered states (default list, priority-1 only, empty state with Clear filters CTA). + + + +- TICK-01: Collapsible filter strip default-collapsed, expands to status/priority/queue/mine controls. +- TICK-02: All four filter primitives sync to the URL via `router.replace()`; reload hydrates state. +- TICK-03: Each row has a `border-l-4` stripe with the correct priority Tailwind class. +- TICK-04: Single-tap on a row navigates to `/mobile/tickets/[id]`. +- TICK-05: ~25-per-page cursor advance via `IntersectionObserver` with `rootMargin: '200px'`. +- TICK-06: A focusable "Load more" button is rendered whenever `hasMore` is true. +- D-20 empty states render correct copy with correct CTAs. +- No new state libraries introduced; CLAUDE.md conventions honored. + + + +After completion, create `.planning/phases/04-tickets-restyle/04-02-SUMMARY.md` documenting: +- Final file size of `app/mobile/tickets/page.tsx`. +- The URL <-> filter state mapping (which params are present when, and how `status` interacts with the default `[1, 8, 7]`). +- That the queue list is derived from first-page tickets (no new endpoint). +- The "open total" approximation note (tickets.length + (hasMore ? 1 : 0)) and that a precise count endpoint is deferred. +- Any deviations from the plan (e.g., extra renders, fallback behaviors). + diff --git a/.planning/phases/04-tickets-restyle/04-03-PLAN.md b/.planning/phases/04-tickets-restyle/04-03-PLAN.md new file mode 100644 index 0000000..a26f06b --- /dev/null +++ b/.planning/phases/04-tickets-restyle/04-03-PLAN.md @@ -0,0 +1,218 @@ +--- +phase: 04 +plan: 03 +type: execute +wave: 1 +depends_on: [] +files_modified: + - app/mobile/tickets/[id]/page.tsx +autonomous: true +requirements: [TICK-07] +must_haves: + truths: + - "Detail page in-page header shows a back chevron + 'Tickets' label that calls router.back()" + - "Detail page in-page header shows the breadcrumb 'Tickets / #{ticket_number}' centered" + - "Detail page in-page header shows an external-link icon that opens the desktop ticket URL in a new tab" + - "The shell HeaderBar (Wulf mark + Bell + avatar) from app/mobile/layout.tsx still renders above the in-page header" + - "The detail body (priority/status badges, stats grid, description, timeline) is unchanged from the legacy implementation" + artifacts: + - path: "app/mobile/tickets/[id]/page.tsx" + provides: "Mobile ticket detail page with reskinned in-page header per D-18" + contains: "ArrowLeft" + key_links: + - from: "app/mobile/tickets/[id]/page.tsx" + to: "lucide-react ArrowLeft + ExternalLink icons" + via: "named import" + pattern: "ExternalLink" + - from: "app/mobile/tickets/[id]/page.tsx" + to: "/api/mobile/tickets/{id}/timeline endpoint" + via: "fetch — unchanged from legacy" + pattern: "fetch\\(`/api/mobile/tickets/" +--- + + +Reskin only the in-page header bar at the top of `app/mobile/tickets/[id]/page.tsx` per D-18: replace the current "← Back" button with a three-slot header (back chevron + label, breadcrumb, external link). The detail body — priority badge row, h1 title, stats grid, description block, timeline — stays untouched per D-19. + +Purpose: closes TICK-07. The shell HeaderBar already renders above this page from `app/mobile/layout.tsx`, so the in-page header docks under it consistently with the new shell language. + +Output: +- Modified `app/mobile/tickets/[id]/page.tsx` with the new three-slot header bar and `ExternalLink` icon import. Body untouched. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/REQUIREMENTS.md +@.planning/phases/04-tickets-restyle/04-CONTEXT.md +@.planning/phases/04-tickets-restyle/04-UI-SPEC.md +@.planning/phases/02-mobile-shell/02-CONTEXT.md +@CLAUDE.md +@app/mobile/tickets/[id]/page.tsx + + + + + + Task 1: Reskin the in-page header of /mobile/tickets/[id] with back chevron, breadcrumb, and external-link icon + app/mobile/tickets/[id]/page.tsx + + - app/mobile/tickets/[id]/page.tsx (full 357-line current file — only lines ~239-242 change in the header; everything else is preserved) + - .planning/phases/04-tickets-restyle/04-CONTEXT.md decisions D-18 (header structure) and D-19 (body unchanged) + - .planning/phases/04-tickets-restyle/04-UI-SPEC.md "Detail Page In-Page Header" section + + + Open `app/mobile/tickets/[id]/page.tsx` and make TWO surgical edits. + + **Edit 1: Add `ExternalLink` to the lucide-react imports (line 5-8 region).** + + Current import block: + ```typescript + import { + ArrowLeft, RefreshCw, Clock, FileText, Timer, CheckCircle2, + ChevronDown, ChevronRight, User, Briefcase, AlertCircle, EyeOff, Eye, Mail, AlignLeft, Code2, + } from 'lucide-react'; + ``` + + Add `ExternalLink` to the named imports (alphabetical position: after `Eye`, before `Mail`). Final form: + ```typescript + import { + ArrowLeft, RefreshCw, Clock, FileText, Timer, CheckCircle2, + ChevronDown, ChevronRight, User, Briefcase, AlertCircle, EyeOff, Eye, ExternalLink, Mail, AlignLeft, Code2, + } from 'lucide-react'; + ``` + + Do NOT remove any existing import — `ArrowLeft`, `RefreshCw`, etc. all remain in use. + + **Edit 2: Replace the legacy back button (currently at lines ~240-242) with the three-slot header bar per D-18 / UI-SPEC §"Detail Page In-Page Header".** + + Current code (the section to replace — inside the `{/* Ticket header */}` div, only the FIRST element of that block): + ```tsx + + ``` + + Replace with the new three-slot header. **Important context:** the parent `
` already provides horizontal padding and the bottom border. The new header must NOT double-bracket the border. So the new header bar replaces ONLY the back button — keep the parent div as-is, just substitute its first child: + + ```tsx +
+ + +

+ Tickets / #{ticket.ticket_number} +

+ + + +
+ ``` + + Notes on the layout: + - `-mx-4 px-4` extends the header band to the parent div's edges and reapplies internal padding so the `border-b` runs full-width visually under the new header. + - The legacy parent div still has its own `border-b` from when it bracketed the entire header (badges + title + stats + description). That outer `border-b` stays — it now sits below the description / stats area, which is the correct visual structure (the new header has its own divider; the outer border still divides the header section from the timeline). + - Breadcrumb uses `text-sm font-semibold truncate` — UI-SPEC §"Typography" `Detail breadcrumb` row. + - Desktop URL: `/analyzer/ticket/{id}` — this is the desktop analyzer ticket view (the canonical desktop ticket URL in this codebase). UI-SPEC permits "Autotask direct URL" as alternative; the analyzer URL is the in-app desktop equivalent and stays inside the auth boundary. + - `aria-hidden="true"` on icons because the surrounding text / aria-label provides the accessible name. + + **What NOT to change (D-19 — body untouched):** + - The badges row (`
` with priority + ticket number + status pills) — preserve verbatim. + - The `

{ticket.title}

` — preserve verbatim. + - The metadata icons row (Briefcase / User / Clock with company / assignee / created date) — preserve verbatim. + - The 3-card stats grid (Notes / Time entries / Hours logged) — preserve verbatim. + - The Description Collapsible block + Timeline section — preserve verbatim. + - The `TimelineCard` component definition — preserve verbatim. + - The `loading` and `error` states — preserve verbatim. + - All helper functions (`fmtDate`, `fmtHours`, `renderContent`, `relTime`) — preserve verbatim. + - The `STATUS_LABEL`, `PRIORITY_LABEL`, `PRIORITY_COLOR` constant maps — preserve verbatim. + + Do NOT touch `app/mobile/tickets/[id]/timeline/route.ts` or any other file. The plan's `files_modified` is exactly one file. + + Anti-patterns (do NOT do): + - Do NOT remove the parent `
` wrapper — the body still expects it. + - Do NOT remove or alter the existing badges, title, stats, description, or timeline — body is out of scope (D-19). + - Do NOT introduce a `` element here — that's the shell's job and already renders from `app/mobile/layout.tsx`. + - Do NOT swap the desktop URL to an external Autotask link unless the analyzer URL is unreachable — the UI-SPEC accepts either; analyzer URL is preferred (in-app navigation). + + + npx tsc --noEmit --pretty 2>&1 | grep -E "app/mobile/tickets/\[id\]/page\.tsx" || echo "OK: detail page typechecks" + + + - `grep -q "ExternalLink" app/mobile/tickets/[id]/page.tsx` (icon imported and used) + - `grep -q 'aria-label="Back to Tickets"' app/mobile/tickets/[id]/page.tsx` (D-18 / UI-SPEC accessibility) + - `grep -q 'aria-label="Open ticket on desktop"' app/mobile/tickets/[id]/page.tsx` (D-18 external link a11y) + - `grep -q "Tickets / #" app/mobile/tickets/[id]/page.tsx` (D-18 breadcrumb literal) + - `grep -q "router\.back()" app/mobile/tickets/[id]/page.tsx` (back gesture preserved) + - `grep -q "/analyzer/ticket/" app/mobile/tickets/[id]/page.tsx` (desktop URL in href) + - `grep -q 'target="_blank"' app/mobile/tickets/[id]/page.tsx` (opens in new tab) + - `grep -q 'rel="noopener noreferrer"' app/mobile/tickets/[id]/page.tsx` (security on target=_blank) + - `grep -q "TimelineCard" app/mobile/tickets/[id]/page.tsx` (body component preserved — D-19) + - `grep -q "function fmtDate" app/mobile/tickets/[id]/page.tsx` (body helper preserved — D-19) + - `grep -q "function renderContent" app/mobile/tickets/[id]/page.tsx` (body helper preserved — D-19) + - `grep -q "Notes" app/mobile/tickets/[id]/page.tsx && grep -q "Time entries" app/mobile/tickets/[id]/page.tsx && grep -q "Hours logged" app/mobile/tickets/[id]/page.tsx` (stats grid preserved — D-19) + - `! grep -qE ">\\s*Back\\s*" app/mobile/tickets/[id]/page.tsx` (legacy "Back" text removed in favor of "Tickets") + - `npx tsc --noEmit --pretty 2>&1` reports no errors for `app/mobile/tickets/[id]/page.tsx` + + The detail page imports `ExternalLink`, renders the three-slot in-page header (back chevron + "Tickets" label, breadcrumb "Tickets / #{ticket_number}", external-link icon to `/analyzer/ticket/{id}`), and leaves the badges row, title, stats grid, description, and timeline byte-identical to the legacy implementation. TypeScript compiles cleanly. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| in-page link → external desktop URL | The new ExternalLink anchor opens `/analyzer/ticket/{id}` in a new tab | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-04-11 | Tampering | external link `target="_blank"` reverse tabnabbing | mitigate | `rel="noopener noreferrer"` on the anchor — prevents the opened page from accessing `window.opener`, even though `/analyzer/ticket/{id}` is same-origin. Defense in depth. | +| T-04-12 | Information Disclosure | desktop URL leaks ticket id in browser tab | accept | Same id is already in the current page URL; no new exposure. | + + + +1. `npx tsc --noEmit --pretty` passes — no errors for `app/mobile/tickets/[id]/page.tsx`. +2. All Task 1 acceptance-criteria greps return success. +3. Manual smoke (developer terminal): visit `/mobile/tickets/` and confirm: + - The shell HeaderBar (Wulf wordmark + Bell + avatar) renders at the very top from `app/mobile/layout.tsx`. + - Below it, the new in-page header shows back chevron + "Tickets" on the left, breadcrumb in the center, ExternalLink icon on the right. + - Below that, the unchanged badges row → title → stats grid → description → timeline. + - Tapping "Tickets" calls `router.back()` and returns to the list. + - Tapping the ExternalLink icon opens `/analyzer/ticket/` in a new tab. + + + +- TICK-07: Detail page header reskinned to match the new shell language (back chevron + breadcrumb + external link); body unchanged. +- D-18 implemented exactly: three slots (back, breadcrumb, external). +- D-19 honored: badges, title, stats grid, description, timeline byte-identical. +- TypeScript clean. +- The shell HeaderBar from `app/mobile/layout.tsx` continues to render above this in-page header — no double-rendering. + + + +After completion, create `.planning/phases/04-tickets-restyle/04-03-SUMMARY.md` documenting: +- The exact lines changed in `app/mobile/tickets/[id]/page.tsx` (import line + header bar replacement). +- The desktop URL chosen (`/analyzer/ticket/{id}`) and the rationale (in-app navigation, stays in auth boundary). +- Confirmation that the body (D-19 scope) was not touched — list which sections remained verbatim. +- That this plan ran in parallel with 04-02 (no shared file conflict). +