docs(04): capture phase context

This commit is contained in:
lorentz 2026-05-03 17:37:05 -04:00
parent 05a3d44045
commit 28b5845ee8
2 changed files with 269 additions and 0 deletions

View file

@ -0,0 +1,142 @@
# Phase 4: Tickets Restyle - Context
**Gathered:** 2026-05-03 (auto mode)
**Status:** Ready for planning
<domain>
## 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.
</domain>
<decisions>
## 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
</decisions>
<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>
<specifics>
## 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.
</specifics>
<deferred>
## 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
</deferred>
---
*Phase: 04-tickets-restyle*
*Context gathered: 2026-05-03*

View file

@ -0,0 +1,127 @@
# Phase 4: Tickets Restyle - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** 2026-05-03
**Phase:** 04-tickets-restyle
**Mode:** auto (--auto --chain)
**Areas discussed:** Filter strip, URL sync, Pagination model, Infinite scroll trigger, List row presentation, Detail page header, Empty/loading states
---
## Filter strip
| Option | Description | Selected |
|--------|-------------|----------|
| Always-expanded inline filters | All controls visible by default — more taps reachable | |
| `Collapsible` from shadcn, default collapsed | TICK-01 spec; phone-first density | ✓ |
| Drawer-based filter sheet | Filters open in a Sheet — feels heavier, more taps | |
**Auto choice rationale:** TICK-01 explicitly says "default collapsed". Spec-locked.
---
## Filter set
| Option | Description | Selected |
|--------|-------------|----------|
| Status + priority + queue + assigned-to-me | Spec set | ✓ |
| Add company filter | More flexibility; out of TICK-* scope | |
| Add date range | Could be useful; out of TICK-* scope | |
**Auto choice rationale:** TICK-01 enumerates the four filters. Anything more is scope creep.
---
## URL sync mechanism
| Option | Description | Selected |
|--------|-------------|----------|
| `router.push()` on every filter change | Each filter tweak adds a back-stack entry | |
| `router.replace()` on every filter change | Filter tweaks don't pollute back stack | ✓ |
| Manual `history.replaceState()` | Lower-level; loses Next.js rerouting integration | |
**Auto choice rationale:** `router.replace()` is the App Router idiom for filter UIs. Back button should return to whatever surface the user came from, not 8 filter mutations ago.
---
## Pagination model
| Option | Description | Selected |
|--------|-------------|----------|
| Keep page-based `?page=N&limit=30` | Simpler, current behavior | |
| Cursor `?cursor=&limit=25` with last_activity_date+id | Stable under writes; matches triage workflow | ✓ |
| Cursor on created_date+id | Stable but doesn't reflect "recent activity" priority | |
**Auto choice rationale:** TICK-05 mandates cursor-based ~25/page. Choosing `last_activity_date DESC, id DESC` because managers triage by "what just changed" not "what was created".
---
## Infinite scroll trigger
| Option | Description | Selected |
|--------|-------------|----------|
| `react-intersection-observer` library | Hook abstraction, extra dep | |
| Browser-native `IntersectionObserver` | No dep, ~20 lines | ✓ |
| Scroll-event listener with throttle | Less precise, more re-renders | |
**Auto choice rationale:** CLAUDE.md prohibits adding new state libraries; native IntersectionObserver is sufficient.
---
## "Load more" fallback
| Option | Description | Selected |
|--------|-------------|----------|
| Skip — sentinel handles it | Fails accessibility | |
| Always-rendered focusable button below sentinel | TICK-06 mandates accessibility | ✓ |
| Show only when keyboard navigation detected | Brittle, not robust | |
**Auto choice rationale:** TICK-06 requires the fallback button. Always-rendered keeps it focusable and visible to screen readers.
---
## List row priority indicator
| Option | Description | Selected |
|--------|-------------|----------|
| Existing dot (bg-color-N) | Smaller, less prominent | |
| Left-edge stripe via `border-l-4` | TICK-03 spec | ✓ |
| Background tint of whole row | Too heavy on phone | |
**Auto choice rationale:** TICK-03 explicitly specifies left-edge stripe.
---
## Detail page header
| Option | Description | Selected |
|--------|-------------|----------|
| Refactor full detail page | Out of scope per TICK-07 | |
| Reskin in-page header only (back chevron + breadcrumb + ext link) | TICK-07 spec | ✓ |
**Auto choice rationale:** TICK-07 explicitly says "header reskinned... body kept largely as-is".
---
## Empty/loading states
| Option | Description | Selected |
|--------|-------------|----------|
| Spinner only on load | Janky, no skeleton | |
| Skeleton rows initial + inline spinner subsequent + empty-state messages | Polished, fits design tokens | ✓ |
**Auto choice rationale:** Standard mobile pattern; existing `Skeleton` shadcn primitive available.
---
## Auto-resolved scope creep checks
- **Bulk actions** — flagged and deferred (read-tap-detail is the manager workflow)
- **Saved presets** — deferred to a future phase
- **Detail body refactor** — explicitly excluded by TICK-07
## External research
None. Decisions were derivable from spec + existing code patterns.