wulf-pulse/.planning/phases/04-tickets-restyle/04-01-PLAN.md
lorentz 9658640c04 fix(04-01): restore phase 2/3 work lost by worktree soft-reset
The soft reset to 77073ba inadvertently staged deletions of all phase 2
and 3 artifacts. This commit restores them from their source commits so
subsequent task commits build on the complete prior-phase foundation:
- components/mobile/{BottomNav,HeaderBar,KpiCardMobile,MoreDrawer,NeedsAttentionStrip,WorkerStatusRow}
- app/mobile/layout.tsx, dashboard/page.tsx, analyzer/page.tsx
- app/api/mobile/dashboard/route.ts
- All .planning/** files from phases 01-04
- CLAUDE.md, app/layout.tsx, app/styles/brand.css, public/manifest.json
2026-05-03 18:01:14 -04:00

32 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
04 01 execute 1
app/api/mobile/tickets/route.ts
components/mobile/TicketRowSkeleton.tsx
components/mobile/TicketFilterStrip.tsx
true
TICK-01
TICK-02
TICK-05
truths artifacts key_links
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
path provides exports contains
app/api/mobile/tickets/route.ts Cursor-paginated list endpoint exporting MobileTicket and MobileTicketListResponse interfaces
GET
MobileTicket
MobileTicketListResponse
nextCursor
path provides exports
components/mobile/TicketFilterStrip.tsx Collapsible filter strip presentational component
TicketFilterStrip
TicketFilterValue
QueueOption
path provides exports
components/mobile/TicketRowSkeleton.tsx Skeleton placeholder row matching ticket row layout
TicketRowSkeleton
from to via pattern
app/api/mobile/tickets/route.ts kiosk_settings table via getMobileCompanyFilter() preserved helper, unchanged getMobileCompanyFilter
from to via pattern
app/api/mobile/tickets/route.ts tickets / companies / queues / resources tables parameterized SQL, last_activity_date DESC NULLS LAST, id DESC tie-breaker ORDER BY.*last_activity_date.*DESC.*id.*DESC
from to via pattern
components/mobile/TicketFilterStrip.tsx components/ui/collapsible.tsx shadcn primitive import from ['"]@/components/ui/collapsible['"]
Reshape `/api/mobile/tickets` from page-based pagination (`?page=N&limit=30`) to opaque-cursor pagination (`?cursor=&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)

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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 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:

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):

export { Select, SelectContent, SelectItem, SelectTrigger, SelectValue }

From components/ui/switch.tsx (shadcn):

export { Switch }  // controlled via checked + onCheckedChange

From components/ui/skeleton.tsx (shadcn):

export { Skeleton }  // div with bg-muted animate-pulse rounded

From components/ui/input.tsx (shadcn):

export { Input }

From components/ui/button.tsx (shadcn):

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
Task 1: Rewrite /api/mobile/tickets to cursor-paginated shape with exported interfaces app/api/mobile/tickets/route.ts - 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 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).
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" - `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` 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. Task 2: Create TicketRowSkeleton and TicketFilterStrip presentational components components/mobile/TicketRowSkeleton.tsx, components/mobile/TicketFilterStrip.tsx - .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) 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.
npx tsc --noEmit --pretty 2>&1 | grep -E "components/mobile/(TicketRowSkeleton|TicketFilterStrip)" || echo "OK: no type errors in new components" - `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 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.

<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>
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).

<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>
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.