docs(04): create phase plan

This commit is contained in:
lorentz 2026-05-03 17:53:50 -04:00
parent bf780790d5
commit 77073bac97
4 changed files with 1396 additions and 2 deletions

View file

@ -86,7 +86,10 @@ Decimal phases appear between their surrounding integers in numeric order.
3. Single-tapping a row navigates to `/mobile/tickets/[id]` 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 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 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 **UI hint**: yes
### Phase 5: Finance Restyle ### Phase 5: Finance Restyle
@ -146,7 +149,7 @@ Phases execute in numeric order. Phase 2 unblocks Phases 37 (any order, paral
| 1. PWA Scaffolding | 1/2 | Executing | - | | 1. PWA Scaffolding | 1/2 | Executing | - |
| 2. Mobile Shell + More Drawer | 0/TBD | Not started | - | | 2. Mobile Shell + More Drawer | 0/TBD | Not started | - |
| 3. Dashboard Restyle | 0/2 | 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 | - | | 5. Finance Restyle | 0/TBD | Not started | - |
| 6. Analyzer Feed | 0/TBD | Not started | - | | 6. Analyzer Feed | 0/TBD | Not started | - |
| 7. Engagement Overview | 0/TBD | Not started | - | | 7. Engagement Overview | 0/TBD | Not started | - |

View file

@ -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['\"]"
---
<objective>
Reshape `/api/mobile/tickets` from page-based pagination (`?page=N&limit=30`) to opaque-cursor pagination (`?cursor=<b64>&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)
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- Existing route helper that MUST be preserved verbatim — see 04-CONTEXT.md code_context -->
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
</interfaces>
</context>
<tasks>
<task type="auto" tdd="false">
<name>Task 1: Rewrite /api/mobile/tickets to cursor-paginated shape with exported interfaces</name>
<files>app/api/mobile/tickets/route.ts</files>
<read_first>
- 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
</read_first>
<action>
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<NextResponse>`:
- 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 <conditions joined with AND>
ORDER BY t.last_activity_date DESC NULLS LAST, t.id DESC
LIMIT <limit + 1>
```
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).
</action>
<verify>
<automated>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"</automated>
</verify>
<acceptance_criteria>
- `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`
</acceptance_criteria>
<done>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.</done>
</task>
<task type="auto" tdd="false">
<name>Task 2: Create TicketRowSkeleton and TicketFilterStrip presentational components</name>
<files>components/mobile/TicketRowSkeleton.tsx, components/mobile/TicketFilterStrip.tsx</files>
<read_first>
- .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)
</read_first>
<action>
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 (
<div className="border-l-4 border-muted px-4 py-4">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2 mt-1" />
<div className="flex gap-2 mt-2 items-center">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-3 w-16 ml-auto" />
</div>
</div>
);
}
```
Match UI-SPEC §"Skeleton Loading State" exactly. The wrapping container in Plan 02 will render `Array.from({ length: 5 }).map((_, i) => <TicketRowSkeleton key={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 (
<div className="sticky top-0 bg-background z-10 border-b px-4 pt-4 pb-3 space-y-2">
{/* Search row — always visible (D-03) */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" aria-hidden="true" />
<Input
type="text"
placeholder="Search tickets, company…"
value={value.q}
onChange={(e) => onChange({ ...value, q: e.target.value })}
className="w-full pl-9 pr-9"
aria-label="Search tickets"
/>
{value.q && (
<button
type="button"
onClick={() => onChange({ ...value, q: '' })}
className="absolute right-3 top-1/2 -translate-y-1/2"
aria-label="Clear search"
>
<X className="w-4 h-4 text-muted-foreground" />
</button>
)}
</div>
{/* Toggle row — always visible (D-01) */}
<Collapsible open={open} onOpenChange={setOpen}>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">{openTotal} open tickets</p>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="text-xs font-semibold" aria-label="Toggle filters">
<SlidersHorizontal className="w-3.5 h-3.5 mr-1.5" aria-hidden="true" />
Filters{activeCount > 0 ? ` (${activeCount})` : ''}
{open ? <ChevronUp className="w-3.5 h-3.5 ml-1" aria-hidden="true" /> : <ChevronDown className="w-3.5 h-3.5 ml-1" aria-hidden="true" />}
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent className="pt-3 space-y-3">
{/* Status (D-02) */}
<div className="space-y-1.5">
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Status</p>
<div className="flex gap-2 flex-wrap">
{STATUS_OPTIONS.map((s) => (
<button
key={s.id}
type="button"
role="checkbox"
aria-checked={value.status.includes(s.id)}
onClick={() => onChange({ ...value, status: toggleInArray(value.status, s.id) })}
className={chipClass(value.status.includes(s.id))}
>
{s.label}
</button>
))}
</div>
</div>
{/* Priority (D-02) */}
<div className="space-y-1.5">
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Priority</p>
<div className="flex gap-2 flex-wrap">
{PRIORITY_OPTIONS.map((p) => (
<button
key={p.id}
type="button"
role="checkbox"
aria-checked={value.priority.includes(p.id)}
onClick={() => onChange({ ...value, priority: toggleInArray(value.priority, p.id) })}
className={chipClass(value.priority.includes(p.id))}
>
{p.label}
</button>
))}
</div>
</div>
{/* Queue (D-02) */}
<div className="space-y-1.5">
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Queue</p>
<Select
value={value.queue !== null ? String(value.queue) : 'all'}
onValueChange={(v) => onChange({ ...value, queue: v === 'all' ? null : parseInt(v, 10) })}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="All queues" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All queues</SelectItem>
{queueOptions.map((q) => (
<SelectItem key={q.id} value={String(q.id)}>{q.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Assigned to me (D-02) */}
<div className="flex items-center justify-between">
<label htmlFor="filter-mine" className="text-sm">Assigned to me</label>
<Switch
id="filter-mine"
checked={value.mine}
onCheckedChange={(checked) => onChange({ ...value, mine: checked })}
/>
</div>
{/* Clear all — only when isFiltered (D-04) */}
{isFiltered && (
<button
type="button"
onClick={onClearAll}
className="text-xs text-muted-foreground underline"
>
Clear all
</button>
)}
</CollapsibleContent>
</Collapsible>
</div>
);
}
```
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.
</action>
<verify>
<automated>npx tsc --noEmit --pretty 2>&1 | grep -E "components/mobile/(TicketRowSkeleton|TicketFilterStrip)" || echo "OK: no type errors in new components"</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>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.</done>
</task>
</tasks>
<threat_model>
## 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. |
</threat_model>
<verification>
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 "<session-cookie>" | 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).
</verification>
<success_criteria>
- `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).
</success_criteria>
<output>
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.
</output>

View file

@ -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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- From Plan 04-01 — these will exist when Plan 02 runs -->
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" | "—"
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="false">
<name>Task 1: Rewrite app/mobile/tickets/page.tsx with URL-synced filters, priority-stripe rows, and IntersectionObserver infinite scroll</name>
<files>app/mobile/tickets/page.tsx</files>
<read_first>
- 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)
</read_first>
<action>
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<number, string> = {
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 (
<Suspense fallback={<div className="flex items-center justify-center h-40"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>}>
<MobileTicketsInner />
</Suspense>
);
}
```
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<TicketFilterValue>(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<MobileTicket[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(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<string | null>(null);
const [queueOptions, setQueueOptions] = useState<QueueOption[]>([]);
// 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<number, QueueOption>();
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<HTMLDivElement | null>(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 (
<div className="flex flex-col h-full">
<TicketFilterStrip
value={filter}
onChange={setFilter}
queueOptions={queueOptions}
openTotal={openTotal}
isFiltered={filtered}
onClearAll={clearAll}
/>
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="divide-y">
{Array.from({ length: 5 }).map((_, i) => <TicketRowSkeleton key={i} />)}
</div>
) : tickets.length === 0 ? (
// Empty state (D-20)
<div className="text-center py-12 px-4 space-y-3">
{filtered ? (
<>
<p className="text-sm text-muted-foreground">No tickets match your filters</p>
<Button variant="outline" size="sm" onClick={clearAll}>Clear filters</Button>
</>
) : (
<>
<p className="text-sm text-muted-foreground">No tickets to triage right now</p>
<Button variant="ghost" size="sm" onClick={() => loadFirst(filter, debouncedQ)} aria-label="Refresh ticket list">
<RefreshCw className="w-4 h-4" aria-hidden="true" />
</Button>
</>
)}
</div>
) : (
<>
<div className="divide-y">
{tickets.map((t) => (
<Link
key={t.id}
href={`/mobile/tickets/${t.id}`}
className={`flex items-start border-l-4 ${PRIORITY_BORDER[t.priority] ?? 'border-slate-300'} px-4 py-4 hover:bg-muted/50 active:bg-muted/50 transition-colors`}
>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-semibold leading-snug truncate">{t.title}</p>
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" aria-hidden="true" />
</div>
<p className="text-xs text-muted-foreground truncate mt-0.5">{t.company_name}</p>
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
<span className="text-[10px] bg-muted rounded px-1.5 py-0.5 font-mono">{t.ticket_number}</span>
{t.queue_label && (
<span className="text-[10px] text-muted-foreground">{t.queue_label}</span>
)}
{t.assigned_to && (
<span className="inline-flex items-center justify-center h-5 w-5 rounded-full bg-primary/15 text-primary text-[10px] font-semibold">
{t.assigned_to.split(' ').map(s => s[0]).filter(Boolean).slice(0, 2).join('').toUpperCase() || '·'}
</span>
)}
<span className="text-[10px] text-muted-foreground flex items-center gap-0.5 ml-auto">
<Clock className="w-3 h-3" aria-hidden="true" />
{relTime(t.last_activity_date)}
</span>
</div>
</div>
</Link>
))}
</div>
{/* Sentinel — IntersectionObserver target (D-12) */}
<div ref={sentinelRef} aria-hidden="true" />
{/* Loading-more spinner (D-21) */}
{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-14, TICK-06) */}
{hasMore && (
<div className="p-4">
<button
type="button"
onClick={() => void loadMore()}
disabled={loadingMore}
aria-label="Load more tickets"
className="w-full py-3 rounded-xl border text-sm font-semibold hover:bg-muted/50 transition-colors disabled:opacity-50"
>
{error ? 'Retry' : loadingMore ? 'Loading…' : 'Load more'}
</button>
</div>
)}
</>
)}
</div>
</div>
);
}
```
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 `<div className="...PRIORITY_DOT">` 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 `<Suspense>` 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.
</action>
<verify>
<automated>npx tsc --noEmit --pretty 2>&1 | grep -E "app/mobile/tickets/page\.tsx" || echo "OK: page typechecks"</automated>
</verify>
<acceptance_criteria>
- `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`
</acceptance_criteria>
<done>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.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 2: Verify the new tickets list end-to-end on a real device or simulator</name>
<files>app/mobile/tickets/page.tsx (verifying — not modifying)</files>
<action>Human verification only — see <how-to-verify> 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.</action>
<verify><automated>echo "Manual checkpoint — see resume-signal"</automated></verify>
<done>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.</done>
<what-built>
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.
</what-built>
<how-to-verify>
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.)
</how-to-verify>
<resume-signal>Type "approved" if all 14 checks pass. If any fail, describe the failure precisely (which step, what you saw vs. expected).</resume-signal>
</task>
</tasks>
<threat_model>
## 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. |
</threat_model>
<verification>
1. `npx tsc --noEmit --pretty` passes — no type errors in `app/mobile/tickets/page.tsx`.
2. All `<acceptance_criteria>` 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).
</verification>
<success_criteria>
- 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.
</success_criteria>
<output>
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).
</output>

View file

@ -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/"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<tasks>
<task type="auto" tdd="false">
<name>Task 1: Reskin the in-page header of /mobile/tickets/[id] with back chevron, breadcrumb, and external-link icon</name>
<files>app/mobile/tickets/[id]/page.tsx</files>
<read_first>
- 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
</read_first>
<action>
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
<button onClick={() => router.back()} className="flex items-center gap-1 text-sm text-muted-foreground mb-3 hover:text-foreground">
<ArrowLeft className="w-4 h-4" /> Back
</button>
```
Replace with the new three-slot header. **Important context:** the parent `<div className="px-4 pt-4 pb-3 border-b">` 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
<div className="flex items-center justify-between -mx-4 px-4 py-3 border-b mb-3">
<button
type="button"
onClick={() => router.back()}
aria-label="Back to Tickets"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="w-4 h-4" aria-hidden="true" />
<span>Tickets</span>
</button>
<p className="text-sm font-semibold truncate mx-2 flex-1 text-center">
Tickets / #{ticket.ticket_number}
</p>
<a
href={`/analyzer/ticket/${ticket.id}`}
target="_blank"
rel="noopener noreferrer"
aria-label="Open ticket on desktop"
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
<ExternalLink className="w-4 h-4" aria-hidden="true" />
</a>
</div>
```
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 (`<div className="flex items-start gap-2 mb-2">` with priority + ticket number + status pills) — preserve verbatim.
- The `<h1 className="text-base font-bold leading-snug">{ticket.title}</h1>` — 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 `<div className="px-4 pt-4 pb-3 border-b">` 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 `<HeaderBar>` 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).
</action>
<verify>
<automated>npx tsc --noEmit --pretty 2>&1 | grep -E "app/mobile/tickets/\[id\]/page\.tsx" || echo "OK: detail page typechecks"</automated>
</verify>
<acceptance_criteria>
- `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*</button>" 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`
</acceptance_criteria>
<done>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.</done>
</task>
</tasks>
<threat_model>
## 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. |
</threat_model>
<verification>
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/<an-id>` 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/<id>` in a new tab.
</verification>
<success_criteria>
- 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.
</success_criteria>
<output>
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).
</output>