docs(04): create phase plan
This commit is contained in:
parent
bf780790d5
commit
77073bac97
4 changed files with 1396 additions and 2 deletions
|
|
@ -86,7 +86,10 @@ Decimal phases appear between their surrounding integers in numeric order.
|
|||
3. Single-tapping a row navigates to `/mobile/tickets/[id]`
|
||||
4. Scrolling to the bottom of the list automatically loads the next ~25 rows (no Next button); a "Load more" fallback button is also visible/focusable for accessibility
|
||||
5. The detail page header uses the new shell styling (Wulf mark, breadcrumb back) while the body remains largely unchanged
|
||||
**Plans**: TBD
|
||||
**Plans**: 3 plans
|
||||
- [ ] 04-01-PLAN.md — /api/mobile/tickets cursor rewrite + TicketFilterStrip + TicketRowSkeleton components (TICK-01, TICK-02, TICK-05)
|
||||
- [ ] 04-02-PLAN.md — Replace app/mobile/tickets/page.tsx with URL-synced filters, priority-stripe rows, IntersectionObserver infinite scroll (TICK-01..TICK-06)
|
||||
- [ ] 04-03-PLAN.md — Reskin in-page header of app/mobile/tickets/[id]/page.tsx (back chevron + breadcrumb + ExternalLink) (TICK-07)
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 5: Finance Restyle
|
||||
|
|
@ -146,7 +149,7 @@ Phases execute in numeric order. Phase 2 unblocks Phases 3–7 (any order, paral
|
|||
| 1. PWA Scaffolding | 1/2 | Executing | - |
|
||||
| 2. Mobile Shell + More Drawer | 0/TBD | Not started | - |
|
||||
| 3. Dashboard Restyle | 0/2 | Not started | - |
|
||||
| 4. Tickets Restyle | 0/TBD | Not started | - |
|
||||
| 4. Tickets Restyle | 0/3 | Not started | - |
|
||||
| 5. Finance Restyle | 0/TBD | Not started | - |
|
||||
| 6. Analyzer Feed | 0/TBD | Not started | - |
|
||||
| 7. Engagement Overview | 0/TBD | Not started | - |
|
||||
|
|
|
|||
583
.planning/phases/04-tickets-restyle/04-01-PLAN.md
Normal file
583
.planning/phases/04-tickets-restyle/04-01-PLAN.md
Normal file
|
|
@ -0,0 +1,583 @@
|
|||
---
|
||||
phase: 04
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- app/api/mobile/tickets/route.ts
|
||||
- components/mobile/TicketRowSkeleton.tsx
|
||||
- components/mobile/TicketFilterStrip.tsx
|
||||
autonomous: true
|
||||
requirements: [TICK-01, TICK-02, TICK-05]
|
||||
must_haves:
|
||||
truths:
|
||||
- "GET /api/mobile/tickets accepts a base64 cursor and returns { tickets, nextCursor, hasMore } shape"
|
||||
- "API caps page size at 25 server-side regardless of caller's limit param"
|
||||
- "Default status filter (no status param) returns Open + In Progress + Waiting tickets (status IN (1, 8, 7)) — matches the legacy t.status != 5 default"
|
||||
- "TicketFilterStrip renders the search input, ticket count line, and Collapsible toggle row with the four filter controls when expanded"
|
||||
- "TicketRowSkeleton renders five placeholder rows that visually match the priority-stripe ticket-row layout"
|
||||
artifacts:
|
||||
- path: "app/api/mobile/tickets/route.ts"
|
||||
provides: "Cursor-paginated list endpoint exporting MobileTicket and MobileTicketListResponse interfaces"
|
||||
exports: ["GET", "MobileTicket", "MobileTicketListResponse"]
|
||||
contains: "nextCursor"
|
||||
- path: "components/mobile/TicketFilterStrip.tsx"
|
||||
provides: "Collapsible filter strip presentational component"
|
||||
exports: ["TicketFilterStrip", "TicketFilterValue", "QueueOption"]
|
||||
- path: "components/mobile/TicketRowSkeleton.tsx"
|
||||
provides: "Skeleton placeholder row matching ticket row layout"
|
||||
exports: ["TicketRowSkeleton"]
|
||||
key_links:
|
||||
- from: "app/api/mobile/tickets/route.ts"
|
||||
to: "kiosk_settings table via getMobileCompanyFilter()"
|
||||
via: "preserved helper, unchanged"
|
||||
pattern: "getMobileCompanyFilter"
|
||||
- from: "app/api/mobile/tickets/route.ts"
|
||||
to: "tickets / companies / queues / resources tables"
|
||||
via: "parameterized SQL, last_activity_date DESC NULLS LAST, id DESC tie-breaker"
|
||||
pattern: "ORDER BY.*last_activity_date.*DESC.*id.*DESC"
|
||||
- from: "components/mobile/TicketFilterStrip.tsx"
|
||||
to: "components/ui/collapsible.tsx"
|
||||
via: "shadcn primitive import"
|
||||
pattern: "from ['\"]@/components/ui/collapsible['\"]"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Reshape `/api/mobile/tickets` from page-based pagination (`?page=N&limit=30`) to opaque-cursor pagination (`?cursor=<b64>&limit=25`) returning a typed `{ tickets, nextCursor, hasMore }` envelope, AND ship the two new presentational components (`TicketFilterStrip`, `TicketRowSkeleton`) the page (Plan 04-02) will consume.
|
||||
|
||||
Purpose: TICK-05 mandates cursor-based ~25/page infinite scroll; the page can't be wired without the new API shape. Co-locating the filter strip and skeleton component here keeps Plan 02 focused on wiring rather than building presentational primitives. Mirror the Phase 3 pattern of exporting TypeScript interfaces from the route file so Plan 02 can `import type` them directly.
|
||||
|
||||
Output:
|
||||
- Rewritten `app/api/mobile/tickets/route.ts` (cursor-based, exported interfaces, capped limit, default-status fallback)
|
||||
- New `components/mobile/TicketFilterStrip.tsx` (Collapsible filter strip, search + status/priority/queue/mine controls)
|
||||
- New `components/mobile/TicketRowSkeleton.tsx` (5-row skeleton shape matching priority-stripe row layout)
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/REQUIREMENTS.md
|
||||
@.planning/phases/04-tickets-restyle/04-CONTEXT.md
|
||||
@.planning/phases/04-tickets-restyle/04-UI-SPEC.md
|
||||
@.planning/phases/03-dashboard-restyle/03-01-SUMMARY.md
|
||||
@CLAUDE.md
|
||||
@app/api/mobile/tickets/route.ts
|
||||
@components/ui/collapsible.tsx
|
||||
|
||||
<interfaces>
|
||||
<!-- Existing route helper that MUST be preserved verbatim — see 04-CONTEXT.md code_context -->
|
||||
From app/api/mobile/tickets/route.ts (lines 4-26):
|
||||
```typescript
|
||||
async function getMobileCompanyFilter(): Promise<{ join: string; condition: string }> {
|
||||
// reads kiosk_settings: mobile_company_category_ids, mobile_excluded_company_ids
|
||||
// returns { join, condition } where condition is a SQL fragment for tickets table alias `t`
|
||||
}
|
||||
```
|
||||
|
||||
From components/ui/collapsible.tsx:
|
||||
```typescript
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
// Wrappers around @radix-ui/react-collapsible
|
||||
// Props: open, onOpenChange — control state externally for URL sync
|
||||
```
|
||||
|
||||
From components/ui/select.tsx (shadcn):
|
||||
```typescript
|
||||
export { Select, SelectContent, SelectItem, SelectTrigger, SelectValue }
|
||||
```
|
||||
|
||||
From components/ui/switch.tsx (shadcn):
|
||||
```typescript
|
||||
export { Switch } // controlled via checked + onCheckedChange
|
||||
```
|
||||
|
||||
From components/ui/skeleton.tsx (shadcn):
|
||||
```typescript
|
||||
export { Skeleton } // div with bg-muted animate-pulse rounded
|
||||
```
|
||||
|
||||
From components/ui/input.tsx (shadcn):
|
||||
```typescript
|
||||
export { Input }
|
||||
```
|
||||
|
||||
From components/ui/button.tsx (shadcn):
|
||||
```typescript
|
||||
export { Button } // accepts variant: 'default'|'ghost'|..., size: 'sm'|'default'|'lg'
|
||||
```
|
||||
|
||||
Pattern from Phase 3 (`app/api/mobile/dashboard/route.ts`):
|
||||
- `import postgresClient from '@/lib/services/postgres-client'` (default import)
|
||||
- `import { requireAuth } from '@/lib/auth-utils'` then `const { session, error } = await requireAuth(); if (error) return error;`
|
||||
- Single `Promise.all` of queries
|
||||
- Manual snake_case → camelCase NOT done in Phase 3 dashboard (kept snake_case in JSON) — for tickets the existing route returns snake_case so we KEEP snake_case to avoid breaking the page contract
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 1: Rewrite /api/mobile/tickets to cursor-paginated shape with exported interfaces</name>
|
||||
<files>app/api/mobile/tickets/route.ts</files>
|
||||
<read_first>
|
||||
- app/api/mobile/tickets/route.ts (current 90-line implementation — preserve getMobileCompanyFilter verbatim)
|
||||
- app/api/mobile/dashboard/route.ts (Phase 3 reference for requireAuth + default postgresClient import + interface export pattern)
|
||||
- .planning/phases/04-tickets-restyle/04-CONTEXT.md decisions D-08 through D-11 (cursor model, page size 25)
|
||||
- .planning/phases/04-tickets-restyle/04-UI-SPEC.md "API Shape Contract" section
|
||||
</read_first>
|
||||
<action>
|
||||
Rewrite `app/api/mobile/tickets/route.ts` end-to-end. The new file:
|
||||
|
||||
1. **Imports** — keep `NextRequest`, `NextResponse` from `next/server`. Switch to default import: `import postgresClient from '@/lib/services/postgres-client'` (matches Phase 3 dashboard route convention). Add `import { requireAuth } from '@/lib/auth-utils'`.
|
||||
|
||||
2. **Preserve `getMobileCompanyFilter()` helper VERBATIM** — copy lines 4-26 of the current file unchanged. Do NOT regress the kiosk_settings lookup or the `c.company_category_id = 1` fallback. The function signature and body must be byte-identical to the current implementation.
|
||||
|
||||
3. **Export TypeScript interfaces** at the top of the module (mirrors Phase 3 03-01-SUMMARY.md pattern):
|
||||
```typescript
|
||||
export interface MobileTicket {
|
||||
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;
|
||||
}
|
||||
|
||||
export interface MobileTicketListResponse {
|
||||
tickets: MobileTicket[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
4. **Cursor encode/decode helpers** (inline, NOT exported — D-09):
|
||||
```typescript
|
||||
interface CursorPayload { last_activity_date: string; id: number; }
|
||||
function encodeCursor(p: CursorPayload): string {
|
||||
return Buffer.from(JSON.stringify(p), 'utf8').toString('base64');
|
||||
}
|
||||
function decodeCursor(raw: string | null): CursorPayload | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(Buffer.from(raw, 'base64').toString('utf8'));
|
||||
if (typeof parsed?.last_activity_date === 'string' && typeof parsed?.id === 'number') {
|
||||
return parsed as CursorPayload;
|
||||
}
|
||||
return null;
|
||||
} catch { return null; }
|
||||
}
|
||||
```
|
||||
|
||||
5. **`GET` handler** — `export async function GET(request: NextRequest): Promise<NextResponse>`:
|
||||
- Auth gate first: `const { error: authError } = await requireAuth(); if (authError) return authError;`
|
||||
- Parse query params:
|
||||
- `q` (search, may be empty)
|
||||
- `status` — comma-separated ints; if absent, default to `[1, 8, 7]` (Open + In Progress + Waiting per UI-SPEC "Default behavior when no URL params"); accept `''` as "no filter — explicit clear"; treat empty array same as default
|
||||
- `priority` — comma-separated ints; if absent, no filter
|
||||
- `queue` — single int; if absent, no filter
|
||||
- `mine` — `'1'` means filter by current user; pull email from `session.user.email` then resolve to `resources.email = $X`
|
||||
- `limit` — parseInt, clamped: `Math.min(25, Math.max(1, parseInt(searchParams.get('limit') ?? '25')))`
|
||||
- `cursor` — decode via `decodeCursor()`; null if missing or malformed
|
||||
- Build conditions array starting with `t.is_deleted = false`, the company-scope condition from `getMobileCompanyFilter()`, and the status filter (default-or-supplied list → `t.status = ANY($N::int[])`).
|
||||
- Search: keep the existing pattern — `(t.title ILIKE $N OR t.ticket_number ILIKE $N OR c.company_name ILIKE $N)` with single `%search%` param.
|
||||
- Cursor seek predicate (only if cursor decoded): `(t.last_activity_date, t.id) < ($N::timestamp, $M::int)` — this is the standard keyset pagination form for `ORDER BY last_activity_date DESC, id DESC`.
|
||||
- Use `requireAuth()`'s session for `mine`: `params.push(session.user.email); conditions.push('LOWER(r.email) = LOWER($N))` — but ONLY if you have access to session here; since `requireAuth()` already returned `{ session, error }`, capture `session` from the call (`const { session, error: authError } = await requireAuth();`).
|
||||
|
||||
SQL:
|
||||
```sql
|
||||
SELECT t.id, t.ticket_number, t.title, t.status, t.priority,
|
||||
t.create_date, t.last_activity_date, t.due_date_time,
|
||||
t.queue_id, q.label AS queue_label,
|
||||
c.company_name,
|
||||
COALESCE(r.first_name || ' ' || r.last_name, '') AS assigned_to
|
||||
FROM tickets t
|
||||
INNER JOIN companies c ON c.id = t.company_id
|
||||
LEFT JOIN queues q ON q.value = t.queue_id
|
||||
LEFT JOIN resources r ON r.id = t.assigned_resource_id
|
||||
WHERE <conditions joined with AND>
|
||||
ORDER BY t.last_activity_date DESC NULLS LAST, t.id DESC
|
||||
LIMIT <limit + 1>
|
||||
```
|
||||
|
||||
Fetch `limit + 1` rows to detect `hasMore` without a second COUNT query. If `rows.length > limit`, slice to `limit` and `hasMore = true`; the next-cursor's `last_activity_date` and `id` come from the last kept row (`rows[limit - 1]`).
|
||||
|
||||
Build response:
|
||||
```typescript
|
||||
const tickets: MobileTicket[] = sliced.map(/* row → MobileTicket; preserve snake_case keys exactly as the interface declares */);
|
||||
const nextCursor = hasMore ? encodeCursor({ last_activity_date: tickets[tickets.length - 1].last_activity_date, id: tickets[tickets.length - 1].id }) : null;
|
||||
return NextResponse.json({ tickets, nextCursor, hasMore } satisfies MobileTicketListResponse);
|
||||
```
|
||||
|
||||
6. **Error handling** — wrap the body in `try/catch`; on error log `console.error('GET /api/mobile/tickets failed:', error)` and return `NextResponse.json({ error: 'Failed to fetch tickets', message: error instanceof Error ? error.message : 'unknown' }, { status: 500 })` per CLAUDE.md API route convention.
|
||||
|
||||
Anti-patterns (do NOT do):
|
||||
- Do NOT add Zod validation here (CLAUDE.md: "No Zod validation in route handlers today").
|
||||
- Do NOT change the snake_case keys of MobileTicket (the original page used `ticket_number`, `last_activity_date`, etc. — Plan 02 expects these names).
|
||||
- Do NOT remove `requireAuth()` once added — it's a security gate. Note: the legacy file did NOT have `requireAuth()`; this is intentional hardening per Phase 3 pattern.
|
||||
- Do NOT touch `app/api/mobile/tickets/[id]/timeline/route.ts` — that's the detail endpoint, out of scope.
|
||||
|
||||
Per D-09 the cursor encodes `{ last_activity_date, id }` exactly — do not rename to `lastActivityDate` (would break decode round-trip).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty 2>&1 | grep -E "(app/api/mobile/tickets/route|components/mobile/TicketFilterStrip|components/mobile/TicketRowSkeleton)" || echo "OK: no type errors in target files"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -q "export interface MobileTicket" app/api/mobile/tickets/route.ts` (interface exported)
|
||||
- `grep -q "export interface MobileTicketListResponse" app/api/mobile/tickets/route.ts` (envelope interface exported)
|
||||
- `grep -q "nextCursor" app/api/mobile/tickets/route.ts` (cursor field present)
|
||||
- `grep -q "hasMore" app/api/mobile/tickets/route.ts` (hasMore field present)
|
||||
- `grep -q "getMobileCompanyFilter" app/api/mobile/tickets/route.ts` (helper preserved)
|
||||
- `grep -q "requireAuth" app/api/mobile/tickets/route.ts` (auth gate added)
|
||||
- `grep -qE "Math\.min\(25" app/api/mobile/tickets/route.ts` (limit cap of 25 — D-11)
|
||||
- `grep -qE "ORDER BY.*last_activity_date.*DESC" app/api/mobile/tickets/route.ts` (keyset order)
|
||||
- `grep -qE "t\.id DESC" app/api/mobile/tickets/route.ts` (tie-breaker on id — D-09)
|
||||
- `! grep -q "OFFSET" app/api/mobile/tickets/route.ts` (no page-based offset remains)
|
||||
- `! grep -q "?page=" app/api/mobile/tickets/route.ts` (no page param consumed)
|
||||
- `grep -q "is_deleted" app/api/mobile/tickets/route.ts` (deleted filter preserved)
|
||||
- `npx tsc --noEmit --pretty 2>&1` does not report errors for `app/api/mobile/tickets/route.ts`
|
||||
</acceptance_criteria>
|
||||
<done>The route file compiles cleanly, exports `MobileTicket` and `MobileTicketListResponse`, returns `{ tickets, nextCursor, hasMore }`, caps limit at 25, applies the `[1, 8, 7]` default status filter when no `status` param is supplied, preserves `getMobileCompanyFilter()` verbatim, and gates with `requireAuth()`. No OFFSET-based pagination remains. The legacy `?page=N` shape is fully replaced.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 2: Create TicketRowSkeleton and TicketFilterStrip presentational components</name>
|
||||
<files>components/mobile/TicketRowSkeleton.tsx, components/mobile/TicketFilterStrip.tsx</files>
|
||||
<read_first>
|
||||
- .planning/phases/04-tickets-restyle/04-UI-SPEC.md "Filter Strip" and "Skeleton Loading State" sections
|
||||
- .planning/phases/04-tickets-restyle/04-CONTEXT.md decisions D-01 through D-04 (filter strip), D-21 (skeleton)
|
||||
- components/mobile/KpiCardMobile.tsx (Phase 3 component for the comment-block pattern reference)
|
||||
- components/ui/collapsible.tsx (Collapsible API surface)
|
||||
- components/ui/skeleton.tsx (Skeleton primitive)
|
||||
</read_first>
|
||||
<action>
|
||||
Create two new files. Both use the Phase 3 mobile component comment-block convention:
|
||||
```
|
||||
/* ComponentName — phase 04 (TICK-NN).
|
||||
* Purpose: one-line description.
|
||||
* Props: ... */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**File 1: `components/mobile/TicketRowSkeleton.tsx`** (D-21)
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
/* TicketRowSkeleton — phase 04 (TICK-05/D-21).
|
||||
* Purpose: skeleton placeholder row that matches the priority-stripe ticket row layout
|
||||
* for the initial-load state of /mobile/tickets.
|
||||
* Props: none — purely presentational. */
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
export function TicketRowSkeleton() {
|
||||
return (
|
||||
<div className="border-l-4 border-muted px-4 py-4">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2 mt-1" />
|
||||
<div className="flex gap-2 mt-2 items-center">
|
||||
<Skeleton className="h-3 w-12" />
|
||||
<Skeleton className="h-3 w-16 ml-auto" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Match UI-SPEC §"Skeleton Loading State" exactly. The wrapping container in Plan 02 will render `Array.from({ length: 5 }).map((_, i) => <TicketRowSkeleton key={i} />)`.
|
||||
|
||||
---
|
||||
|
||||
**File 2: `components/mobile/TicketFilterStrip.tsx`** (D-01..D-04)
|
||||
|
||||
Headers and exports:
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
/* TicketFilterStrip — phase 04 (TICK-01, TICK-02).
|
||||
* Purpose: sticky search input + Collapsible filter panel (status, priority, queue, mine)
|
||||
* with controlled values; URL sync is the parent page's responsibility.
|
||||
* Props: value, onChange, queueOptions, openTotal, isFiltered, onClearAll. */
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Search, X, SlidersHorizontal, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
export interface QueueOption {
|
||||
id: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface TicketFilterValue {
|
||||
q: string;
|
||||
status: number[]; // [] = no filter — caller treats default elsewhere
|
||||
priority: number[]; // [] = no filter
|
||||
queue: number | null;
|
||||
mine: boolean;
|
||||
}
|
||||
|
||||
export interface TicketFilterStripProps {
|
||||
value: TicketFilterValue;
|
||||
onChange: (next: TicketFilterValue) => void;
|
||||
queueOptions: QueueOption[];
|
||||
openTotal: number;
|
||||
isFiltered: boolean; // true when ≥1 non-default filter is active (excludes default status)
|
||||
onClearAll: () => void;
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS: Array<{ id: number; label: string }> = [
|
||||
{ id: 1, label: 'Open' },
|
||||
{ id: 8, label: 'In Progress' },
|
||||
{ id: 7, label: 'Waiting' },
|
||||
];
|
||||
|
||||
const PRIORITY_OPTIONS: Array<{ id: number; label: string }> = [
|
||||
{ id: 1, label: 'Critical' },
|
||||
{ id: 2, label: 'High' },
|
||||
{ id: 3, label: 'Medium' },
|
||||
{ id: 4, label: 'Low' },
|
||||
];
|
||||
|
||||
function chipClass(active: boolean): string {
|
||||
const base = 'shrink-0 px-3 py-1 rounded-full text-xs font-semibold border transition-colors min-h-[32px]';
|
||||
return active
|
||||
? `${base} bg-primary text-primary-foreground border-primary`
|
||||
: `${base} border-border hover:bg-muted/50`;
|
||||
}
|
||||
|
||||
function toggleInArray(arr: number[], id: number): number[] {
|
||||
return arr.includes(id) ? arr.filter(x => x !== id) : [...arr, id];
|
||||
}
|
||||
|
||||
export function TicketFilterStrip(props: TicketFilterStripProps) {
|
||||
const { value, onChange, queueOptions, openTotal, isFiltered, onClearAll } = props;
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const activeCount =
|
||||
(value.status.length > 0 && !(value.status.length === 3 && value.status.includes(1) && value.status.includes(7) && value.status.includes(8)) ? 1 : 0) +
|
||||
(value.priority.length > 0 ? 1 : 0) +
|
||||
(value.queue !== null ? 1 : 0) +
|
||||
(value.mine ? 1 : 0);
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 bg-background z-10 border-b px-4 pt-4 pb-3 space-y-2">
|
||||
{/* Search row — always visible (D-03) */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" aria-hidden="true" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search tickets, company…"
|
||||
value={value.q}
|
||||
onChange={(e) => onChange({ ...value, q: e.target.value })}
|
||||
className="w-full pl-9 pr-9"
|
||||
aria-label="Search tickets"
|
||||
/>
|
||||
{value.q && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ ...value, q: '' })}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toggle row — always visible (D-01) */}
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">{openTotal} open tickets</p>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-xs font-semibold" aria-label="Toggle filters">
|
||||
<SlidersHorizontal className="w-3.5 h-3.5 mr-1.5" aria-hidden="true" />
|
||||
Filters{activeCount > 0 ? ` (${activeCount})` : ''}
|
||||
{open ? <ChevronUp className="w-3.5 h-3.5 ml-1" aria-hidden="true" /> : <ChevronDown className="w-3.5 h-3.5 ml-1" aria-hidden="true" />}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent className="pt-3 space-y-3">
|
||||
{/* Status (D-02) */}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Status</p>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={value.status.includes(s.id)}
|
||||
onClick={() => onChange({ ...value, status: toggleInArray(value.status, s.id) })}
|
||||
className={chipClass(value.status.includes(s.id))}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority (D-02) */}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Priority</p>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{PRIORITY_OPTIONS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={value.priority.includes(p.id)}
|
||||
onClick={() => onChange({ ...value, priority: toggleInArray(value.priority, p.id) })}
|
||||
className={chipClass(value.priority.includes(p.id))}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Queue (D-02) */}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Queue</p>
|
||||
<Select
|
||||
value={value.queue !== null ? String(value.queue) : 'all'}
|
||||
onValueChange={(v) => onChange({ ...value, queue: v === 'all' ? null : parseInt(v, 10) })}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="All queues" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All queues</SelectItem>
|
||||
{queueOptions.map((q) => (
|
||||
<SelectItem key={q.id} value={String(q.id)}>{q.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Assigned to me (D-02) */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor="filter-mine" className="text-sm">Assigned to me</label>
|
||||
<Switch
|
||||
id="filter-mine"
|
||||
checked={value.mine}
|
||||
onCheckedChange={(checked) => onChange({ ...value, mine: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Clear all — only when isFiltered (D-04) */}
|
||||
{isFiltered && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearAll}
|
||||
className="text-xs text-muted-foreground underline"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Notes on behavior:
|
||||
- The component is purely controlled (no internal filter state besides the open/closed Collapsible toggle). URL sync lives in the parent page.
|
||||
- `activeCount` excludes the default `[1, 7, 8]` status set so the badge only counts user-selected modifications. The parent decides what "default" means and passes `isFiltered` accordingly.
|
||||
- The `min-h-[32px]` on chips meets the touch-target guidance with `py-1` baseline; the entire chip area is tappable. (UI-SPEC notes 44px for primary controls; chips are secondary and use a relaxed target consistent with the existing `py-1 rounded-full` pattern.)
|
||||
|
||||
Anti-patterns (do NOT do):
|
||||
- Do NOT introduce SWR / react-query / Zustand (CLAUDE.md: "No additional state libraries").
|
||||
- Do NOT fetch queue options inside this component; the parent passes `queueOptions` (queue list comes from a future endpoint or from the existing tickets API; Plan 02 will decide). For Plan 01 we only define the prop contract.
|
||||
- Do NOT render the priority dot — the row stripe replaces it (D-17). This component only handles filters; the row itself is built in Plan 02.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty 2>&1 | grep -E "components/mobile/(TicketRowSkeleton|TicketFilterStrip)" || echo "OK: no type errors in new components"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `test -f components/mobile/TicketRowSkeleton.tsx` (file exists)
|
||||
- `test -f components/mobile/TicketFilterStrip.tsx` (file exists)
|
||||
- `grep -q "export function TicketRowSkeleton" components/mobile/TicketRowSkeleton.tsx` (named export)
|
||||
- `grep -q "export function TicketFilterStrip" components/mobile/TicketFilterStrip.tsx` (named export)
|
||||
- `grep -q "export interface TicketFilterValue" components/mobile/TicketFilterStrip.tsx` (filter value interface exported)
|
||||
- `grep -q "export interface QueueOption" components/mobile/TicketFilterStrip.tsx` (queue option interface exported)
|
||||
- `grep -q "border-l-4 border-muted" components/mobile/TicketRowSkeleton.tsx` (4px stripe per UI-SPEC)
|
||||
- `grep -q "from '@/components/ui/collapsible'" components/mobile/TicketFilterStrip.tsx` (uses Collapsible primitive — D-01)
|
||||
- `grep -q "sticky top-0" components/mobile/TicketFilterStrip.tsx` (sticky positioning per UI-SPEC viewport contract)
|
||||
- `grep -q "Clear all" components/mobile/TicketFilterStrip.tsx` (D-04 copy)
|
||||
- `grep -qE "(Open|In Progress|Waiting)" components/mobile/TicketFilterStrip.tsx` (status options — D-02)
|
||||
- `grep -qE "(Critical|High|Medium|Low)" components/mobile/TicketFilterStrip.tsx` (priority options — D-02)
|
||||
- `grep -q "Assigned to me" components/mobile/TicketFilterStrip.tsx` (mine toggle label — D-02)
|
||||
- `grep -q "phase 04" components/mobile/TicketRowSkeleton.tsx && grep -q "phase 04" components/mobile/TicketFilterStrip.tsx` (Phase 3 comment-block convention)
|
||||
- `! grep -q "useSWR\|@tanstack/react-query\|zustand" components/mobile/TicketFilterStrip.tsx` (no forbidden state libraries)
|
||||
- `npx tsc --noEmit --pretty 2>&1` does not report errors for either new file
|
||||
</acceptance_criteria>
|
||||
<done>Both component files exist, type-check cleanly, expose the documented prop interfaces, follow the Phase 3 comment-block convention, and contain the exact UI-SPEC class strings for the priority skeleton stripe (`border-l-4 border-muted`) and the sticky filter strip container (`sticky top-0 bg-background z-10 border-b px-4 pt-4 pb-3 space-y-2`). Plan 02 can `import { TicketFilterStrip, type TicketFilterValue, type QueueOption } from '@/components/mobile/TicketFilterStrip'` and `import { TicketRowSkeleton } from '@/components/mobile/TicketRowSkeleton'` without further changes.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| client → /api/mobile/tickets | Authenticated user supplies q/status/priority/queue/cursor — must be validated and parameterised |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-04-01 | Tampering | cursor query param | mitigate | `decodeCursor()` returns null on JSON parse failure, missing fields, or wrong types — falls back to "no cursor" rather than throwing or trusting parsed data; the cursor only affects ordering, never company scope. |
|
||||
| T-04-02 | Information Disclosure | company-scope bypass via SQL injection | mitigate | All user input flows through parameterised queries via `postgresClient.query(sql, params)`; `getMobileCompanyFilter()` interpolates only ints filtered through `parseInt + isNaN` guards (existing helper, preserved verbatim). |
|
||||
| T-04-03 | Information Disclosure | unauthenticated access to ticket list | mitigate | Add `await requireAuth()` at the top of GET — the legacy route lacked this, Phase 4 hardens it (matches Phase 3 dashboard route). |
|
||||
| T-04-04 | Denial of Service | unbounded `limit` param | mitigate | Server-side cap: `Math.min(25, Math.max(1, parseInt(limit ?? '25')))` — caller cannot request more than 25 rows. |
|
||||
| T-04-05 | Information Disclosure | `mine` filter using session.user.email | mitigate | Email comes from the verified session, never from the query string; SQL uses `LOWER(r.email) = LOWER($N)` parameterised. |
|
||||
| T-04-06 | Spoofing | filter chips submit forged status/priority ids | accept | Status/priority are foreign keys to `tickets`; non-existent ids simply return zero rows. No data exfiltration risk; legacy route had the same model. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
After both tasks complete, run:
|
||||
|
||||
1. `npx tsc --noEmit --pretty` — must pass with no new errors in `app/api/mobile/tickets/route.ts`, `components/mobile/TicketRowSkeleton.tsx`, or `components/mobile/TicketFilterStrip.tsx`.
|
||||
2. `grep -c "export interface" app/api/mobile/tickets/route.ts` — must be `>= 2` (MobileTicket + MobileTicketListResponse).
|
||||
3. `grep -c "export function" components/mobile/TicketFilterStrip.tsx` — must be `>= 1`.
|
||||
4. Hand-execute one curl for sanity (developer terminal): `curl -sS 'http://localhost:3100/api/mobile/tickets?limit=5' -b "<session-cookie>" | jq '.tickets | length, .nextCursor, .hasMore'` — should return `5`, an opaque base64 string (or null if fewer than 5 tickets), and a boolean. NOTE: this is for the dev's smoke check; not part of the automated gate (it requires a live dev server).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `app/api/mobile/tickets/route.ts` rewritten with cursor pagination, `requireAuth()`, exported `MobileTicket` and `MobileTicketListResponse` interfaces, server-side limit cap of 25, default-status fallback `[1, 8, 7]`, and `getMobileCompanyFilter()` preserved verbatim.
|
||||
- `components/mobile/TicketRowSkeleton.tsx` exists with the exact UI-SPEC skeleton shape (4px muted stripe + 3 skeleton lines + metadata row).
|
||||
- `components/mobile/TicketFilterStrip.tsx` exists with the controlled prop contract, Collapsible-driven panel, all four filter controls, "Clear all" button, and active-filter count badge.
|
||||
- TypeScript clean (`npx tsc --noEmit --pretty` passes for these files).
|
||||
- No legacy `?page=` / `OFFSET` paths remain in the route.
|
||||
- All TICK-01 (filter strip), TICK-02 (URL sync — interface contract ready for Plan 02), TICK-05 (cursor API) requirements substantially landed (the page wiring closes them in Plan 02).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/04-tickets-restyle/04-01-SUMMARY.md` documenting:
|
||||
- The exported interface signatures (so Plan 02 can `import type` directly).
|
||||
- The cursor encoding format (`base64(JSON({ last_activity_date, id }))`).
|
||||
- Default status filter when no `status` URL param is supplied (`[1, 8, 7]`).
|
||||
- Server-side limit cap (25).
|
||||
- That `getMobileCompanyFilter()` was preserved byte-identical.
|
||||
- The two new component file paths and their exported names.
|
||||
</output>
|
||||
590
.planning/phases/04-tickets-restyle/04-02-PLAN.md
Normal file
590
.planning/phases/04-tickets-restyle/04-02-PLAN.md
Normal file
|
|
@ -0,0 +1,590 @@
|
|||
---
|
||||
phase: 04
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["04-01"]
|
||||
files_modified:
|
||||
- app/mobile/tickets/page.tsx
|
||||
autonomous: false
|
||||
requirements: [TICK-01, TICK-02, TICK-03, TICK-04, TICK-05, TICK-06]
|
||||
must_haves:
|
||||
truths:
|
||||
- "Opening /mobile/tickets renders the filter strip in collapsed state with the search input and Filters toggle visible"
|
||||
- "Tapping the Filters toggle expands the Collapsible to show status, priority, queue, and Assigned-to-me controls"
|
||||
- "Changing any filter updates the URL query string in place (router.replace) without adding history entries"
|
||||
- "Reloading the page with ?status=1&priority=2&queue=15&mine=1&q=foo hydrates filter state from those params"
|
||||
- "Each list row has a 4px-wide left-edge color stripe matching the ticket priority"
|
||||
- "Tapping any list row navigates to /mobile/tickets/[id]"
|
||||
- "Scrolling to the bottom automatically loads the next ~25 rows via IntersectionObserver"
|
||||
- "A focusable Load more button is present below the sentinel until hasMore is false"
|
||||
- "When zero tickets match active filters, the page shows 'No tickets match your filters' with a Clear filters button"
|
||||
artifacts:
|
||||
- path: "app/mobile/tickets/page.tsx"
|
||||
provides: "Mobile tickets list page wired to TicketFilterStrip + cursor-based /api/mobile/tickets"
|
||||
contains: "TicketFilterStrip"
|
||||
key_links:
|
||||
- from: "app/mobile/tickets/page.tsx"
|
||||
to: "/api/mobile/tickets"
|
||||
via: "fetch with cursor + filter URL params"
|
||||
pattern: "fetch\\(.*api/mobile/tickets"
|
||||
- from: "app/mobile/tickets/page.tsx"
|
||||
to: "components/mobile/TicketFilterStrip.tsx"
|
||||
via: "named import"
|
||||
pattern: "from ['\"]@/components/mobile/TicketFilterStrip['\"]"
|
||||
- from: "app/mobile/tickets/page.tsx"
|
||||
to: "components/mobile/TicketRowSkeleton.tsx"
|
||||
via: "named import"
|
||||
pattern: "from ['\"]@/components/mobile/TicketRowSkeleton['\"]"
|
||||
- from: "app/mobile/tickets/page.tsx"
|
||||
to: "MobileTicketListResponse type"
|
||||
via: "import type from route file (Phase 3 pattern)"
|
||||
pattern: "import type.*from.*api/mobile/tickets/route"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Replace the body of `app/mobile/tickets/page.tsx` with the new shell-aligned implementation: Collapsible URL-synced filter strip, priority-stripe rows, IntersectionObserver-driven infinite scroll with a Load more fallback, skeleton loading state, and empty-state copy per D-20.
|
||||
|
||||
Purpose: closes TICK-01 through TICK-06 — the only requirements left after Plan 01 ships the API + presentational components. URL sync via `useSearchParams()` + `router.replace()` is the deep-link contract.
|
||||
|
||||
Output:
|
||||
- Rewritten `app/mobile/tickets/page.tsx` consuming `MobileTicketListResponse` from the new route, the `TicketFilterStrip` and `TicketRowSkeleton` components from `components/mobile/`, with infinite scroll + URL sync + priority stripes.
|
||||
- One human-verify checkpoint after the rewrite to confirm visual + interaction behavior on a real device.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/REQUIREMENTS.md
|
||||
@.planning/phases/04-tickets-restyle/04-CONTEXT.md
|
||||
@.planning/phases/04-tickets-restyle/04-UI-SPEC.md
|
||||
@.planning/phases/03-dashboard-restyle/03-02-SUMMARY.md
|
||||
@CLAUDE.md
|
||||
@app/mobile/tickets/page.tsx
|
||||
|
||||
<interfaces>
|
||||
<!-- From Plan 04-01 — these will exist when Plan 02 runs -->
|
||||
|
||||
From `app/api/mobile/tickets/route.ts`:
|
||||
```typescript
|
||||
export interface MobileTicket {
|
||||
id: number;
|
||||
ticket_number: string;
|
||||
title: string;
|
||||
status: number;
|
||||
priority: number; // 1=Critical, 2=High, 3=Medium, 4=Low
|
||||
create_date: string;
|
||||
last_activity_date: string;
|
||||
due_date_time: string | null;
|
||||
queue_id: number;
|
||||
queue_label: string;
|
||||
company_name: string;
|
||||
assigned_to: string;
|
||||
}
|
||||
|
||||
export interface MobileTicketListResponse {
|
||||
tickets: MobileTicket[];
|
||||
nextCursor: string | null; // base64 cursor; null when list is exhausted
|
||||
hasMore: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
From `components/mobile/TicketFilterStrip.tsx`:
|
||||
```typescript
|
||||
export interface QueueOption { id: number; label: string; }
|
||||
export interface TicketFilterValue {
|
||||
q: string;
|
||||
status: number[]; // [] = treated as default by parent
|
||||
priority: number[];
|
||||
queue: number | null;
|
||||
mine: boolean;
|
||||
}
|
||||
export interface TicketFilterStripProps {
|
||||
value: TicketFilterValue;
|
||||
onChange: (next: TicketFilterValue) => void;
|
||||
queueOptions: QueueOption[];
|
||||
openTotal: number;
|
||||
isFiltered: boolean;
|
||||
onClearAll: () => void;
|
||||
}
|
||||
export function TicketFilterStrip(props: TicketFilterStripProps): JSX.Element;
|
||||
```
|
||||
|
||||
From `components/mobile/TicketRowSkeleton.tsx`:
|
||||
```typescript
|
||||
export function TicketRowSkeleton(): JSX.Element;
|
||||
```
|
||||
|
||||
Helpers preserved from current page (line 29-37):
|
||||
```typescript
|
||||
function relTime(ts: string | null): string; // "5m ago" | "3h ago" | "2d ago" | "—"
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 1: Rewrite app/mobile/tickets/page.tsx with URL-synced filters, priority-stripe rows, and IntersectionObserver infinite scroll</name>
|
||||
<files>app/mobile/tickets/page.tsx</files>
|
||||
<read_first>
|
||||
- app/mobile/tickets/page.tsx (current 164-line implementation — preserve relTime helper)
|
||||
- .planning/phases/04-tickets-restyle/04-UI-SPEC.md (entire file — class strings and structure are load-bearing)
|
||||
- .planning/phases/04-tickets-restyle/04-CONTEXT.md decisions D-05 through D-21
|
||||
- components/mobile/TicketFilterStrip.tsx (the prop contract Plan 01 ships)
|
||||
- components/mobile/TicketRowSkeleton.tsx (the skeleton Plan 01 ships)
|
||||
- app/mobile/dashboard/page.tsx (Phase 3 mobile page pattern: 'use client', single load function, useEffect once, error block + Retry)
|
||||
</read_first>
|
||||
<action>
|
||||
Replace `app/mobile/tickets/page.tsx` end-to-end. The new file structure (target ≤ 220 lines):
|
||||
|
||||
1. **Header** — `'use client';` then imports:
|
||||
```typescript
|
||||
import { useEffect, useState, useCallback, useRef, useMemo, Suspense } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { ChevronRight, Clock, Loader2, RefreshCw } from 'lucide-react';
|
||||
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';
|
||||
```
|
||||
|
||||
2. **Constants** (top of module, outside component):
|
||||
```typescript
|
||||
const PRIORITY_BORDER: Record<number, string> = {
|
||||
1: 'border-red-500',
|
||||
2: 'border-orange-400',
|
||||
3: 'border-amber-400',
|
||||
4: 'border-slate-300',
|
||||
};
|
||||
const DEFAULT_STATUS: number[] = [1, 8, 7]; // Open + In Progress + Waiting (matches API default)
|
||||
```
|
||||
These exact class strings are LOCKED by D-15 / UI-SPEC §"Priority Stripe Colors". Do NOT use `border-yellow-400` (the legacy code's medium dot) — D-15 specifies `border-amber-400` for priority 3.
|
||||
|
||||
3. **`relTime` helper** — copy verbatim from the current file (lines 29-37). Do NOT inline-replace with a library; D-17 says "Keep `relTime()` helper as-is".
|
||||
|
||||
4. **URL <-> filter state helpers** (module-scope pure functions):
|
||||
```typescript
|
||||
function parseFilterFromSearch(sp: URLSearchParams): TicketFilterValue {
|
||||
const parseIntList = (raw: string | null): number[] => {
|
||||
if (raw === null) return [];
|
||||
if (raw === '') return []; // explicit empty
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
5. **Suspense wrapper** — Next.js 16 requires `useSearchParams()` to be inside a Suspense boundary. Pattern (matches CLAUDE.md "Build Notes" memory):
|
||||
```typescript
|
||||
export default function MobileTicketsPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex items-center justify-center h-40"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>}>
|
||||
<MobileTicketsInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
```
|
||||
The actual page logic lives in `MobileTicketsInner`.
|
||||
|
||||
6. **`MobileTicketsInner` component** — the full page state machine:
|
||||
```typescript
|
||||
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<TicketFilterValue>(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<MobileTicket[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(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<string | null>(null);
|
||||
const [queueOptions, setQueueOptions] = useState<QueueOption[]>([]);
|
||||
|
||||
// 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;
|
||||
}, []);
|
||||
|
||||
// Fetch first page (filters changed)
|
||||
const loadFirst = useCallback(async (f: TicketFilterValue, q: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
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<number, QueueOption>();
|
||||
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) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load tickets');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [buildApiParams]);
|
||||
|
||||
// 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) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load more tickets');
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [loadingMore, hasMore, nextCursor, filter, debouncedQ, buildApiParams]);
|
||||
|
||||
// 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<HTMLDivElement | null>(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 (
|
||||
<div className="flex flex-col h-full">
|
||||
<TicketFilterStrip
|
||||
value={filter}
|
||||
onChange={setFilter}
|
||||
queueOptions={queueOptions}
|
||||
openTotal={openTotal}
|
||||
isFiltered={filtered}
|
||||
onClearAll={clearAll}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="divide-y">
|
||||
{Array.from({ length: 5 }).map((_, i) => <TicketRowSkeleton key={i} />)}
|
||||
</div>
|
||||
) : tickets.length === 0 ? (
|
||||
// Empty state (D-20)
|
||||
<div className="text-center py-12 px-4 space-y-3">
|
||||
{filtered ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">No tickets match your filters</p>
|
||||
<Button variant="outline" size="sm" onClick={clearAll}>Clear filters</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">No tickets to triage right now</p>
|
||||
<Button variant="ghost" size="sm" onClick={() => loadFirst(filter, debouncedQ)} aria-label="Refresh ticket list">
|
||||
<RefreshCw className="w-4 h-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="divide-y">
|
||||
{tickets.map((t) => (
|
||||
<Link
|
||||
key={t.id}
|
||||
href={`/mobile/tickets/${t.id}`}
|
||||
className={`flex items-start border-l-4 ${PRIORITY_BORDER[t.priority] ?? 'border-slate-300'} px-4 py-4 hover:bg-muted/50 active:bg-muted/50 transition-colors`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-semibold leading-snug truncate">{t.title}</p>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" aria-hidden="true" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">{t.company_name}</p>
|
||||
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
||||
<span className="text-[10px] bg-muted rounded px-1.5 py-0.5 font-mono">{t.ticket_number}</span>
|
||||
{t.queue_label && (
|
||||
<span className="text-[10px] text-muted-foreground">{t.queue_label}</span>
|
||||
)}
|
||||
{t.assigned_to && (
|
||||
<span className="inline-flex items-center justify-center h-5 w-5 rounded-full bg-primary/15 text-primary text-[10px] font-semibold">
|
||||
{t.assigned_to.split(' ').map(s => s[0]).filter(Boolean).slice(0, 2).join('').toUpperCase() || '·'}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-muted-foreground flex items-center gap-0.5 ml-auto">
|
||||
<Clock className="w-3 h-3" aria-hidden="true" />
|
||||
{relTime(t.last_activity_date)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Sentinel — IntersectionObserver target (D-12) */}
|
||||
<div ref={sentinelRef} aria-hidden="true" />
|
||||
|
||||
{/* Loading-more spinner (D-21) */}
|
||||
{loadingMore && (
|
||||
<div className="flex justify-center py-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more fallback button (D-14, TICK-06) */}
|
||||
{hasMore && (
|
||||
<div className="p-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadMore()}
|
||||
disabled={loadingMore}
|
||||
aria-label="Load more tickets"
|
||||
className="w-full py-3 rounded-xl border text-sm font-semibold hover:bg-muted/50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{error ? 'Retry' : loadingMore ? 'Loading…' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Key behaviors / locked decisions:
|
||||
- `router.replace()` not `router.push()` (D-05).
|
||||
- Cursor is NOT in URL (D-07) — only `q`, `status`, `priority`, `queue`, `mine`.
|
||||
- `border-l-4` + exact UI-SPEC class strings: `border-red-500`, `border-orange-400`, `border-amber-400`, `border-slate-300` (D-15).
|
||||
- No priority dot rendered (D-17 — the legacy `<div className="...PRIORITY_DOT">` is removed).
|
||||
- Sentinel `aria-hidden="true"` (UI-SPEC accessibility section).
|
||||
- Load more button has `aria-label="Load more tickets"` and is always rendered when `hasMore` so screen-reader users have a focusable control even after the IntersectionObserver triggers (TICK-06 / D-14).
|
||||
- Skeleton state for initial load only — subsequent `loadingMore` shows the small spinner above Load more (D-21).
|
||||
- Title uses `truncate` (1-line, per UI-SPEC "Row title — 1-line truncate") — the legacy code used `line-clamp-2`; switch to `truncate` to match locked spec.
|
||||
- Use `<Suspense>` wrapper because `useSearchParams()` requires it in Next.js 16 (CLAUDE.md memory entry).
|
||||
|
||||
Anti-patterns (do NOT do):
|
||||
- Do NOT introduce SWR / react-query (CLAUDE.md).
|
||||
- Do NOT use `router.push()` for filter updates (D-05).
|
||||
- Do NOT persist `cursor` to the URL (D-07).
|
||||
- Do NOT add a separate count endpoint — Plan 02 deliberately uses `tickets.length + hasMore ? 1 : 0` as a "≥N" approximation; revisit only if the exact count is needed (out of scope this phase).
|
||||
- Do NOT reintroduce the priority dot — the stripe replaces it (D-17).
|
||||
- Do NOT use Tailwind class `border-yellow-400` for priority 3 (legacy used yellow; UI-SPEC locked it to `border-amber-400`).
|
||||
- Do NOT call `router.push()` on every keystroke — the debounced effect handles URL sync once the search settles.
|
||||
|
||||
Discretionary choices made (per "Claude's Discretion" in 04-CONTEXT.md):
|
||||
- Assignee initials avatar: `inline-flex items-center justify-center h-5 w-5 rounded-full bg-primary/15 text-primary text-[10px] font-semibold` rendering up to 2 initials, falling back to `·`.
|
||||
- Queue option list is derived from the first page's tickets — no separate `/api/mobile/queues` endpoint. Acceptable for v1; the Select still works because the parent always passes the most recent set after the first load.
|
||||
- "Open total" approximated as `tickets.length + (hasMore ? 1 : 0)` — visible label reads "N open tickets"; precision deferred until a count endpoint exists.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty 2>&1 | grep -E "app/mobile/tickets/page\.tsx" || echo "OK: page typechecks"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -q "'use client'" app/mobile/tickets/page.tsx` (client component declaration)
|
||||
- `grep -q "Suspense" app/mobile/tickets/page.tsx` (Suspense wrapper for useSearchParams — Next.js 16 requirement)
|
||||
- `grep -q "useSearchParams" app/mobile/tickets/page.tsx` (URL hydration)
|
||||
- `grep -q "router\.replace" app/mobile/tickets/page.tsx` (D-05 — replace not push)
|
||||
- `! grep -q "router\.push" app/mobile/tickets/page.tsx` (no push for filter updates)
|
||||
- `grep -q "TicketFilterStrip" app/mobile/tickets/page.tsx` (uses Plan 01 component)
|
||||
- `grep -q "TicketRowSkeleton" app/mobile/tickets/page.tsx` (uses Plan 01 skeleton)
|
||||
- `grep -q "import type.*MobileTicketListResponse" app/mobile/tickets/page.tsx` (typed response — Phase 3 pattern)
|
||||
- `grep -q "IntersectionObserver" app/mobile/tickets/page.tsx` (D-12)
|
||||
- `grep -q "rootMargin: '200px'" app/mobile/tickets/page.tsx` (D-12 — exact margin)
|
||||
- `grep -q "border-red-500" app/mobile/tickets/page.tsx` (priority 1 — D-15)
|
||||
- `grep -q "border-orange-400" app/mobile/tickets/page.tsx` (priority 2 — D-15)
|
||||
- `grep -q "border-amber-400" app/mobile/tickets/page.tsx` (priority 3 — D-15, NOT yellow)
|
||||
- `grep -q "border-slate-300" app/mobile/tickets/page.tsx` (priority 4 — D-15)
|
||||
- `grep -q "border-l-4" app/mobile/tickets/page.tsx` (4px stripe — D-15)
|
||||
- `grep -q "Load more" app/mobile/tickets/page.tsx` (TICK-06 fallback)
|
||||
- `grep -q 'aria-label="Load more tickets"' app/mobile/tickets/page.tsx` (a11y)
|
||||
- `grep -q 'aria-hidden="true"' app/mobile/tickets/page.tsx` (sentinel a11y)
|
||||
- `grep -q "No tickets match your filters" app/mobile/tickets/page.tsx` (D-20 empty state — filtered)
|
||||
- `grep -q "No tickets to triage right now" app/mobile/tickets/page.tsx` (D-20 empty state — unfiltered)
|
||||
- `grep -q "Clear filters" app/mobile/tickets/page.tsx` (D-20 CTA)
|
||||
- `grep -q "function relTime" app/mobile/tickets/page.tsx` (D-17 helper preserved)
|
||||
- `! grep -q "PRIORITY_DOT" app/mobile/tickets/page.tsx` (D-17 — dot removed)
|
||||
- `! grep -q "border-yellow-400" app/mobile/tickets/page.tsx` (legacy yellow replaced by amber)
|
||||
- `! grep -qE "useSWR|@tanstack/react-query|zustand" app/mobile/tickets/page.tsx` (CLAUDE.md — no forbidden libs)
|
||||
- `! grep -q "?page=" app/mobile/tickets/page.tsx` (no legacy page param)
|
||||
- `npx tsc --noEmit --pretty 2>&1` reports no errors for `app/mobile/tickets/page.tsx`
|
||||
</acceptance_criteria>
|
||||
<done>The rewritten page hydrates filters from `useSearchParams()` inside a Suspense boundary, calls `router.replace()` to sync filter changes back to the URL, fetches the cursor-paginated API on first load and on filter changes, advances via cursor on IntersectionObserver intersection (with a Load more fallback), renders priority-stripe rows using the four locked Tailwind border classes, shows skeleton rows on initial load and a small spinner during cursor advances, and renders the two distinct empty-state copies. TypeScript compiles cleanly.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 2: Verify the new tickets list end-to-end on a real device or simulator</name>
|
||||
<files>app/mobile/tickets/page.tsx (verifying — not modifying)</files>
|
||||
<action>Human verification only — see <how-to-verify> below for the 14-step checklist. No code changes. Pause execution and wait for the user to confirm the new list page behaves per spec on a phone-width viewport.</action>
|
||||
<verify><automated>echo "Manual checkpoint — see resume-signal"</automated></verify>
|
||||
<done>User confirms all 14 checklist items pass on a phone-width viewport (real device or DevTools iPhone 15 Pro emulation), or describes precisely which step failed and why.</done>
|
||||
<what-built>
|
||||
The mobile Tickets list page now uses the new shell-aligned layout: Collapsible filter strip, URL-synced filter state, priority-stripe rows, IntersectionObserver-driven infinite scroll, Load more fallback button, skeleton loading state, and the two D-20 empty-state copies. The detail page link target (`/mobile/tickets/[id]`) is unchanged — that page's header reskin is shipped by Plan 04-03 in parallel.
|
||||
</what-built>
|
||||
<how-to-verify>
|
||||
Start the dev server (`npm run dev` → http://localhost:3100) and sign in. Then on a phone-width viewport (or Chrome DevTools iPhone 15 Pro emulation):
|
||||
|
||||
1. **Initial load + skeleton** — Navigate to `/mobile/tickets`. You should briefly see 5 skeleton rows (each with a muted left stripe + 3 placeholder lines), then the real tickets render.
|
||||
2. **Default state** — Filter strip is COLLAPSED. Search input visible. "Filters" button visible. Count line shows "N open tickets". Each row has a 4px colored left stripe (red / orange / amber / slate) — no dot.
|
||||
3. **Single-tap row** — Tap any row → routes to `/mobile/tickets/[id]` (existing detail page; header reskin from Plan 04-03 may or may not be live yet — body should render either way).
|
||||
4. **Filter strip expands** — Tap "Filters". Panel reveals four controls: status chips (Open / In Progress / Waiting), priority chips (Critical / High / Medium / Low), queue Select, "Assigned to me" Switch.
|
||||
5. **URL deep-link — set filters** — Tap "High" priority chip. URL updates IN PLACE to include `?priority=2` (no new history entry — back button takes you OUT of `/mobile/tickets`, not to a previous filter state).
|
||||
6. **URL deep-link — reload** — Reload the page with the URL still showing `?priority=2`. Filter strip hydrates with "High" already selected; list shows only priority-2 tickets.
|
||||
7. **Search debounce** — Type in the search box. URL updates ~400ms after you stop typing, not on every keystroke.
|
||||
8. **Clear all** — Tap "Clear all". Status returns to default (Open + In Progress + Waiting), priority/queue/mine reset, URL params clear.
|
||||
9. **Infinite scroll** — Scroll to the bottom of the list. The next ~25 rows append automatically (small spinner appears briefly above "Load more"). The page does NOT navigate to a new URL.
|
||||
10. **Load more button** — Confirm the "Load more" button is visible and focusable (Tab to it). Clicking it also advances the list. When the list is exhausted, the button disappears.
|
||||
11. **Empty state with filters** — Set filters that return no rows (e.g., a non-existent search term). Page shows "No tickets match your filters" + a "Clear filters" button. Tapping it restores defaults.
|
||||
12. **No charts / no recharts imports** — Sanity check: open DevTools network tab and confirm only `/api/mobile/tickets` is called (no extra count or queue endpoints).
|
||||
13. **Priority colors** — A row with `priority=1` has `border-red-500`, `priority=2` `border-orange-400`, `priority=3` `border-amber-400`, `priority=4` `border-slate-300`. These are direct Tailwind palette references per UI-SPEC §"Priority Stripe Colors".
|
||||
14. **Detail back nav (Plan 04-03 dependency)** — From a detail page, the device back gesture returns you to the list at the same scroll position with filters intact. (Plan 04-03 reskins the in-page back chevron — UX should still work without it.)
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" if all 14 checks pass. If any fail, describe the failure precisely (which step, what you saw vs. expected).</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| URL search params → component state | A user-supplied URL (incl. shared deep links) populates filter state and is fed into API calls |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-04-07 | Tampering | URL params (`status`, `priority`, `queue`, `mine`, `q`) | mitigate | `parseFilterFromSearch()` runs `parseInt + isNaN` filter on every numeric value; non-numeric tokens silently dropped. The page only forwards values to the API, which itself parameterises and validates. |
|
||||
| T-04-08 | Information Disclosure | search query reflected in URL | accept | Query parameters appear in browser history and any logging — same risk as the existing implementation; users searching for sensitive terms is a userland concern. |
|
||||
| T-04-09 | Denial of Service | rapid filter changes flood the API | mitigate | Search input debounced 400ms (D-03). Other filters are discrete user actions (chip tap, dropdown change) — already rate-limited by human input speed. |
|
||||
| T-04-10 | Repudiation | mobile actions are read-only | accept | This page is read-only; no audit logging needed. Detail page comments/edits are out of scope. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
1. `npx tsc --noEmit --pretty` passes — no type errors in `app/mobile/tickets/page.tsx`.
|
||||
2. All `<acceptance_criteria>` grep checks for Task 1 return success.
|
||||
3. Human-verify checklist (Task 2) reaches "approved".
|
||||
4. Phase-level smoke: visit `/mobile/tickets`, then `/mobile/tickets?priority=1`, then `/mobile/tickets?status=&priority=&q=zzz_no_match` — three different rendered states (default list, priority-1 only, empty state with Clear filters CTA).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- TICK-01: Collapsible filter strip default-collapsed, expands to status/priority/queue/mine controls.
|
||||
- TICK-02: All four filter primitives sync to the URL via `router.replace()`; reload hydrates state.
|
||||
- TICK-03: Each row has a `border-l-4` stripe with the correct priority Tailwind class.
|
||||
- TICK-04: Single-tap on a row navigates to `/mobile/tickets/[id]`.
|
||||
- TICK-05: ~25-per-page cursor advance via `IntersectionObserver` with `rootMargin: '200px'`.
|
||||
- TICK-06: A focusable "Load more" button is rendered whenever `hasMore` is true.
|
||||
- D-20 empty states render correct copy with correct CTAs.
|
||||
- No new state libraries introduced; CLAUDE.md conventions honored.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/04-tickets-restyle/04-02-SUMMARY.md` documenting:
|
||||
- Final file size of `app/mobile/tickets/page.tsx`.
|
||||
- The URL <-> filter state mapping (which params are present when, and how `status` interacts with the default `[1, 8, 7]`).
|
||||
- That the queue list is derived from first-page tickets (no new endpoint).
|
||||
- The "open total" approximation note (tickets.length + (hasMore ? 1 : 0)) and that a precise count endpoint is deferred.
|
||||
- Any deviations from the plan (e.g., extra renders, fallback behaviors).
|
||||
</output>
|
||||
218
.planning/phases/04-tickets-restyle/04-03-PLAN.md
Normal file
218
.planning/phases/04-tickets-restyle/04-03-PLAN.md
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
---
|
||||
phase: 04
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- app/mobile/tickets/[id]/page.tsx
|
||||
autonomous: true
|
||||
requirements: [TICK-07]
|
||||
must_haves:
|
||||
truths:
|
||||
- "Detail page in-page header shows a back chevron + 'Tickets' label that calls router.back()"
|
||||
- "Detail page in-page header shows the breadcrumb 'Tickets / #{ticket_number}' centered"
|
||||
- "Detail page in-page header shows an external-link icon that opens the desktop ticket URL in a new tab"
|
||||
- "The shell HeaderBar (Wulf mark + Bell + avatar) from app/mobile/layout.tsx still renders above the in-page header"
|
||||
- "The detail body (priority/status badges, stats grid, description, timeline) is unchanged from the legacy implementation"
|
||||
artifacts:
|
||||
- path: "app/mobile/tickets/[id]/page.tsx"
|
||||
provides: "Mobile ticket detail page with reskinned in-page header per D-18"
|
||||
contains: "ArrowLeft"
|
||||
key_links:
|
||||
- from: "app/mobile/tickets/[id]/page.tsx"
|
||||
to: "lucide-react ArrowLeft + ExternalLink icons"
|
||||
via: "named import"
|
||||
pattern: "ExternalLink"
|
||||
- from: "app/mobile/tickets/[id]/page.tsx"
|
||||
to: "/api/mobile/tickets/{id}/timeline endpoint"
|
||||
via: "fetch — unchanged from legacy"
|
||||
pattern: "fetch\\(`/api/mobile/tickets/"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Reskin only the in-page header bar at the top of `app/mobile/tickets/[id]/page.tsx` per D-18: replace the current "← Back" button with a three-slot header (back chevron + label, breadcrumb, external link). The detail body — priority badge row, h1 title, stats grid, description block, timeline — stays untouched per D-19.
|
||||
|
||||
Purpose: closes TICK-07. The shell HeaderBar already renders above this page from `app/mobile/layout.tsx`, so the in-page header docks under it consistently with the new shell language.
|
||||
|
||||
Output:
|
||||
- Modified `app/mobile/tickets/[id]/page.tsx` with the new three-slot header bar and `ExternalLink` icon import. Body untouched.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/REQUIREMENTS.md
|
||||
@.planning/phases/04-tickets-restyle/04-CONTEXT.md
|
||||
@.planning/phases/04-tickets-restyle/04-UI-SPEC.md
|
||||
@.planning/phases/02-mobile-shell/02-CONTEXT.md
|
||||
@CLAUDE.md
|
||||
@app/mobile/tickets/[id]/page.tsx
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 1: Reskin the in-page header of /mobile/tickets/[id] with back chevron, breadcrumb, and external-link icon</name>
|
||||
<files>app/mobile/tickets/[id]/page.tsx</files>
|
||||
<read_first>
|
||||
- app/mobile/tickets/[id]/page.tsx (full 357-line current file — only lines ~239-242 change in the header; everything else is preserved)
|
||||
- .planning/phases/04-tickets-restyle/04-CONTEXT.md decisions D-18 (header structure) and D-19 (body unchanged)
|
||||
- .planning/phases/04-tickets-restyle/04-UI-SPEC.md "Detail Page In-Page Header" section
|
||||
</read_first>
|
||||
<action>
|
||||
Open `app/mobile/tickets/[id]/page.tsx` and make TWO surgical edits.
|
||||
|
||||
**Edit 1: Add `ExternalLink` to the lucide-react imports (line 5-8 region).**
|
||||
|
||||
Current import block:
|
||||
```typescript
|
||||
import {
|
||||
ArrowLeft, RefreshCw, Clock, FileText, Timer, CheckCircle2,
|
||||
ChevronDown, ChevronRight, User, Briefcase, AlertCircle, EyeOff, Eye, Mail, AlignLeft, Code2,
|
||||
} from 'lucide-react';
|
||||
```
|
||||
|
||||
Add `ExternalLink` to the named imports (alphabetical position: after `Eye`, before `Mail`). Final form:
|
||||
```typescript
|
||||
import {
|
||||
ArrowLeft, RefreshCw, Clock, FileText, Timer, CheckCircle2,
|
||||
ChevronDown, ChevronRight, User, Briefcase, AlertCircle, EyeOff, Eye, ExternalLink, Mail, AlignLeft, Code2,
|
||||
} from 'lucide-react';
|
||||
```
|
||||
|
||||
Do NOT remove any existing import — `ArrowLeft`, `RefreshCw`, etc. all remain in use.
|
||||
|
||||
**Edit 2: Replace the legacy back button (currently at lines ~240-242) with the three-slot header bar per D-18 / UI-SPEC §"Detail Page In-Page Header".**
|
||||
|
||||
Current code (the section to replace — inside the `{/* Ticket header */}` div, only the FIRST element of that block):
|
||||
```tsx
|
||||
<button onClick={() => router.back()} className="flex items-center gap-1 text-sm text-muted-foreground mb-3 hover:text-foreground">
|
||||
<ArrowLeft className="w-4 h-4" /> Back
|
||||
</button>
|
||||
```
|
||||
|
||||
Replace with the new three-slot header. **Important context:** the parent `<div className="px-4 pt-4 pb-3 border-b">` already provides horizontal padding and the bottom border. The new header must NOT double-bracket the border. So the new header bar replaces ONLY the back button — keep the parent div as-is, just substitute its first child:
|
||||
|
||||
```tsx
|
||||
<div className="flex items-center justify-between -mx-4 px-4 py-3 border-b mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
aria-label="Back to Tickets"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" aria-hidden="true" />
|
||||
<span>Tickets</span>
|
||||
</button>
|
||||
|
||||
<p className="text-sm font-semibold truncate mx-2 flex-1 text-center">
|
||||
Tickets / #{ticket.ticket_number}
|
||||
</p>
|
||||
|
||||
<a
|
||||
href={`/analyzer/ticket/${ticket.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Open ticket on desktop"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
```
|
||||
|
||||
Notes on the layout:
|
||||
- `-mx-4 px-4` extends the header band to the parent div's edges and reapplies internal padding so the `border-b` runs full-width visually under the new header.
|
||||
- The legacy parent div still has its own `border-b` from when it bracketed the entire header (badges + title + stats + description). That outer `border-b` stays — it now sits below the description / stats area, which is the correct visual structure (the new header has its own divider; the outer border still divides the header section from the timeline).
|
||||
- Breadcrumb uses `text-sm font-semibold truncate` — UI-SPEC §"Typography" `Detail breadcrumb` row.
|
||||
- Desktop URL: `/analyzer/ticket/{id}` — this is the desktop analyzer ticket view (the canonical desktop ticket URL in this codebase). UI-SPEC permits "Autotask direct URL" as alternative; the analyzer URL is the in-app desktop equivalent and stays inside the auth boundary.
|
||||
- `aria-hidden="true"` on icons because the surrounding text / aria-label provides the accessible name.
|
||||
|
||||
**What NOT to change (D-19 — body untouched):**
|
||||
- The badges row (`<div className="flex items-start gap-2 mb-2">` with priority + ticket number + status pills) — preserve verbatim.
|
||||
- The `<h1 className="text-base font-bold leading-snug">{ticket.title}</h1>` — preserve verbatim.
|
||||
- The metadata icons row (Briefcase / User / Clock with company / assignee / created date) — preserve verbatim.
|
||||
- The 3-card stats grid (Notes / Time entries / Hours logged) — preserve verbatim.
|
||||
- The Description Collapsible block + Timeline section — preserve verbatim.
|
||||
- The `TimelineCard` component definition — preserve verbatim.
|
||||
- The `loading` and `error` states — preserve verbatim.
|
||||
- All helper functions (`fmtDate`, `fmtHours`, `renderContent`, `relTime`) — preserve verbatim.
|
||||
- The `STATUS_LABEL`, `PRIORITY_LABEL`, `PRIORITY_COLOR` constant maps — preserve verbatim.
|
||||
|
||||
Do NOT touch `app/mobile/tickets/[id]/timeline/route.ts` or any other file. The plan's `files_modified` is exactly one file.
|
||||
|
||||
Anti-patterns (do NOT do):
|
||||
- Do NOT remove the parent `<div className="px-4 pt-4 pb-3 border-b">` wrapper — the body still expects it.
|
||||
- Do NOT remove or alter the existing badges, title, stats, description, or timeline — body is out of scope (D-19).
|
||||
- Do NOT introduce a `<HeaderBar>` element here — that's the shell's job and already renders from `app/mobile/layout.tsx`.
|
||||
- Do NOT swap the desktop URL to an external Autotask link unless the analyzer URL is unreachable — the UI-SPEC accepts either; analyzer URL is preferred (in-app navigation).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty 2>&1 | grep -E "app/mobile/tickets/\[id\]/page\.tsx" || echo "OK: detail page typechecks"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -q "ExternalLink" app/mobile/tickets/[id]/page.tsx` (icon imported and used)
|
||||
- `grep -q 'aria-label="Back to Tickets"' app/mobile/tickets/[id]/page.tsx` (D-18 / UI-SPEC accessibility)
|
||||
- `grep -q 'aria-label="Open ticket on desktop"' app/mobile/tickets/[id]/page.tsx` (D-18 external link a11y)
|
||||
- `grep -q "Tickets / #" app/mobile/tickets/[id]/page.tsx` (D-18 breadcrumb literal)
|
||||
- `grep -q "router\.back()" app/mobile/tickets/[id]/page.tsx` (back gesture preserved)
|
||||
- `grep -q "/analyzer/ticket/" app/mobile/tickets/[id]/page.tsx` (desktop URL in href)
|
||||
- `grep -q 'target="_blank"' app/mobile/tickets/[id]/page.tsx` (opens in new tab)
|
||||
- `grep -q 'rel="noopener noreferrer"' app/mobile/tickets/[id]/page.tsx` (security on target=_blank)
|
||||
- `grep -q "TimelineCard" app/mobile/tickets/[id]/page.tsx` (body component preserved — D-19)
|
||||
- `grep -q "function fmtDate" app/mobile/tickets/[id]/page.tsx` (body helper preserved — D-19)
|
||||
- `grep -q "function renderContent" app/mobile/tickets/[id]/page.tsx` (body helper preserved — D-19)
|
||||
- `grep -q "Notes" app/mobile/tickets/[id]/page.tsx && grep -q "Time entries" app/mobile/tickets/[id]/page.tsx && grep -q "Hours logged" app/mobile/tickets/[id]/page.tsx` (stats grid preserved — D-19)
|
||||
- `! grep -qE ">\\s*Back\\s*</button>" app/mobile/tickets/[id]/page.tsx` (legacy "Back" text removed in favor of "Tickets")
|
||||
- `npx tsc --noEmit --pretty 2>&1` reports no errors for `app/mobile/tickets/[id]/page.tsx`
|
||||
</acceptance_criteria>
|
||||
<done>The detail page imports `ExternalLink`, renders the three-slot in-page header (back chevron + "Tickets" label, breadcrumb "Tickets / #{ticket_number}", external-link icon to `/analyzer/ticket/{id}`), and leaves the badges row, title, stats grid, description, and timeline byte-identical to the legacy implementation. TypeScript compiles cleanly.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| in-page link → external desktop URL | The new ExternalLink anchor opens `/analyzer/ticket/{id}` in a new tab |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-04-11 | Tampering | external link `target="_blank"` reverse tabnabbing | mitigate | `rel="noopener noreferrer"` on the anchor — prevents the opened page from accessing `window.opener`, even though `/analyzer/ticket/{id}` is same-origin. Defense in depth. |
|
||||
| T-04-12 | Information Disclosure | desktop URL leaks ticket id in browser tab | accept | Same id is already in the current page URL; no new exposure. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
1. `npx tsc --noEmit --pretty` passes — no errors for `app/mobile/tickets/[id]/page.tsx`.
|
||||
2. All Task 1 acceptance-criteria greps return success.
|
||||
3. Manual smoke (developer terminal): visit `/mobile/tickets/<an-id>` and confirm:
|
||||
- The shell HeaderBar (Wulf wordmark + Bell + avatar) renders at the very top from `app/mobile/layout.tsx`.
|
||||
- Below it, the new in-page header shows back chevron + "Tickets" on the left, breadcrumb in the center, ExternalLink icon on the right.
|
||||
- Below that, the unchanged badges row → title → stats grid → description → timeline.
|
||||
- Tapping "Tickets" calls `router.back()` and returns to the list.
|
||||
- Tapping the ExternalLink icon opens `/analyzer/ticket/<id>` in a new tab.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- TICK-07: Detail page header reskinned to match the new shell language (back chevron + breadcrumb + external link); body unchanged.
|
||||
- D-18 implemented exactly: three slots (back, breadcrumb, external).
|
||||
- D-19 honored: badges, title, stats grid, description, timeline byte-identical.
|
||||
- TypeScript clean.
|
||||
- The shell HeaderBar from `app/mobile/layout.tsx` continues to render above this in-page header — no double-rendering.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/04-tickets-restyle/04-03-SUMMARY.md` documenting:
|
||||
- The exact lines changed in `app/mobile/tickets/[id]/page.tsx` (import line + header bar replacement).
|
||||
- The desktop URL chosen (`/analyzer/ticket/{id}`) and the rationale (in-app navigation, stays in auth boundary).
|
||||
- Confirmation that the body (D-19 scope) was not touched — list which sections remained verbatim.
|
||||
- That this plan ran in parallel with 04-02 (no shared file conflict).
|
||||
</output>
|
||||
Loading…
Add table
Add a link
Reference in a new issue