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]`
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 37 (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 | - |

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>