test(04): persist human verification items as UAT

This commit is contained in:
lorentz 2026-05-03 18:17:18 -04:00
parent 5d87455e49
commit 5b6a8ff5d5
2 changed files with 280 additions and 0 deletions

View file

@ -0,0 +1,74 @@
---
status: partial
phase: 04-tickets-restyle
source: [04-VERIFICATION.md]
started: 2026-05-03T00:00:00Z
updated: 2026-05-03T00:00:00Z
---
## Current Test
[awaiting human testing]
## Tests
### 1. Filter strip default-collapsed and expand/collapse
expected: |
Open `/mobile/tickets` on iPhone 15 Pro emulation (393×852).
- On load: filter strip is collapsed (only "Filters" toggle and search visible)
- Tap "Filters" → strip expands smoothly revealing status chips, priority chips, queue Select, and "Assigned to me" Switch
- Tap "Filters" again → collapses
result: [pending]
### 2. URL deep-link hydration
expected: |
- Navigate to `/mobile/tickets?priority=2&mine=1` directly
- Filters hydrate: "High" priority chip selected, "Assigned to me" toggled on
- Adjust a filter — URL updates via router.replace (no new history entry)
- Browser back exits the page rather than reverting the filter mutation
result: [pending]
### 3. IntersectionObserver infinite scroll
expected: |
- Load page, scroll to bottom of first ~25 rows
- Next page (~25 more) auto-loads when last row is ~200px from viewport
- No double-fetch when scrolling fast
- When `nextCursor` is null, no further fetches occur
result: [pending]
### 4. Load more fallback button (accessibility)
expected: |
- Tab key reaches the "Load more" button below the sentinel
- Press Enter or Space triggers the next page fetch
- Button hides when `hasMore` is false
result: [pending]
### 5. Priority stripe colors render correctly
expected: |
- Critical (priority=1) row has a red left border (`border-red-500`)
- High (priority=2) → orange left border
- Medium (priority=3) → amber left border
- Low (priority=4) → slate left border
- Confirms Tailwind didn't purge dynamic priority classes
result: [pending]
### 6. Detail page header layout
expected: |
- Tap a ticket row → navigate to `/mobile/tickets/[id]`
- Shell HeaderBar (Wulf mark + bell + avatar) renders at top
- Below it: new in-page header with back chevron + "Tickets" + breadcrumb "Tickets / #{number}" + external-link icon
- Tapping back chevron returns to list (preserving filter URL state)
- Tapping external-link icon opens desktop ticket page in new tab
- No double-border or visual overlap between the two header levels
result: [pending]
## Summary
total: 6
passed: 0
issues: 0
pending: 6
skipped: 0
blocked: 0
## Gaps

View file

