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
9.3 KiB
Phase 4: Tickets Restyle - Context
Gathered: 2026-05-03 (auto mode) Status: Ready for planning
## Phase BoundaryReskin the mobile Tickets surfaces (/mobile/tickets list + /mobile/tickets/[id] detail header) to match the new shell. Replace the current sticky search/filter bar with a Collapsible URL-synced filter strip. Replace page-based pagination (?page=N, 30/page) with cursor-based infinite scroll (~25/page, IntersectionObserver) plus a "Load more" fallback. Add priority left-edge stripes to list rows. Detail page body stays largely as-is — only its header is reskinned.
In scope: list page UI + filter strip + URL deep-linking + cursor pagination API change + detail header reskin.
Out of scope: filter set expansion (only the four spec'd filters), detail body refactor, comment/attach UI changes, search UX overhaul beyond what existing search input provides.
## Implementation DecisionsFilter strip
- D-01: Use shadcn
Collapsiblecomponent for the filter strip; default state is collapsed (only the search input + "Filters" toggle button visible). Reason: TICK-01 spec says default collapsed; matches phone-first density goal. - D-02: Expanded panel exposes exactly four controls: status (multi-select chips: Open/In Progress/Waiting), priority (chips: Critical/High/Medium/Low), queue (Select dropdown sourced from existing queues), and an "Assigned to me" toggle. No additional filter capabilities this phase.
- D-03: Keep the existing search input visible at all times (above the Collapsible toggle), debounced 400ms — match current behavior.
- D-04: "Clear all" button appears in the expanded panel when ≥1 filter is active.
URL sync
- D-05: Use
useSearchParams()+router.replace()(NOTrouter.push()) to update query string on filter changes. Reason: replace prevents back-button pollution from filter tweaks; matches Next.js App Router convention. - D-06: URL param keys:
q(search),status(comma-separated ints),priority(comma-separated ints),queue(int),mine(1/absent). On reload, page hydrates filter state from these params — deep link works. - D-07: Cursor (
cursor) is intentionally not persisted to URL — fresh visits always start at the top of the list.
Pagination — cursor model
- D-08: Replace
?page=N&limit=30with?cursor=<opaque>&limit=25. Reason: TICK-05 mandates cursor-based ~25/page. - D-09: Cursor is a base64-encoded JSON of
{ last_activity_date: ISO string, id: number }. Reason:last_activity_date DESC, id DESCmatches the manager's triage mental model (recently-touched bubbles up); tie-breaker on id makes it stable. - D-10: API returns
{ tickets: [...], nextCursor: string | null, hasMore: boolean }. WhennextCursoris null, list is exhausted. - D-11: Page size is 25 (per TICK-05). The
limitquery param is accepted but capped server-side at 25 to prevent abuse.
Infinite scroll trigger
- D-12: Use the browser-native
IntersectionObserverAPI (no library). A sentinel<div ref={sentinelRef} />lives at the end of the list; when it intersects the viewport withrootMargin: '200px', fetch next page. - D-13: Guard against duplicate fetches: the sentinel callback is a no-op if
loadingMore || !hasMore. - D-14: Always render a focusable "Load more" button below the sentinel as the accessibility fallback (TICK-06). Clicking it triggers the same fetch path. Hide only when
!hasMore.
List row presentation
- D-15: Each row has a 4px-wide left-edge color stripe via
border-l-4+ a priority color class:- 1 (Critical) →
border-red-500 - 2 (High) →
border-orange-400 - 3 (Medium) →
border-amber-400 - 4 (Low) →
border-slate-300Reason: TICK-03 spec.
- 1 (Critical) →
- D-16: Row body shows: ticket number (mono small), title (1-line truncate), company (muted small), age (relative time, muted), assignee (initials avatar or text). Single-tap navigates to
/mobile/tickets/[id](TICK-04). - D-17: Remove the priority dot from the existing row (replaced by the stripe). Keep
relTime()helper as-is.
Detail page header
- D-18: Detail page (
/mobile/tickets/[id]) keeps its current body. Only the in-page header bar at the top is reskinned: replace the current title bar with a row containing a back chevron (ArrowLefticon →router.back()), the breadcrumb "Tickets / #{ticket_number}", and an external-link icon that opens the desktop ticket URL. The shell's HeaderBar (Wulf + Bell + avatar) already renders above it fromapp/mobile/layout.tsx— no change there. - D-19: No structural changes to the detail body, comments, or attachments — out of scope for this phase.
Empty state
- D-20: When the active filter set returns zero results, render a centered message: "No tickets match your filters" with a "Clear filters" button. When there are no tickets at all (no filters, empty result), render "No tickets to triage right now" with a refresh affordance.
Loading & error states
- D-21: Initial load → skeleton rows (5 placeholder cards). Subsequent infinite scroll → small inline spinner above the Load more button. Error → toast + Load more button shows "Retry".
Claude's Discretion
- Exact spacing/typography within rows (match existing density)
- Whether to memoize row components (only if perf measurement warrants)
- Cursor encoding helper location (lib/services or inline in route)
- Exact skeleton visual
<canonical_refs>
Canonical References
Downstream agents MUST read these before planning or implementing.
Phase spec
docs/superpowers/specs/2026-05-03-mobile-shell-design.md§6.2 (Tickets) — spec for filter strip, priority stripes, cursor pagination, detail header reskin.planning/REQUIREMENTS.md(TICK-01 through TICK-07) — locked acceptance criteria
Project conventions
CLAUDE.md— Pulse stack rules (no SWR/react-query, no ORM, fetch-from-clients pattern),/mobile/*route boundary,port 3100DESIGN.md— token usage, navigation IA, current cleanup backlogARCHITECTURE.md— runtime, data flow, workers (no impact this phase)
Prior phase context
.planning/phases/02-mobile-shell/02-CONTEXT.md— MobileShell + HeaderBar decisions (the detail header docks under this).planning/phases/03-dashboard-restyle/03-01-SUMMARY.md— pattern for new/api/mobile/*shape with TypeScript interface exports (mirror this for tickets endpoint)
Existing code (entry points)
app/mobile/tickets/page.tsx— current list page (164 lines, page-based)app/mobile/tickets/[id]/page.tsx— current detail page (357 lines, body kept as-is)app/api/mobile/tickets/route.ts— current API route (90 lines, page-based, withkiosk_settingscompany scoping)
</canonical_refs>
<code_context>
Existing Code Insights
Reusable Assets
Collapsiblefrom shadcn (components/ui/collapsible.tsx) — exists in shadcn primitivesrelTime()helper inline inapp/mobile/tickets/page.tsx:29-37— keepkiosk_settingscompany scoping helpergetMobileCompanyFilter()inapp/api/mobile/tickets/route.ts:3-26— keep, do not regresslucide-reacticons (Search, X, RefreshCw, ChevronRight, Clock, ArrowLeft) — already in deps- shadcn primitives:
Button,Input,Select,Toggle(orSwitch),Skeleton— all available incomponents/ui/ IntersectionObserver— browser-native, no dep needed
Established Patterns
- Mobile pages are
'use client'withuseState+useEffect+fetch('/api/mobile/...')— NO SWR, NO react-query - API routes call
postgresClient.query()with parameterized SQL; manual snake_case → camelCase transform requireAuth()fromlib/auth-utils.tsis the auth gate for all/api/*routes- TypeScript interfaces for API response shapes are exported from the route file and imported via
import typein the page (pattern from Phase 3)
Integration Points
app/mobile/layout.tsx(Phase 2) renders the new shell — Tickets pages dock inside it; no layout changes needed- BottomNav active-tab detection uses
pathname.startsWith('/mobile/tickets')— already correct kiosk_settingstable for company scoping — existing, already wired into the current ticket route
</code_context>
## Specific Ideas- Mirror Phase 3's pattern: API route exports
MobileTicketListResponseand the row interface; page imports the response type viaimport type. - Cursor encoder/decoder should be small (~10 lines) and live inline in the route file unless a second consumer appears.
- Status filter should default to "Open + In Progress + Waiting" (i.e.
status != 5) matching the existing route's hardcoded filter, so the URL with no status param yields the same default the manager expects.
- Bulk actions (mass close/assign) — out of scope; manager use case here is read-and-tap-into-detail
- Saved filter presets — not in TICK-* scope; consider for a future phase
- Detail page body refactor (comments, time entries) — explicitly out of scope (TICK-07 says "body kept largely as-is")
- Server-sent push of new tickets — no streaming this iteration
- Search highlight in row — nice-to-have, not in TICK-* scope
- Tablet/desktop responsive breakpoints —
/mobileis phone-only by milestone constraint
Phase: 04-tickets-restyle Context gathered: 2026-05-03