From 5d87455e498fbd9ed5ba4f18d32bba3020d74f80 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sun, 3 May 2026 18:12:00 -0400 Subject: [PATCH] feat(04-02): rewrite mobile tickets list page with URL-synced filters, priority stripes, and IntersectionObserver infinite scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picked from 04-02 worktree (b27db7d, 4ba89a0). The full worktree merge included unrelated regressions of phase 2/3 work — only the intended changes (app/mobile/tickets/page.tsx + SUMMARY.md) were kept. - Suspense wrapper for useSearchParams (Next.js 16 requirement) - URL-synced filter state via router.replace - TicketFilterStrip wired with controlled props - Priority-stripe rows (border-l-4 with red/orange/amber/slate) - IntersectionObserver infinite scroll + Load more fallback - toast.error() in load catch blocks per D-21 - Empty states per D-20 --- .../04-tickets-restyle/04-02-SUMMARY.md | 136 +++++++ app/mobile/tickets/page.tsx | 342 +++++++++++++----- 2 files changed, 380 insertions(+), 98 deletions(-) create mode 100644 .planning/phases/04-tickets-restyle/04-02-SUMMARY.md diff --git a/.planning/phases/04-tickets-restyle/04-02-SUMMARY.md b/.planning/phases/04-tickets-restyle/04-02-SUMMARY.md new file mode 100644 index 0000000..fa2abc8 --- /dev/null +++ b/.planning/phases/04-tickets-restyle/04-02-SUMMARY.md @@ -0,0 +1,136 @@ +--- +phase: 04-tickets-restyle +plan: 02 +subsystem: ui +tags: [mobile, tickets, infinite-scroll, url-sync, cursor-pagination, suspense, shadcn, typescript] + +# Dependency graph +requires: + - phase: 04-tickets-restyle + plan: 01 + provides: "GET /api/mobile/tickets cursor-paginated endpoint, TicketFilterStrip, TicketRowSkeleton" + - phase: 02-mobile-shell-more-drawer + provides: "Mobile layout shell (HeaderBar, BottomNav) that the tickets page docks inside" +provides: + - "app/mobile/tickets/page.tsx: fully wired mobile tickets list — URL-synced filters, priority-stripe rows, IntersectionObserver infinite scroll, skeleton load, D-20 empty states" +affects: [] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "useSearchParams + Suspense boundary: Next.js 16 requirement for URL param hydration in client components" + - "router.replace (not push) for filter URL sync: prevents back-button history pollution (D-05)" + - "Cursor NOT in URL: fresh visits always start at page 1 (D-07)" + - "IntersectionObserver with rootMargin 200px + Load more fallback for a11y (D-12/D-14)" + - "toast.error() in BOTH loadFirst and loadMore catch blocks alongside setError (D-21)" + - "Queue options derived from first-page tickets — no separate /api/mobile/queues endpoint" + - "openTotal approximated as tickets.length + (hasMore ? 1 : 0) — precise count deferred" + +key-files: + created: [] + modified: + - app/mobile/tickets/page.tsx + +key-decisions: + - "Default status [1, 8, 7] applied client-side when URL has no status param — matches server default and keeps URL clean on unfiltered visits" + - "status='' in URL means explicit 'no status filter'; status absent means default [1, 8, 7]" + - "Queue options built incrementally from first-page ticket data — acceptable for v1, no new endpoint needed" + - "openTotal is tickets.length + (hasMore ? 1 : 0) — visible label reads 'N open tickets'; precision deferred until count endpoint exists" + - "toast.error() always fires alongside setError in catch blocks per D-21" + +requirements-completed: [TICK-01, TICK-02, TICK-03, TICK-04, TICK-05, TICK-06] + +# Metrics +duration: 8min +completed: 2026-05-03 +--- + +# Phase 4 Plan 02: Mobile Tickets List Page Wiring Summary + +**Rewrote app/mobile/tickets/page.tsx with URL-synced Collapsible filter strip, priority-stripe rows, IntersectionObserver infinite scroll, Load-more fallback, skeleton loading, toast.error on failure, and D-20 empty states** + +## Performance + +- **Duration:** 8 min +- **Completed:** 2026-05-03T22:09:00Z +- **Tasks:** 1 code task + 1 human-verify checkpoint (auto-approved) +- **Files modified:** 1 + +## Accomplishments + +- Replaced the 164-line page-based `MobileTickets` component end-to-end with a 310-line `MobileTicketsPage` (Suspense shell) + `MobileTicketsInner` (state machine) +- Wired `TicketFilterStrip` (Plan 01) and `TicketRowSkeleton` (Plan 01) into the page +- URL filter sync via `useSearchParams` + `router.replace` — all five params (`q`, `status`, `priority`, `queue`, `mine`) are round-trippable; reload hydrates filter state +- Cursor-based infinite scroll via `IntersectionObserver` (rootMargin 200px) with a focusable `Load more` fallback button +- Priority-stripe rows: `border-l-4` + locked Tailwind classes `border-red-500` / `border-orange-400` / `border-amber-400` / `border-slate-300` +- Two D-20 empty states: "No tickets match your filters" (filtered) and "No tickets to triage right now" (unfiltered) +- `toast.error()` in both `loadFirst` and `loadMore` catch blocks (D-21 compliance) +- Removed priority dot (`PRIORITY_DOT`), switched title from `line-clamp-2` to `truncate` (D-17 / UI-SPEC) + +## URL <-> Filter State Mapping + +| URL param | Absent behavior | Present value | Component effect | +|-----------|----------------|---------------|-----------------| +| `q` | `""` (no search) | string | Search input value; debounced 400ms | +| `status` | Default `[1, 8, 7]` (Open + In Progress + Waiting) | comma-separated ints | Status chip selection | +| `status=` | Empty string → no status filter | `[]` | All statuses shown | +| `priority` | `[]` (no filter) | comma-separated ints | Priority chip selection | +| `queue` | `null` (all queues) | int | Queue Select value | +| `mine` | `false` | `1` | Assigned-to-me Switch | + +Default status `[1, 8, 7]` matches the server-side default — when no `status` param is present, both client and server show the same set of tickets without the URL being polluted with a status parameter. + +## Queue List Derivation + +No `/api/mobile/queues` endpoint was added. Queue options are derived incrementally from ticket data seen in the first page load: `queue_id` + `queue_label` from each `MobileTicket` are deduped by id and sorted alphabetically. The Select control works correctly because it always receives the most recent set after first load. This is acceptable for v1; adding a dedicated queues endpoint is deferred. + +## Open Total Approximation + +`openTotal = tickets.length + (hasMore ? 1 : 0)` — the visible label reads "N open tickets". When `hasMore` is false, this is exact. When `hasMore` is true, it reads as ">N" semantically (though the UI just shows the count). A precise count endpoint is deferred to a future phase. + +## Task Commits + +1. **Task 1: Rewrite mobile tickets list page** — `b27db7d` (feat) + +## Files Modified + +- `app/mobile/tickets/page.tsx` — Full rewrite: 164 lines (old) → 310 lines (new) + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] Restored Plan 01 route.ts from HEAD after worktree working-tree mismatch** +- **Found during:** Task 1 TypeScript check (errors: `MobileTicket` and `MobileTicketListResponse` not exported from route) +- **Issue:** The working tree contained the legacy page-based route.ts rather than the cursor-paginated Plan 01 version. This is a consequence of the worktree reset — git HEAD had the Plan 01 route, but the working tree files were from the pre-reset state. +- **Fix:** `git checkout 422ea2bd -- app/api/mobile/tickets/route.ts` to restore the Plan 01 cursor-paginated route with exported interfaces. +- **Files modified:** `app/api/mobile/tickets/route.ts` (restored to Plan 01 state — not a new change) +- **Commit:** Included in `b27db7d` (staged alongside page.tsx) + +**2. [Rule 1 - Bug] Fixed implicit `any` TypeScript error in initials avatar** +- **Found during:** Task 1 TypeScript check +- **Issue:** `t.assigned_to.split(' ').map(s => s[0])` — `s` was implicitly `any` in strict mode +- **Fix:** Added explicit type annotation: `.map((s: string) => s[0])` +- **Files modified:** `app/mobile/tickets/page.tsx` +- **Commit:** `b27db7d` + +### Checkpoint Auto-approval + +**Task 2: Human-verify checkpoint** — Auto-approved per auto-mode active in parent orchestration. `⚡ Auto-approved: mobile tickets list page with filter strip, priority stripes, infinite scroll, and URL sync.` + +## Known Stubs + +None — all data is fetched from live `/api/mobile/tickets`. No hardcoded values flow to UI rendering. Queue options are derived from real ticket data. The `openTotal` approximation is documented above and is intentional, not a stub. + +## Threat Flags + +No new network endpoints, auth paths, file access patterns, or schema changes were introduced. The URL param threat mitigations documented in the plan's `` (T-04-07 through T-04-10) are implemented: + +- T-04-07: `parseFilterFromSearch()` runs `parseInt + isNaN` filter — non-numeric tokens silently dropped +- T-04-09: Search debounced 400ms; other filters are discrete actions + +--- + +*Phase: 04-tickets-restyle* +*Completed: 2026-05-03* diff --git a/app/mobile/tickets/page.tsx b/app/mobile/tickets/page.tsx index 8680fcc..80ae646 100644 --- a/app/mobile/tickets/page.tsx +++ b/app/mobile/tickets/page.tsx @@ -1,32 +1,25 @@ 'use client'; -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, useRef, useMemo, Suspense } from 'react'; import Link from 'next/link'; -import { Search, X, RefreshCw, ChevronRight, Clock } from 'lucide-react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { ChevronRight, Clock, Loader2, RefreshCw } from 'lucide-react'; +import { toast } from 'sonner'; +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'; -interface Ticket { - 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; -} - -const PRIORITY_DOT: Record = { - 1: 'bg-red-500', 2: 'bg-orange-400', 3: 'bg-yellow-400', 4: 'bg-slate-300', -}; -const PRIORITY_LABEL: Record = { - 1: 'Critical', 2: 'High', 3: 'Medium', 4: 'Low', +const PRIORITY_BORDER: Record = { + 1: 'border-red-500', + 2: 'border-orange-400', + 3: 'border-amber-400', + 4: 'border-slate-300', }; -function relTime(ts: string | null) { +const DEFAULT_STATUS: number[] = [1, 8, 7]; // Open + In Progress + Waiting (matches API default) + +function relTime(ts: string | null): string { if (!ts) return '—'; const diff = Date.now() - new Date(ts).getTime(); const m = Math.floor(diff / 60000); @@ -36,107 +29,247 @@ function relTime(ts: string | null) { return `${Math.floor(h / 24)}d ago`; } -export default function MobileTickets() { - const [tickets, setTickets] = useState([]); - const [total, setTotal] = useState(0); - const [page, setPage] = useState(1); - const [search, setSearch] = useState(''); - const [debouncedSearch, setDebouncedSearch] = useState(''); - const [priority, setPriority] = useState(''); +function parseFilterFromSearch(sp: URLSearchParams): TicketFilterValue { + const parseIntList = (raw: string | null): number[] => { + if (raw === null) return []; + if (raw === '') return []; + 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; +} + +export default function MobileTicketsPage() { + return ( + }> + + + ); +} + +function MobileTicketsInner() { + const router = useRouter(); + const searchParams = useSearchParams(); + + // Filter state — initial value from URL (deep-link hydration per D-06) + const initialFilter = useMemo(() => parseFilterFromSearch(new URLSearchParams(searchParams.toString())), []); + const [filter, setFilter] = useState(initialFilter); + + // Debounced search — separate from filter so other filters update immediately + const [debouncedQ, setDebouncedQ] = useState(initialFilter.q); + useEffect(() => { + const t = setTimeout(() => setDebouncedQ(filter.q), 400); + return () => clearTimeout(t); + }, [filter.q]); + + // List state + const [tickets, setTickets] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + const [hasMore, setHasMore] = useState(false); + const [openTotal, setOpenTotal] = useState(0); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(null); + const [queueOptions, setQueueOptions] = useState([]); - // Debounce search - useEffect(() => { - const t = setTimeout(() => setDebouncedSearch(search), 400); - return () => clearTimeout(t); - }, [search]); + // 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; + }, []); - const load = useCallback(async (pg = 1, append = false) => { - if (pg === 1) setLoading(true); else setLoadingMore(true); + // Fetch first page (filters changed) + const loadFirst = useCallback(async (f: TicketFilterValue, q: string) => { + setLoading(true); + setError(null); try { - const params = new URLSearchParams({ page: String(pg) }); - if (debouncedSearch) params.set('q', debouncedSearch); - if (priority) params.set('priority', priority); - const r = await fetch(`/api/mobile/tickets?${params}`); - const d = await r.json(); - setTickets(prev => append ? [...prev, ...d.tickets] : d.tickets); - setTotal(d.total); - setPage(pg); + const sp = buildApiParams(f, q, null); + const r = await fetch(`/api/mobile/tickets?${sp.toString()}`); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const data: MobileTicketListResponse = await r.json(); + setTickets(data.tickets); + setNextCursor(data.nextCursor); + setHasMore(data.hasMore); + // Approximate "open total" from first page until a count endpoint exists; keep tickets.length when hasMore=false + setOpenTotal(data.tickets.length + (data.hasMore ? 1 : 0)); + // Derive queue options from the first page so the Select shows real labels (best-effort; deduped by id) + setQueueOptions(prev => { + const seen = new Map(); + for (const opt of prev) seen.set(opt.id, opt); + for (const t of data.tickets) { + if (t.queue_id && t.queue_label && !seen.has(t.queue_id)) { + seen.set(t.queue_id, { id: t.queue_id, label: t.queue_label }); + } + } + return Array.from(seen.values()).sort((a, b) => a.label.localeCompare(b.label)); + }); + } catch (e) { + const msg = e instanceof Error ? e.message : 'Failed to load tickets'; + setError(msg); + toast.error(msg); } finally { - setLoading(false); setLoadingMore(false); + setLoading(false); } - }, [debouncedSearch, priority]); + }, [buildApiParams]); - useEffect(() => { load(1, false); }, [load]); + // 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) { + const msg = e instanceof Error ? e.message : 'Failed to load more tickets'; + setError(msg); + toast.error(msg); + } finally { + setLoadingMore(false); + } + }, [loadingMore, hasMore, nextCursor, filter, debouncedQ, buildApiParams]); - const hasMore = tickets.length < total; + // Reload first page when filter or debounced search changes (D-05/D-06: also push URL) + useEffect(() => { + const next = filterToSearch({ ...filter, q: debouncedQ }); + const nextStr = next.toString(); + if (nextStr !== searchParams.toString()) { + router.replace(`/mobile/tickets${nextStr ? `?${nextStr}` : ''}`, { scroll: false }); + } + void loadFirst(filter, debouncedQ); + // Intentionally exclude searchParams from deps to prevent loop with router.replace + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedQ, filter.status, filter.priority, filter.queue, filter.mine, loadFirst, router]); + // IntersectionObserver — infinite scroll trigger (D-12, D-13) + const sentinelRef = useRef(null); + useEffect(() => { + const node = sentinelRef.current; + if (!node) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && hasMore && !loadingMore && !loading) { + void loadMore(); + } + }, + { rootMargin: '200px' }, + ); + observer.observe(node); + return () => observer.disconnect(); + }, [hasMore, loadingMore, loading, loadMore]); + + // Clear all (D-04, D-20 empty-state CTA) + const clearAll = useCallback(() => { + setFilter({ q: '', status: [...DEFAULT_STATUS], priority: [], queue: null, mine: false }); + }, []); + + const filtered = isFilterModified(filter); + + // ───── Render ───── return (
- {/* Search + filter bar */} -
-
- - setSearch(e.target.value)} - className="w-full pl-9 pr-9 py-2.5 rounded-xl border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30" - /> - {search && ( - - )} -
-
- {(['', '1', '2', '3', '4'] as const).map(p => ( - - ))} -
-

{total} open tickets

-
+ - {/* List */}
{loading ? ( -
- +
+ {Array.from({ length: 5 }).map((_, i) => )}
) : tickets.length === 0 ? ( -
No tickets found
+ // Empty state (D-20) +
+ {filtered ? ( + <> +

No tickets match your filters

+ + + ) : ( + <> +

No tickets to triage right now

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

{t.title}

- +

{t.title}

+
-

{t.company_name}

+

{t.company_name}

{t.ticket_number} {t.queue_label && ( {t.queue_label} )} + {t.assigned_to && ( + + {t.assigned_to.split(' ').map((s: string) => s[0]).filter(Boolean).slice(0, 2).join('').toUpperCase() || '·'} + + )} - +
@@ -145,14 +278,27 @@ export default function MobileTickets() { ))}
+ {/* Sentinel — IntersectionObserver target (D-12) */} +