@ -0,0 +1,206 @@
---
phase: 04-tickets-restyle
verified: 2026-05-03T22:15:02Z
status: human_needed
score: 5/5 must-haves verified
gaps: []
human_verification:
- test: "Filter strip collapse/expand on real device or DevTools phone emulation"
expected: "Opening /mobile/tickets shows collapsed filter strip (search input + Filters button only); tapping Filters reveals status chips, priority chips, queue Select, and Assigned-to-me Switch"
why_human: "Collapsible open/close behavior and touch interaction require a browser with rendered DOM — cannot be verified from source code alone"
- test: "URL deep-link round-trip on reload"
expected: "Navigating to /mobile/tickets?priority=2 hydrates the filter strip with High selected; the list shows only priority-2 tickets; back button leaves /mobile/tickets rather than reverting filters"
why_human: "router.replace history behavior and URL param hydration require a running Next.js app in a real browser"
- test: "IntersectionObserver infinite scroll trigger"
expected: "Scrolling to within ~200px of the list end automatically fetches the next 25 tickets and appends them without a page navigation"
why_human: "IntersectionObserver is browser-native; rootMargin behavior cannot be simulated from static code inspection"
- test: "Load more button keyboard accessibility"
expected: "Tab key reaches the Load more button when hasMore is true; pressing Enter/Space triggers the next page fetch"
why_human: "Focus management and keyboard interaction require a rendered browser"
- test: "Priority stripe colors rendered correctly per priority value"
expected: "Priority 1 rows show red left border, priority 2 orange, priority 3 amber, priority 4 slate — matching the locked D-15 color map"
why_human: "Tailwind class purge in production build could drop dynamic class strings unless all four are explicitly referenced — visual confirmation validates they render"
- test: "Detail page three-slot header renders correctly under shell HeaderBar"
expected: "On /mobile/tickets/[id] the Wulf mark + Bell + avatar HeaderBar renders at top, then the in-page header with ArrowLeft+Tickets / breadcrumb / ExternalLink icon below it, then the unchanged body"
why_human: "Nested header layout and safe-area spacing require a rendered browser to confirm no visual overlap or double-border"
---
# Phase 4: Tickets Restyle Verification Report
**Phase Goal:** A manager triages tickets on a phone with a collapsible filter bar that deep-links via URL, priority-coloured rows, and infinite scroll — and the detail page header matches the new shell.
**Verified:** 2026-05-03T22:15:02Z
**Status:** human_needed
**Re-verification:** No — initial verification
## Goal Achievement
### Observable Truths (from Roadmap Success Criteria)
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | Tickets page opens with filter strip collapsed; expanding reveals status, priority, queue, mine toggle; changing any filter updates the URL (deep link works on reload) | ✓ VERIFIED | `TicketFilterStrip` uses `useState(false)` as Collapsible default; `router.replace()` called in filter effect; `parseFilterFromSearch()` hydrates state from `useSearchParams()` on mount; `filterToSearch()` serializes all 5 params |
| 2 | Each list row has a left-edge stripe matching priority (Critical/High/Medium/Low → red/orange/amber/slate) and shows ticket #, title, company, age, and assignee | ✓ VERIFIED | `PRIORITY_BORDER` record maps `{1: 'border-red-500', 2: 'border-orange-400', 3: 'border-amber-400', 4: 'border-slate-300'}`; `border-l-4` applied to Link wrapper; row renders `ticket_number`, `title`, `company_name`, `relTime(last_activity_date)`, initials avatar from `assigned_to` |
| 3 | Single-tapping a row navigates to `/mobile/tickets/[id]` | ✓ VERIFIED | Each row is a `<Link href={/mobile/tickets/${t.id}}>` — native Next.js navigation |
| 4 | Scrolling to the bottom auto-loads next ~25 rows (no Next button); a Load more fallback button is visible/focusable for accessibility | ✓ VERIFIED | `IntersectionObserver` with `rootMargin: '200px'` on `sentinelRef`; guard `loadingMore \|\| !hasMore`; focusable `<button aria-label="Load more tickets">` always rendered when `hasMore` |
| 5 | Detail page header uses new shell styling (back chevron + breadcrumb); body remains largely unchanged | ✓ VERIFIED | Three-slot header: `ArrowLeft`+Tickets button (`router.back()`), `"Tickets / #" + ticket_number` breadcrumb, `ExternalLink` anchor to `/analyzer/ticket/{id}`; `TimelineCard`, `fmtDate`, `renderContent`, stats grid, description Collapsible, and timeline all preserved verbatim (D-19) |
**Score:** 5/5 truths verified
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `app/api/mobile/tickets/route.ts` | Cursor-paginated endpoint exporting `MobileTicket` and `MobileTicketListResponse` | ✓ VERIFIED | Exports both interfaces; `{ tickets, nextCursor, hasMore }` envelope; `requireAuth()` gate; `Math.min(25,...)` cap; `ORDER BY t.last_activity_date DESC NULLS LAST, t.id DESC`; no `OFFSET`; `getMobileCompanyFilter()` preserved |
| `components/mobile/TicketFilterStrip.tsx` | Collapsible filter strip with controlled prop contract | ✓ VERIFIED | Exports `TicketFilterStrip`, `TicketFilterValue`, `QueueOption`, `TicketFilterStripProps`; uses `@/components/ui/collapsible`; `sticky top-0 bg-background z-10`; all four filter controls; "Clear all" button; phase 04 comment block |
| `components/mobile/TicketRowSkeleton.tsx` | Skeleton placeholder row matching priority-stripe layout | ✓ VERIFIED | `border-l-4 border-muted`; 3 Skeleton lines matching row shape; phase 04 comment block |
| `app/mobile/tickets/page.tsx` | Mobile tickets list page wired to filter strip + cursor API | ✓ VERIFIED | 310-line implementation; `Suspense` + `MobileTicketsInner` pattern; all 5 filter params URL-synced; `IntersectionObserver`; two empty states; `toast.error` in both catch blocks |
| `app/mobile/tickets/[id]/page.tsx` | Detail page with reskinned in-page header | ✓ VERIFIED | `ExternalLink` imported; three-slot header with `aria-label="Back to Tickets"` and `aria-label="Open ticket on desktop"`; breadcrumb `"Tickets / #"`; body sections unchanged |
### Key Link Verification
| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `app/api/mobile/tickets/route.ts` | `kiosk_settings` table | `getMobileCompanyFilter()` | ✓ WIRED | Helper preserved verbatim; queries `kiosk_settings` for `mobile_company_category_ids` and `mobile_excluded_company_ids` |
| `app/api/mobile/tickets/route.ts` | `tickets / companies / queues / resources` tables | parameterized SQL with `ORDER BY t.last_activity_date DESC NULLS LAST, t.id DESC` | ✓ WIRED | Full SQL JOIN visible in route; `postgresClient.query()` executes against live DB |
| `components/mobile/TicketFilterStrip.tsx` | `components/ui/collapsible.tsx` | shadcn primitive import | ✓ WIRED | `import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'` |
| `app/mobile/tickets/page.tsx` | `/api/mobile/tickets` | `fetch()` with cursor + filter params | ✓ WIRED | Two fetch calls: `loadFirst` and `loadMore`, both call `/api/mobile/tickets?${sp.toString()}` |
| `app/mobile/tickets/page.tsx` | `TicketFilterStrip` | named import from `@/components/mobile/TicketFilterStrip` | ✓ WIRED | Imported and rendered as `<TicketFilterStrip value={filter} onChange={setFilter} .../>` |
| `app/mobile/tickets/page.tsx` | `TicketRowSkeleton` | named import from `@/components/mobile/TicketRowSkeleton` | ✓ WIRED | Imported and rendered as `Array.from({length:5}).map((_,i) => <TicketRowSkeleton key={i} />)` |
| `app/mobile/tickets/page.tsx` | `MobileTicketListResponse` type | `import type` from route file | ✓ WIRED | `import type { MobileTicket, MobileTicketListResponse } from '@/app/api/mobile/tickets/route'` |
| `app/mobile/tickets/[id]/page.tsx` | `/api/mobile/tickets/{id}/timeline` | fetch — unchanged from legacy | ✓ WIRED | `fetch('/api/mobile/tickets/${id}/timeline')` present and untouched |
| `app/mobile/tickets/[id]/page.tsx` | `lucide-react ExternalLink` | named import | ✓ WIRED | `ExternalLink` in import block; used in `<ExternalLink className="w-4 h-4" aria-hidden="true" />` |
### Data-Flow Trace (Level 4)
| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|---------------|--------|--------------------|--------|
| `app/api/mobile/tickets/route.ts` | `tickets` (array) | `postgresClient.query(SELECT ... FROM tickets ... WHERE ... LIMIT ...)` | Yes — live DB query with parameterized WHERE clause | ✓ FLOWING |
| `app/mobile/tickets/page.tsx` | `tickets` state | `fetch('/api/mobile/tickets?...')``data.tickets` | Yes — fetches from the live route above | ✓ FLOWING |
| `app/mobile/tickets/page.tsx` | `queueOptions` state | Derived from `data.tickets` first-page `queue_id + queue_label` fields | Yes — populated from real ticket data, not hardcoded | ✓ FLOWING |
### Behavioral Spot-Checks
Server must be running for live API checks. Performed static verification only.
| Behavior | Check | Result | Status |
|----------|-------|--------|--------|
| API returns cursor-based shape | `grep -q "nextCursor" app/api/mobile/tickets/route.ts` | Found: `return NextResponse.json({ tickets, nextCursor, hasMore } satisfies MobileTicketListResponse)` | ✓ PASS |
| TypeScript compiles without errors (all phase 4 files) | `npx tsc --noEmit --pretty 2>&1 \| grep -E "app/mobile/tickets\|app/api/mobile/tickets\|components/mobile/Ticket"` | No output — no type errors | ✓ PASS |
| Full TypeScript build is clean | `npx tsc --noEmit --pretty 2>&1` | No output — zero errors project-wide | ✓ PASS |
| API module exports count | `grep -c "export interface" app/api/mobile/tickets/route.ts` | 2 (MobileTicket + MobileTicketListResponse) | ✓ PASS |
| Live API smoke | Requires running dev server — skip | N/A | ? SKIP |
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|-------------|------------|-------------|--------|----------|
| TICK-01 | 04-01, 04-02 | Collapsible filter strip at top, default collapsed | ✓ SATISFIED | `TicketFilterStrip` with `useState(false)` Collapsible default; rendered in `page.tsx` |
| TICK-02 | 04-01, 04-02 | Filter state syncs to URL query string for deep-linking | ✓ SATISFIED | `router.replace()` called on every filter change; `parseFilterFromSearch()` hydrates on mount; 5 URL params (`q`, `status`, `priority`, `queue`, `mine`) round-trip |
| TICK-03 | 04-02 | Left-edge color stripe by priority; row shows ticket #, title, company, age, assignee | ✓ SATISFIED | `PRIORITY_BORDER` record + `border-l-4`; row renders all 5 fields |
| TICK-04 | 04-02 | Single-tap on row opens detail page | ✓ SATISFIED | Row wrapped in `<Link href="/mobile/tickets/${t.id}">` |
| TICK-05 | 04-01, 04-02 | Cursor-based infinite scroll (~25/page) via IntersectionObserver | ✓ SATISFIED | API uses opaque base64 cursor, server limit 25; `IntersectionObserver` with `rootMargin: '200px'` triggers `loadMore()` |
| TICK-06 | 04-02 | "Load more" fallback button for accessibility | ✓ SATISFIED | `<button aria-label="Load more tickets">` always rendered when `hasMore`; disabled during `loadingMore` |
| TICK-07 | 04-03 | Detail page header reskinned to match new shell; body largely unchanged | ✓ SATISFIED | Three-slot header replaces legacy Back button; `TimelineCard`, stats grid, description, timeline unchanged (D-19) |
All 7 TICK requirements are satisfied. No orphaned requirements.
### Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| `app/mobile/tickets/[id]/page.tsx` | 5970 | "placeholder" word in comment (`// Step 1: convert ... placeholder`) | Info | Part of `renderContent` HTML-link extractor — internal algorithm comment, not a stub. Legacy code preserved per D-19. |
| `components/mobile/TicketFilterStrip.tsx` | 159 | `<SelectValue placeholder="All queues" />` | Info | shadcn `Select` placeholder text — correct use of the component, not a data stub |
| `components/mobile/TicketRowSkeleton.tsx` | 4 | "skeleton placeholder row" in comment | Info | Intentional design — this component IS a placeholder by purpose (D-21) |
No blockers or warnings found. All info-level matches are correct-use patterns, not stubs.
### Regression Verification
| Check | Status | Evidence |
|-------|--------|----------|
| `app/mobile/nav/` does NOT exist | ✓ PASS | `ls app/mobile/nav/ 2>/dev/null` returns "NOT EXISTS" — phase 2 deletion confirmed |
| `CLAUDE.md` is ~499 lines (full project guide) | ✓ PASS | `wc -l CLAUDE.md` = 499 lines |
| Phase 2 components exist: `BottomNav`, `HeaderBar`, `MoreDrawer` | ✓ PASS | All 3 present in `components/mobile/` |
| Phase 3 components exist: `KpiCardMobile`, `NeedsAttentionStrip`, `WorkerStatusRow` | ✓ PASS | All 3 present in `components/mobile/` |
| Dashboard route + page intact | ✓ PASS | `app/mobile/dashboard/page.tsx` and `app/api/mobile/dashboard/route.ts` both present |
### Locked Decisions (D-01 through D-21)
| Decision | Check | Status |
|----------|-------|--------|
| D-01: Collapsible default collapsed | `useState(false)` in TicketFilterStrip | ✓ |
| D-02: Exactly four filter controls | status chips, priority chips, queue Select, mine Switch all present | ✓ |
| D-03: Search always visible, 400ms debounce | Search above Collapsible; `setTimeout(400)` in page.tsx | ✓ |
| D-04: Clear all only when ≥1 filter active | `{isFiltered && <button>Clear all</button>}` | ✓ |
| D-05: `router.replace()` not `router.push()` | `router.replace(...)` in filter effect; no `router.push` | ✓ |
| D-06: URL param keys q/status/priority/queue/mine | All 5 serialized/deserialized | ✓ |
| D-07: Cursor NOT in URL | `filterToSearch()` has no cursor field; cursor only in `buildApiParams()` | ✓ |
| D-08: `?cursor=<opaque>&limit=25` replaces `?page=N&limit=30` | No `OFFSET`, no `?page=`, cursor param accepted | ✓ |
| D-09: Cursor is `base64(JSON({last_activity_date, id}))` snake_case | `encodeCursor`/`decodeCursor` helpers in route | ✓ |
| D-10: API returns `{tickets, nextCursor, hasMore}` | `satisfies MobileTicketListResponse` in return | ✓ |
| D-11: Page size 25, server-side cap | `Math.min(25, Math.max(1, parseInt(...)))` | ✓ |
| D-12: `IntersectionObserver` with `rootMargin: '200px'` | Exact string present in `page.tsx` | ✓ |
| D-13: Guard against duplicate fetches | `if (loadingMore \|\| !hasMore \|\| !nextCursor) return` | ✓ |
| D-14: Focusable Load more button always present when hasMore | `{hasMore && <button aria-label="Load more tickets">}` | ✓ |
| D-15: `border-l-4` + 4 priority colors (red/orange/amber/slate) | `PRIORITY_BORDER` record with exact locked classes | ✓ |
| D-16: Row shows ticket#, title, company, age, assignee | All 5 fields rendered in row | ✓ |
| D-17: Priority dot removed; `relTime()` preserved | No `PRIORITY_DOT`; `function relTime` present | ✓ |
| D-18: Detail header — back+label, breadcrumb, external link | Three-slot header with ArrowLeft+Tickets, breadcrumb, ExternalLink | ✓ |
| D-19: Detail body untouched | `TimelineCard`, `fmtDate`, `renderContent`, stats grid, description, timeline all preserved | ✓ |
| D-20: Two empty state messages with correct copy | "No tickets match your filters" + Clear filters; "No tickets to triage right now" + RefreshCw | ✓ |
| D-21: Initial load → skeletons; subsequent → spinner; error → toast + Retry | `TicketRowSkeleton` on `loading`; `Loader2` on `loadingMore`; `toast.error` in both catch blocks; "Retry" in button label | ✓ |
All 21 locked decisions honored.
### Human Verification Required
**6 items require browser/device testing:**
#### 1. Filter Strip Collapse/Expand
**Test:** Open `/mobile/tickets` on a phone-width viewport (real device or Chrome DevTools iPhone 15 Pro emulation). Observe the filter strip state on initial load. Tap the "Filters" button.
**Expected:** Strip is collapsed on load (only search input + "Filters" button visible). Tapping "Filters" smoothly animates open to show status chips (Open / In Progress / Waiting), priority chips (Critical / High / Medium / Low), queue Select, and Assigned-to-me Switch.
**Why human:** Collapsible animation and touch interaction require rendered DOM.
#### 2. URL Deep-Link Round-Trip
**Test:** Navigate to `/mobile/tickets?priority=2`. Reload the page. Use the browser back button after changing a filter.
**Expected:** Filter strip hydrates with "High" chip selected; list shows only priority-2 tickets. Back button exits `/mobile/tickets` entirely (does not revert to previous filter state — `router.replace` not `router.push`).
**Why human:** `router.replace` history behavior requires a running Next.js app.
#### 3. IntersectionObserver Infinite Scroll
**Test:** On `/mobile/tickets` (with enough tickets in the DB to require multiple pages), scroll to the bottom.
**Expected:** As the last row approaches the viewport (~200px ahead), the next 25 tickets load automatically and append — no page navigation, no URL change, a small spinner appears briefly.
**Why human:** IntersectionObserver `rootMargin` behavior requires a rendered browser.
#### 4. Load More Keyboard Accessibility
**Test:** With a mouse or keyboard, Tab to the "Load more" button when it is visible. Press Enter.
**Expected:** Button is focusable; pressing Enter/Space triggers the next page fetch identically to clicking.
**Why human:** Focus order and keyboard activation require rendered browser.
#### 5. Priority Stripe Colors Rendered
**Test:** Confirm visible priority-colored borders on rows across all four priority values.
**Expected:** Priority 1 → red left border, priority 2 → orange, priority 3 → amber, priority 4 → slate. Colors must match the locked D-15 Tailwind classes (not the legacy yellow for medium).
**Why human:** Tailwind purge in production builds could drop dynamic class references unless all four strings appear literally — visual confirmation is needed to catch any purge regression.
#### 6. Detail Page Three-Slot Header Layout
**Test:** Navigate to any `/mobile/tickets/[id]` page. Confirm the header structure from top to bottom.
**Expected:** Shell HeaderBar (Wulf wordmark + Bell + avatar) at top → below it the in-page three-slot header (ArrowLeft + "Tickets" on left, "Tickets / #TXXXXXX" centered, ExternalLink icon on right with a bottom border separating it from the body) → below that the unchanged badges + title + stats + description + timeline. No double border, no visual overlap with the shell.
**Why human:** Nested sticky/fixed layout and `-mx-4` bleed technique require a rendered browser to confirm correct visual output.
---
## Summary
All five roadmap success criteria are satisfied end-to-end in the codebase. All 7 TICK requirements (TICK-01 through TICK-07) are evidenced. All 21 locked decisions (D-01 through D-21) are honored. TypeScript compiles cleanly project-wide. No prior-phase regressions detected.
The phase is blocked from `passed` status only by 6 items that require a running browser for validation: filter expand/collapse animation, URL history semantics, IntersectionObserver triggering, keyboard focus, priority stripe visual rendering, and the two-level header layout on the detail page.
---
_Verified: 2026-05-03T22:15:02Z_
_Verifier: Claude (gsd-verifier)_