wulf-pulse/.planning/phases/04-tickets-restyle/04-02-PLAN.md
lorentz 9658640c04 fix(04-01): restore phase 2/3 work lost by worktree soft-reset
The soft reset to 77073ba inadvertently staged deletions of all phase 2
and 3 artifacts. This commit restores them from their source commits so
subsequent task commits build on the complete prior-phase foundation:
- components/mobile/{BottomNav,HeaderBar,KpiCardMobile,MoreDrawer,NeedsAttentionStrip,WorkerStatusRow}
- app/mobile/layout.tsx, dashboard/page.tsx, analyzer/page.tsx
- app/api/mobile/dashboard/route.ts
- All .planning/** files from phases 01-04
- CLAUDE.md, app/layout.tsx, app/styles/brand.css, public/manifest.json
2026-05-03 18:01:14 -04:00

34 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
04 02 execute 2
04-01
app/mobile/tickets/page.tsx
false
TICK-01
TICK-02
TICK-03
TICK-04
TICK-05
TICK-06
truths artifacts key_links
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
path provides contains
app/mobile/tickets/page.tsx Mobile tickets list page wired to TicketFilterStrip + cursor-based /api/mobile/tickets TicketFilterStrip
from to via pattern
app/mobile/tickets/page.tsx /api/mobile/tickets fetch with cursor + filter URL params fetch(.*api/mobile/tickets
from to via pattern
app/mobile/tickets/page.tsx components/mobile/TicketFilterStrip.tsx named import from ['"]@/components/mobile/TicketFilterStrip['"]
from to via pattern
app/mobile/tickets/page.tsx components/mobile/TicketRowSkeleton.tsx named import from ['"]@/components/mobile/TicketRowSkeleton['"]
from to via pattern
app/mobile/tickets/page.tsx MobileTicketListResponse type import type from route file (Phase 3 pattern) import type.*from.*api/mobile/tickets/route
Replace the body of `app/mobile/tickets/page.tsx` with the new shell-aligned implementation: Collapsible URL-synced filter strip, priority-stripe rows, IntersectionObserver-driven infinite scroll with a Load more fallback, skeleton loading state, and empty-state copy per D-20.

Purpose: closes TICK-01 through TICK-06 — the only requirements left after Plan 01 ships the API + presentational components. URL sync via useSearchParams() + router.replace() is the deep-link contract.

Output:

  • Rewritten app/mobile/tickets/page.tsx consuming MobileTicketListResponse from the new route, the TicketFilterStrip and TicketRowSkeleton components from components/mobile/, with infinite scroll + URL sync + priority stripes.
  • One human-verify checkpoint after the rewrite to confirm visual + interaction behavior on a real device.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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

From app/api/mobile/tickets/route.ts:

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:

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:

export function TicketRowSkeleton(): JSX.Element;

Helpers preserved from current page (line 29-37):

function relTime(ts: string | null): string;  // "5m ago" | "3h ago" | "2d ago" | "—"
Task 1: Rewrite app/mobile/tickets/page.tsx with URL-synced filters, priority-stripe rows, and IntersectionObserver infinite scroll app/mobile/tickets/page.tsx - app/mobile/tickets/page.tsx (current 164-line implementation — preserve relTime helper) - .planning/phases/04-tickets-restyle/04-UI-SPEC.md (entire file — class strings and structure are load-bearing) - .planning/phases/04-tickets-restyle/04-CONTEXT.md decisions D-05 through D-21 - components/mobile/TicketFilterStrip.tsx (the prop contract Plan 01 ships) - components/mobile/TicketRowSkeleton.tsx (the skeleton Plan 01 ships) - app/mobile/dashboard/page.tsx (Phase 3 mobile page pattern: 'use client', single load function, useEffect once, error block + Retry) Replace `app/mobile/tickets/page.tsx` end-to-end. The new file structure (target ≤ 220 lines):
1. **Header** — `'use client';` then imports:
   ```typescript
   import { useEffect, useState, useCallback, useRef, useMemo, Suspense } from 'react';
   import Link from 'next/link';
   import { useRouter, useSearchParams } from 'next/navigation';
   import { ChevronRight, Clock, Loader2, RefreshCw } from 'lucide-react';
   import { Button } from '@/components/ui/button';
   import { TicketFilterStrip, type TicketFilterValue, type QueueOption } from '@/components/mobile/TicketFilterStrip';
   import { TicketRowSkeleton } from '@/components/mobile/TicketRowSkeleton';
   import type { MobileTicket, MobileTicketListResponse } from '@/app/api/mobile/tickets/route';
   ```

2. **Constants** (top of module, outside component):
   ```typescript
   const PRIORITY_BORDER: Record<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.
npx tsc --noEmit --pretty 2>&1 | grep -E "app/mobile/tickets/page\.tsx" || echo "OK: page typechecks" - `grep -q "'use client'" app/mobile/tickets/page.tsx` (client component declaration) - `grep -q "Suspense" app/mobile/tickets/page.tsx` (Suspense wrapper for useSearchParams — Next.js 16 requirement) - `grep -q "useSearchParams" app/mobile/tickets/page.tsx` (URL hydration) - `grep -q "router\.replace" app/mobile/tickets/page.tsx` (D-05 — replace not push) - `! grep -q "router\.push" app/mobile/tickets/page.tsx` (no push for filter updates) - `grep -q "TicketFilterStrip" app/mobile/tickets/page.tsx` (uses Plan 01 component) - `grep -q "TicketRowSkeleton" app/mobile/tickets/page.tsx` (uses Plan 01 skeleton) - `grep -q "import type.*MobileTicketListResponse" app/mobile/tickets/page.tsx` (typed response — Phase 3 pattern) - `grep -q "IntersectionObserver" app/mobile/tickets/page.tsx` (D-12) - `grep -q "rootMargin: '200px'" app/mobile/tickets/page.tsx` (D-12 — exact margin) - `grep -q "border-red-500" app/mobile/tickets/page.tsx` (priority 1 — D-15) - `grep -q "border-orange-400" app/mobile/tickets/page.tsx` (priority 2 — D-15) - `grep -q "border-amber-400" app/mobile/tickets/page.tsx` (priority 3 — D-15, NOT yellow) - `grep -q "border-slate-300" app/mobile/tickets/page.tsx` (priority 4 — D-15) - `grep -q "border-l-4" app/mobile/tickets/page.tsx` (4px stripe — D-15) - `grep -q "Load more" app/mobile/tickets/page.tsx` (TICK-06 fallback) - `grep -q 'aria-label="Load more tickets"' app/mobile/tickets/page.tsx` (a11y) - `grep -q 'aria-hidden="true"' app/mobile/tickets/page.tsx` (sentinel a11y) - `grep -q "No tickets match your filters" app/mobile/tickets/page.tsx` (D-20 empty state — filtered) - `grep -q "No tickets to triage right now" app/mobile/tickets/page.tsx` (D-20 empty state — unfiltered) - `grep -q "Clear filters" app/mobile/tickets/page.tsx` (D-20 CTA) - `grep -q "function relTime" app/mobile/tickets/page.tsx` (D-17 helper preserved) - `! grep -q "PRIORITY_DOT" app/mobile/tickets/page.tsx` (D-17 — dot removed) - `! grep -q "border-yellow-400" app/mobile/tickets/page.tsx` (legacy yellow replaced by amber) - `! grep -qE "useSWR|@tanstack/react-query|zustand" app/mobile/tickets/page.tsx` (CLAUDE.md — no forbidden libs) - `! grep -q "?page=" app/mobile/tickets/page.tsx` (no legacy page param) - `npx tsc --noEmit --pretty 2>&1` reports no errors for `app/mobile/tickets/page.tsx` The rewritten page hydrates filters from `useSearchParams()` inside a Suspense boundary, calls `router.replace()` to sync filter changes back to the URL, fetches the cursor-paginated API on first load and on filter changes, advances via cursor on IntersectionObserver intersection (with a Load more fallback), renders priority-stripe rows using the four locked Tailwind border classes, shows skeleton rows on initial load and a small spinner during cursor advances, and renders the two distinct empty-state copies. TypeScript compiles cleanly. Task 2: Verify the new tickets list end-to-end on a real device or simulator app/mobile/tickets/page.tsx (verifying — not modifying) Human verification only — see below for the 14-step checklist. No code changes. Pause execution and wait for the user to confirm the new list page behaves per spec on a phone-width viewport. echo "Manual checkpoint — see resume-signal" User confirms all 14 checklist items pass on a phone-width viewport (real device or DevTools iPhone 15 Pro emulation), or describes precisely which step failed and why. The mobile Tickets list page now uses the new shell-aligned layout: Collapsible filter strip, URL-synced filter state, priority-stripe rows, IntersectionObserver-driven infinite scroll, Load more fallback button, skeleton loading state, and the two D-20 empty-state copies. The detail page link target (`/mobile/tickets/[id]`) is unchanged — that page's header reskin is shipped by Plan 04-03 in parallel. Start the dev server (`npm run dev` → http://localhost:3100) and sign in. Then on a phone-width viewport (or Chrome DevTools iPhone 15 Pro emulation):
1. **Initial load + skeleton** — Navigate to `/mobile/tickets`. You should briefly see 5 skeleton rows (each with a muted left stripe + 3 placeholder lines), then the real tickets render.
2. **Default state** — Filter strip is COLLAPSED. Search input visible. "Filters" button visible. Count line shows "N open tickets". Each row has a 4px colored left stripe (red / orange / amber / slate) — no dot.
3. **Single-tap row** — Tap any row → routes to `/mobile/tickets/[id]` (existing detail page; header reskin from Plan 04-03 may or may not be live yet — body should render either way).
4. **Filter strip expands** — Tap "Filters". Panel reveals four controls: status chips (Open / In Progress / Waiting), priority chips (Critical / High / Medium / Low), queue Select, "Assigned to me" Switch.
5. **URL deep-link — set filters** — Tap "High" priority chip. URL updates IN PLACE to include `?priority=2` (no new history entry — back button takes you OUT of `/mobile/tickets`, not to a previous filter state).
6. **URL deep-link — reload** — Reload the page with the URL still showing `?priority=2`. Filter strip hydrates with "High" already selected; list shows only priority-2 tickets.
7. **Search debounce** — Type in the search box. URL updates ~400ms after you stop typing, not on every keystroke.
8. **Clear all** — Tap "Clear all". Status returns to default (Open + In Progress + Waiting), priority/queue/mine reset, URL params clear.
9. **Infinite scroll** — Scroll to the bottom of the list. The next ~25 rows append automatically (small spinner appears briefly above "Load more"). The page does NOT navigate to a new URL.
10. **Load more button** — Confirm the "Load more" button is visible and focusable (Tab to it). Clicking it also advances the list. When the list is exhausted, the button disappears.
11. **Empty state with filters** — Set filters that return no rows (e.g., a non-existent search term). Page shows "No tickets match your filters" + a "Clear filters" button. Tapping it restores defaults.
12. **No charts / no recharts imports** — Sanity check: open DevTools network tab and confirm only `/api/mobile/tickets` is called (no extra count or queue endpoints).
13. **Priority colors** — A row with `priority=1` has `border-red-500`, `priority=2` `border-orange-400`, `priority=3` `border-amber-400`, `priority=4` `border-slate-300`. These are direct Tailwind palette references per UI-SPEC §"Priority Stripe Colors".
14. **Detail back nav (Plan 04-03 dependency)** — From a detail page, the device back gesture returns you to the list at the same scroll position with filters intact. (Plan 04-03 reskins the in-page back chevron — UX should still work without it.)
Type "approved" if all 14 checks pass. If any fail, describe the failure precisely (which step, what you saw vs. expected).

<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>
1. `npx tsc --noEmit --pretty` passes — no type errors in `app/mobile/tickets/page.tsx`. 2. All `` grep checks for Task 1 return success. 3. Human-verify checklist (Task 2) reaches "approved". 4. Phase-level smoke: visit `/mobile/tickets`, then `/mobile/tickets?priority=1`, then `/mobile/tickets?status=&priority=&q=zzz_no_match` — three different rendered states (default list, priority-1 only, empty state with Clear filters CTA).

<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>
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).