wulf-pulse/.planning/phases/04-tickets-restyle/04-CONTEXT.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

9.3 KiB

Phase 4: Tickets Restyle - Context

Gathered: 2026-05-03 (auto mode) Status: Ready for planning

## Phase Boundary

Reskin 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 Decisions

Filter strip

  • D-01: Use shadcn Collapsible component 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() (NOT router.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=30 with ?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 DESC matches 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 }. When nextCursor is null, list is exhausted.
  • D-11: Page size is 25 (per TICK-05). The limit query param is accepted but capped server-side at 25 to prevent abuse.

Infinite scroll trigger

  • D-12: Use the browser-native IntersectionObserver API (no library). A sentinel <div ref={sentinelRef} /> lives at the end of the list; when it intersects the viewport with rootMargin: '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-300 Reason: TICK-03 spec.
  • 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 (ArrowLeft icon → 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 from app/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 3100
  • DESIGN.md — token usage, navigation IA, current cleanup backlog
  • ARCHITECTURE.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, with kiosk_settings company scoping)

</canonical_refs>

<code_context>

Existing Code Insights

Reusable Assets

  • Collapsible from shadcn (components/ui/collapsible.tsx) — exists in shadcn primitives
  • relTime() helper inline in app/mobile/tickets/page.tsx:29-37 — keep
  • kiosk_settings company scoping helper getMobileCompanyFilter() in app/api/mobile/tickets/route.ts:3-26 — keep, do not regress
  • lucide-react icons (Search, X, RefreshCw, ChevronRight, Clock, ArrowLeft) — already in deps
  • shadcn primitives: Button, Input, Select, Toggle (or Switch), Skeleton — all available in components/ui/
  • IntersectionObserver — browser-native, no dep needed

Established Patterns

  • Mobile pages are 'use client' with useState + useEffect + fetch('/api/mobile/...') — NO SWR, NO react-query
  • API routes call postgresClient.query() with parameterized SQL; manual snake_case → camelCase transform
  • requireAuth() from lib/auth-utils.ts is the auth gate for all /api/* routes
  • TypeScript interfaces for API response shapes are exported from the route file and imported via import type in 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_settings table for company scoping — existing, already wired into the current ticket route

</code_context>

## Specific Ideas
  • Mirror Phase 3's pattern: API route exports MobileTicketListResponse and the row interface; page imports the response type via import 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.
## Deferred Ideas
  • 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 — /mobile is phone-only by milestone constraint

Phase: 04-tickets-restyle Context gathered: 2026-05-03