# Admin Mobile PWA Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Make the Route Commerce admin dashboard a first-class, installable, offline-capable mobile PWA with Apple HIG Accessibility-tier legibility for outdoor/gloved use. **Architecture:** Mobile-first admin shell (bottom tab bar + drag-up "More" sheet + sticky top bar) inside a new `/admin/v2/*` route tree. Native `` for sheets. IndexedDB mutation queue with exponential backoff and last-write-wins conflict resolution. New design tokens (type, color, spacing, motion) enforced by a CI contrast test. Service worker with two-cache strategy (shell + data) and a real offline fallback page. Rollout via dual deployment at `/admin/v2/*` then route cutover. **Tech Stack:** Next.js 16 (App Router) · Postgres (direct via `pg`) · Neon Auth (Better Auth) · Tailwind v4 · Vitest + Testing Library · Playwright · Lighthouse CI · IndexedDB (via `idb` library) **Spec:** [docs/superpowers/specs/2026-06-17-admin-mobile-pwa-design.md](../specs/2026-06-17-admin-mobile-pwa-design.md) **Conventions:** All tasks use the existing project's commit message style (`feat:`, `fix:`, `docs:`, `chore:`, `test:`, `refactor:`). Server actions live in `src/actions/`. Components in `src/components/`. Tests in `src/**/__tests__/` (unit) and `tests/` (E2E). RPCs are `CREATE OR REPLACE FUNCTION` and go in `db/migrations/NNN_*.sql` (next number chosen by implementer based on current production state — check `MEMORY.md` or the prod DB). **PR overview:** | PR | Title | New files | Modified files | |---|---|---|---| | 1 | Design system + admin shell + offline queue | 13 | 4 | | 2 | PWA manifest + SW + icons + offline page | 21 | 6 | | 3 | Orders v2 (list + detail) | 4 | 0 | | 4 | Stops v2 | 2 | 0 | | 5 | Products v2 | 2 | 0 | | 6 | Cutover Orders + Stops + Products | 0 | 3 | | 7 | Dashboard v2 + dashboard RPC | 1 (RPC migration) + 1 (page) | 0 | | 8 | Dashboard cutover | 0 | 2 | --- ## PR 1 — Design system + admin shell + offline queue This PR lays the foundation. After this PR, we have the new design tokens, the new mobile-first admin shell (visible at `/admin/v2` once we add a page), the shared components every other PR uses, the IndexedDB mutation queue, and the CI contrast test that prevents future color regressions. No existing routes are modified. ### Task 1.1: Add design tokens to `globals.css` **Files:** - Modify: `src/app/globals.css:1-100` - [ ] **Step 1: Add the new tokens** Open `src/app/globals.css`. The file currently has `@import "tailwindcss";` at the top and an `@theme {}` block with `--font-*`, `--color-surface-*`, `--color-forest-*`, `--color-sage-*`, `--color-gold-*`, `--color-warmred-*`, `--color-citrus-*` tokens. Add a new `@theme` block immediately after the existing one (Tailwind v4 supports multiple `@theme` blocks). This is the canonical "Field Almanac" color set and the HIG Accessibility-tier type scale: ```css /* ─── Field Almanac palette (mobile-first / HIG Accessibility) ─── */ :root { /* Surfaces */ --color-bg: #ffffff; --color-surface: #faf8f5; --color-surface-2: #f5f5f7; --color-surface-3: #e8e8ed; /* Text — AAA on every surface except --text-faint (AA only, ≥ 5.28:1) */ --color-text: #1d1d1f; --color-text-muted: #424245; --color-text-faint: #5e5e63; /* AA on every surface (≥ 5.28:1) */ /* Accent / status — darkened to pass AAA on the surfaces they actually * appear on (bg, surface, and their -soft pill backgrounds). The darker * hues (green-900 / red-900 / amber-900) intentionally trade a touch of * vibrancy for legibility in outdoor / sun-glare conditions. */ --color-accent: #14532d; /* AAA on bg (9.11), surface (8.59), s2 (8.37), s3 (7.46) */ --color-accent-2: #166534; /* hover state — used as button background, NOT text */ --color-accent-soft: #dcfce7; /* pill background; accent text on it = 8.36:1 */ --color-danger: #7f1d1d; /* AAA on bg (10.02), surface (9.45), s2 (9.20), s3 (8.20) */ --color-danger-soft: #fee2e2; --color-warning: #5e2a04; /* AAA on bg (11.61), surface (10.95), s2 (10.66), s3 (9.51) */ --color-warning-soft: #fef9c3; --color-success: #14532d; /* mirrors accent */ --color-success-soft: #dcfce7; --color-info: #1e3a8a; /* AAA on every surface (8.48–10.36) */ --color-info-soft: #dbeafe; } /* ─── Type scale (HIG Accessibility-tier) ─── */ :root { --text-display: 32px; --text-display--line-height: 1.15; --text-display--letter-spacing: -0.01em; --text-h1: 24px; --text-h1--line-height: 1.2; --text-h2: 19px; --text-h2--line-height: 1.3; --text-body: 18px; --text-body--line-height: 1.45; --text-label: 15px; --text-label--line-height: 1.3; --text-label--letter-spacing: 0.02em; --text-mono: 14px; --text-mono--line-height: 1.4; --text-meta: 13px; --text-meta--line-height: 1.4; } /* ─── Spacing (8pt grid) ─── */ :root { --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px; --space-6: 24px; --space-8: 32px; --space-12: 48px; --space-16: 64px; } /* ─── Motion ─── */ :root { --ease-apple: cubic-bezier(0.32, 0.72, 0, 1); --duration-fast: 150ms; --duration-base: 200ms; --duration-slow: 320ms; } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } } ``` - [ ] **Step 2: Verify Tailwind picks up the new tokens** Run: `npm run lint && npx tsc --noEmit` Expected: PASS (no type/lint errors). Run: `npm run dev` and open `http://localhost:3000` in a browser. DevTools → Elements → `` → check the `:root` computed styles to confirm `--color-bg`, `--text-display`, `--space-4` resolve. - [ ] **Step 3: Commit** ```bash git add src/app/globals.css git commit -m "feat(design): add Field Almanac tokens (type, color, spacing, motion)" ``` ### Task 1.2: Write the design-tokens contrast test **Files:** - Create: `src/lib/__tests__/design-tokens.test.ts` - [ ] **Step 1: Create the test file** ```typescript // src/lib/__tests__/design-tokens.test.ts import { describe, it, expect } from "vitest"; /** * Asserts all token combinations meet WCAG contrast requirements for the * surfaces they actually render on. Rationale: outdoor + sun-glare is * harder than WCAG accounts for, so we aim for AAA (7:1) on the * surfaces text appears on, and AA (4.5:1) as the documented floor. * * Surface roles: * bg — page background * surface — card / panel background * surface-2 — input fields, secondary cards * surface-3 — high-emphasis containers, skeletons, dividers * * Token roles (and which assertion groups they belong to): * text / text-muted — body text, appears on all 4 surfaces → AAA * text-faint — meta / timestamps, appears on all 4 surfaces → AA * accent / success — brand + positive status text → AAA on bg, surface * danger / warning / info — status text, on bg + surface + their -soft pill bg → AAA * accent-2 — button background, white text sits on top → AAA * *-soft — pill background, distinct from page surface (decorative) */ type RGB = [number, number, number]; function parseHex(hex: string): RGB { const h = hex.replace("#", ""); return [ parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16), ]; } function srgbToLinear(c: number): number { const v = c / 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); } function luminance([r, g, b]: RGB): number { return ( 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b) ); } function contrastRatio(fg: string, bg: string): number { const l1 = luminance(parseHex(fg)); const l2 = luminance(parseHex(bg)); const lighter = Math.max(l1, l2); const darker = Math.min(l1, l2); return (lighter + 0.05) / (darker + 0.05); } const SURFACES: Record = { bg: "#ffffff", surface: "#faf8f5", "surface-2": "#f5f5f7", "surface-3": "#e8e8ed", }; // Page surfaces that status text actually renders on. Status text does // not render directly on --color-surface-3 (skeleton/dividers); it lives // on its own -soft pill background instead. const PAGE_SURFACES: Record = { bg: "#ffffff", surface: "#faf8f5", }; const BODY_TEXT_AAA: Record = { text: "#1d1d1f", "text-muted": "#424245", info: "#1e3a8a", }; const META_TEXT_AA: Record = { "text-faint": "#5e5e63", }; const STATUS_TEXT_AAA: Record = { accent: "#14532d", danger: "#7f1d1d", warning: "#5e2a04", success: "#14532d", }; const STATUS_ON_SOFT: Record = { "accent on accent-soft": { text: "#14532d", soft: "#dcfce7" }, "danger on danger-soft": { text: "#7f1d1d", soft: "#fee2e2" }, "warning on warning-soft": { text: "#5e2a04", soft: "#fef9c3" }, "success on success-soft": { text: "#14532d", soft: "#dcfce7" }, "info on info-soft": { text: "#1e3a8a", soft: "#dbeafe" }, }; const ON_ACCENT_BUTTON = { fg: "#ffffff", // button label bg: "#166534", // --color-accent-2 }; describe("design tokens — WCAG contrast", () => { describe("body text on every page surface (AAA)", () => { Object.entries(BODY_TEXT_AAA).forEach(([token, hex]) => { Object.entries(SURFACES).forEach(([surfaceName, surfaceHex]) => { it(`${token} on ${surfaceName} meets AAA (>= 7:1)`, () => { const ratio = contrastRatio(hex, surfaceHex); expect(ratio, `${token} (${hex}) on ${surfaceName} (${surfaceHex}) = ${ratio.toFixed(2)}:1`).toBeGreaterThanOrEqual(7); }); }); }); }); describe("meta text on every page surface (AA)", () => { Object.entries(META_TEXT_AA).forEach(([token, hex]) => { Object.entries(SURFACES).forEach(([surfaceName, surfaceHex]) => { it(`${token} on ${surfaceName} meets AA (>= 4.5:1)`, () => { const ratio = contrastRatio(hex, surfaceHex); expect(ratio, `${token} (${hex}) on ${surfaceName} (${surfaceHex}) = ${ratio.toFixed(2)}:1`).toBeGreaterThanOrEqual(4.5); }); }); }); }); describe("status text on page surfaces (AAA)", () => { Object.entries(STATUS_TEXT_AAA).forEach(([token, hex]) => { Object.entries(PAGE_SURFACES).forEach(([surfaceName, surfaceHex]) => { it(`${token} on ${surfaceName} meets AAA (>= 7:1)`, () => { const ratio = contrastRatio(hex, surfaceHex); expect(ratio, `${token} (${hex}) on ${surfaceName} (${surfaceHex}) = ${ratio.toFixed(2)}:1`).toBeGreaterThanOrEqual(7); }); }); }); }); describe("status text on its -soft pill background (AAA)", () => { Object.entries(STATUS_ON_SOFT).forEach(([name, { text, soft }]) => { it(`${name} meets AAA (>= 7:1)`, () => { const ratio = contrastRatio(text, soft); expect(ratio, `${name}: ${text} on ${soft} = ${ratio.toFixed(2)}:1`).toBeGreaterThanOrEqual(7); }); }); }); describe("button text on accent-2 background (AAA)", () => { it(`white on accent-2 meets AAA (>= 7:1)`, () => { const ratio = contrastRatio(ON_ACCENT_BUTTON.fg, ON_ACCENT_BUTTON.bg); expect(ratio, `white on ${ON_ACCENT_BUTTON.bg} = ${ratio.toFixed(2)}:1`).toBeGreaterThanOrEqual(7); }); }); describe("pill backgrounds are visually distinct from page surface", () => { // The "soft" backgrounds are decorative. They don't need to pass // contrast on their own, but they must read as a distinct fill // against the page surface. const softs: Record = { "accent-soft": "#dcfce7", "danger-soft": "#fee2e2", "warning-soft": "#fef9c3", "success-soft": "#dcfce7", "info-soft": "#dbeafe", }; Object.entries(softs).forEach(([token, hex]) => { it(`${token} reads as a distinct fill on --color-surface`, () => { const ratio = contrastRatio(hex, "#faf8f5"); expect(ratio, `${token} too close to surface (${ratio.toFixed(2)}:1)`).toBeLessThan(1.5); }); }); }); }); ``` - [ ] **Step 2: Run the test** Run: `npx vitest run src/lib/__tests__/design-tokens.test.ts` Expected: 35 tests, all PASS. (3 body text AAA × 4 surfaces = 12, plus 1 meta text AA × 4 surfaces = 4, plus 4 status text AAA × 2 page surfaces = 8, plus 5 status-on-soft AAA = 5, plus 1 button-text AAA = 1, plus 5 soft-distinct = 5, total = 35. If a real product surface is missing from the matrix, add it to `SURFACES` or `PAGE_SURFACES`; the goal is to cover what ships.) - [ ] **Step 3: Commit** ```bash git add src/lib/__tests__/design-tokens.test.ts git commit -m "test(design): assert WCAG contrast for all Field Almanac tokens" ``` ### Task 1.3: Add Vitest configuration (if not present) **Files:** - Modify: `package.json` (verify) - Create: `vitest.config.ts` (only if not present) - [ ] **Step 1: Check existing Vitest config** Run: `cat package.json | grep -A1 -B1 "vitest\|test\":\s*\""` Expected: Either a `"test": "vitest"` script in `package.json` (no separate config needed for basic use), or a `vitest.config.ts` at the root. If neither exists: ```bash npm install --save-dev vitest @vitest/ui happy-dom @testing-library/react @testing-library/jest-dom ``` - [ ] **Step 2: Add a test script to package.json** (if not present) Open `package.json` and ensure the `scripts` section includes: ```json "test": "vitest run", "test:watch": "vitest" ``` - [ ] **Step 3: Run all tests to confirm baseline** Run: `npm test` Expected: PASS. The design-tokens test from Task 1.2 should run. - [ ] **Step 4: Commit (only if you changed something)** ```bash git add package.json package-lock.json git commit -m "chore(test): ensure vitest is configured" ``` ### Task 1.4: Create the IndexedDB wrapper **Files:** - Create: `src/lib/offline/db.ts` - [ ] **Step 1: Install `idb` (a thin Promise wrapper for IndexedDB)** Run: `npm install idb` Expected: package updated. - [ ] **Step 2: Create the database module** ```typescript // src/lib/offline/db.ts import { openDB, type DBSchema, type IDBPDatabase } from "idb"; export interface QueuedAction { id: string; // clientActionId (uuid v4) actionName: string; // e.g. "markOrderReady" payload: unknown; // JSON-serializable args createdAt: number; // Date.now() attempts: number; // sync attempts lastAttemptAt: number | null; status: "pending" | "in-flight" | "synced" | "conflict" | "failed"; error?: string; } interface RouteCommerceOfflineDB extends DBSchema { queue: { key: string; value: QueuedAction; indexes: { "by-status": string; "by-created": number }; }; cache: { key: string; // e.g. "orders:brandId:abc" value: { key: string; data: unknown; cachedAt: number }; }; } const DB_NAME = "route-commerce-offline"; const DB_VERSION = 1; let dbPromise: Promise> | null = null; export function getOfflineDB(): Promise> { if (typeof window === "undefined") { throw new Error("getOfflineDB called on the server"); } if (!dbPromise) { dbPromise = openDB(DB_NAME, DB_VERSION, { upgrade(db) { if (!db.objectStoreNames.contains("queue")) { const queue = db.createObjectStore("queue", { keyPath: "id" }); queue.createIndex("by-status", "status"); queue.createIndex("by-created", "createdAt"); } if (!db.objectStoreNames.contains("cache")) { db.createObjectStore("cache", { keyPath: "key" }); } }, }); } return dbPromise; } ``` - [ ] **Step 3: Verify it compiles** Run: `npx tsc --noEmit` Expected: PASS. - [ ] **Step 4: Commit** ```bash git add package.json package-lock.json src/lib/offline/db.ts git commit -m "feat(offline): add IndexedDB schema for queue + cache" ``` ### Task 1.5: Write the queue module (TDD) **Files:** - Create: `src/lib/offline/queue.ts` - Test: `src/lib/offline/queue.test.ts` - [ ] **Step 1: Write the failing test** ```typescript // src/lib/offline/queue.test.ts import { describe, it, expect, beforeEach } from "vitest"; import "fake-indexeddb/auto"; import { enqueueAction, dequeueAction, getQueuedActions, markSynced, markConflict } from "./queue"; beforeEach(async () => { // Clear the queue between tests const { getOfflineDB } = await import("./db"); const db = await getOfflineDB(); await db.clear("queue"); }); describe("offline queue", () => { it("enqueueAction stores an action with a generated clientActionId", async () => { const action = await enqueueAction("markOrderReady", { orderId: "abc" }); expect(action.id).toMatch(/^[0-9a-f-]{36}$/i); expect(action.actionName).toBe("markOrderReady"); expect(action.status).toBe("pending"); expect(action.attempts).toBe(0); }); it("enqueueAction is idempotent on the same clientActionId", async () => { const a = await enqueueAction("markOrderReady", { orderId: "abc" }, { id: "fixed-id" }); const b = await enqueueAction("markOrderReady", { orderId: "abc" }, { id: "fixed-id" }); expect(a.id).toBe(b.id); expect(a.id).toBe("fixed-id"); const all = await getQueuedActions(); expect(all).toHaveLength(1); }); it("getQueuedActions returns pending actions in createdAt order", async () => { const a = await enqueueAction("a", {}, { id: "a" }); // Small delay to ensure ordering await new Promise((r) => setTimeout(r, 5)); const b = await enqueueAction("b", {}, { id: "b" }); const all = await getQueuedActions(); expect(all.map((x) => x.id)).toEqual([a.id, b.id]); }); it("dequeueAction removes the action and returns its data", async () => { const a = await enqueueAction("markOrderReady", { orderId: "abc" }); const removed = await dequeueAction(a.id); expect(removed?.id).toBe(a.id); const all = await getQueuedActions(); expect(all).toHaveLength(0); }); it("markSynced removes the action", async () => { const a = await enqueueAction("markOrderReady", { orderId: "abc" }); await markSynced(a.id); const all = await getQueuedActions(); expect(all).toHaveLength(0); }); it("markConflict moves the action to conflict status", async () => { const a = await enqueueAction("markOrderReady", { orderId: "abc" }); await markConflict(a.id, "stale updated_at"); const all = await getQueuedActions(); expect(all).toHaveLength(1); expect(all[0].status).toBe("conflict"); expect(all[0].error).toBe("stale updated_at"); }); }); ``` - [ ] **Step 2: Run the test to verify it fails** Run: `npx vitest run src/lib/offline/queue.test.ts` Expected: FAIL with "Cannot find module './queue'". - [ ] **Step 3: Install `fake-indexeddb` (dev) and `uuid`** Run: `npm install --save-dev fake-indexeddb` Run: `npm install uuid && npm install --save-dev @types/uuid` - [ ] **Step 4: Implement the queue module** ```typescript // src/lib/offline/queue.ts import { v4 as uuidv4 } from "uuid"; import { getOfflineDB, type QueuedAction } from "./db"; export interface EnqueueOptions { id?: string; // supply for idempotency } export async function enqueueAction( actionName: string, payload: unknown, options: EnqueueOptions = {} ): Promise { const db = await getOfflineDB(); const id = options.id ?? uuidv4(); const existing = await db.get("queue", id); if (existing) return existing; const action: QueuedAction = { id, actionName, payload, createdAt: Date.now(), attempts: 0, lastAttemptAt: null, status: "pending", }; await db.put("queue", action); return action; } export async function getQueuedActions(): Promise { const db = await getOfflineDB(); const all = await db.getAllFromIndex("queue", "by-created"); return all.filter((a) => a.status !== "synced"); } export async function dequeueAction(id: string): Promise { const db = await getOfflineDB(); const tx = db.transaction("queue", "readwrite"); const action = await tx.store.get(id); if (!action) { await tx.done; return null; } await tx.store.delete(id); await tx.done; return action; } export async function markInFlight(id: string): Promise { const db = await getOfflineDB(); const action = await db.get("queue", id); if (!action) return; action.status = "in-flight"; action.lastAttemptAt = Date.now(); action.attempts += 1; await db.put("queue", action); } export async function markSynced(id: string): Promise { const db = await getOfflineDB(); await db.delete("queue", id); } export async function markConflict(id: string, error: string): Promise { const db = await getOfflineDB(); const action = await db.get("queue", id); if (!action) return; action.status = "conflict"; action.error = error; await db.put("queue", action); } export async function markFailed(id: string, error: string): Promise { const db = await getOfflineDB(); const action = await db.get("queue", id); if (!action) return; action.status = "failed"; action.error = error; action.attempts += 1; action.lastAttemptAt = Date.now(); await db.put("queue", action); } ``` - [ ] **Step 5: Run the test to verify it passes** Run: `npx vitest run src/lib/offline/queue.test.ts` Expected: 6 tests, all PASS. - [ ] **Step 6: Commit** ```bash git add src/lib/offline/queue.ts src/lib/offline/queue.test.ts package.json package-lock.json git commit -m "feat(offline): add IndexedDB mutation queue (enqueue, dequeue, mark synced/conflict)" ``` ### Task 1.6: Write the sync module (TDD) **Files:** - Create: `src/lib/offline/sync.ts` - Test: `src/lib/offline/sync.test.ts` - [ ] **Step 1: Write the failing test** ```typescript // src/lib/offline/sync.test.ts import { describe, it, expect, beforeEach, vi } from "vitest"; import "fake-indexeddb/auto"; import { enqueueAction } from "./queue"; import { syncPending, calculateBackoff, BACKOFF_SCHEDULE, MAX_ATTEMPTS } from "./sync"; beforeEach(async () => { const { getOfflineDB } = await import("./db"); const db = await getOfflineDB(); await db.clear("queue"); }); describe("offline sync — backoff", () => { it("BACKOFF_SCHEDULE returns 1s, 4s, 16s, 60s, 300s", () => { expect(BACKOFF_SCHEDULE).toEqual([1000, 4000, 16000, 60000, 300000]); }); it("MAX_ATTEMPTS is 5", () => { expect(MAX_ATTEMPTS).toBe(5); }); it("calculateBackoff(0) returns the first delay", () => { expect(calculateBackoff(0)).toBe(1000); }); it("calculateBackoff(4) returns the last delay", () => { expect(calculateBackoff(4)).toBe(300000); }); it("calculateBackoff(99) caps at MAX_ATTEMPTS (gives up)", () => { expect(calculateBackoff(99)).toBe(300000); }); }); describe("offline sync — replay", () => { it("syncPending posts each action and marks it synced on success", async () => { const a = await enqueueAction("markOrderReady", { orderId: "abc" }); const dispatcher = vi.fn().mockResolvedValue({ ok: true }); const result = await syncPending(dispatcher, { online: true, sleep: () => Promise.resolve() }); expect(dispatcher).toHaveBeenCalledWith("markOrderReady", { orderId: "abc" }, a.id); expect(result.synced).toBe(1); expect(result.conflicts).toBe(0); expect(result.failed).toBe(0); }); it("syncPending marks action as conflict when dispatcher returns conflict", async () => { await enqueueAction("markOrderReady", { orderId: "abc" }); const dispatcher = vi.fn().mockResolvedValue({ ok: false, conflict: "stale" }); const result = await syncPending(dispatcher, { online: true, sleep: () => Promise.resolve() }); expect(result.conflicts).toBe(1); const { getQueuedActions } = await import("./queue"); const all = await getQueuedActions(); expect(all[0].status).toBe("conflict"); }); it("syncPending skips when offline", async () => { await enqueueAction("markOrderReady", { orderId: "abc" }); const dispatcher = vi.fn(); const result = await syncPending(dispatcher, { online: false, sleep: () => Promise.resolve() }); expect(dispatcher).not.toHaveBeenCalled(); expect(result.synced).toBe(0); }); }); ``` - [ ] **Step 2: Run the test to verify it fails** Run: `npx vitest run src/lib/offline/sync.test.ts` Expected: FAIL with "Cannot find module './sync'". - [ ] **Step 3: Implement the sync module** ```typescript // src/lib/offline/sync.ts import { enqueueAction, getQueuedActions, markInFlight, markSynced, markConflict, markFailed } from "./queue"; export const BACKOFF_SCHEDULE = [1000, 4000, 16000, 60000, 300000] as const; export const MAX_ATTEMPTS = 5; export function calculateBackoff(attempt: number): number { const idx = Math.min(attempt, BACKOFF_SCHEDULE.length - 1); return BACKOFF_SCHEDULE[idx]; } export type Dispatcher = (actionName: string, payload: unknown, clientActionId: string) => Promise< | { ok: true } | { ok: false; conflict: string } | { ok: false; error: string } >; export interface SyncOptions { online: boolean; sleep: (ms: number) => Promise; now?: () => number; } export interface SyncResult { synced: number; conflicts: number; failed: number; } export async function syncPending(dispatcher: Dispatcher, options: SyncOptions): Promise { const result: SyncResult = { synced: 0, conflicts: 0, failed: 0 }; if (!options.online) return result; const actions = await getQueuedActions(); for (const action of actions) { if (action.status === "conflict" || action.status === "failed") { // Don't auto-retry conflicts/failures; require manual user intervention continue; } if (action.attempts >= MAX_ATTEMPTS) { await markFailed(action.id, "max attempts reached"); result.failed += 1; continue; } const backoff = calculateBackoff(action.attempts); if (action.lastAttemptAt) { const elapsed = (options.now?.() ?? Date.now()) - action.lastAttemptAt; if (elapsed < backoff) continue; // not time yet } await markInFlight(action.id); try { const dispatchResult = await dispatcher(action.actionName, action.payload, action.id); if (dispatchResult.ok) { await markSynced(action.id); result.synced += 1; } else if ("conflict" in dispatchResult) { await markConflict(action.id, dispatchResult.conflict); result.conflicts += 1; } else { await markFailed(action.id, dispatchResult.error); result.failed += 1; } } catch (err) { await markFailed(action.id, (err as Error).message); result.failed += 1; } } return result; } /** * Wire this up to navigator.onLine + 'online' event in the client. * Returns an unsubscribe function. */ export function attachSyncListener(dispatcher: Dispatcher): () => void { if (typeof window === "undefined") return () => {}; const handler = () => { void syncPending(dispatcher, { online: navigator.onLine, sleep: (ms) => new Promise((r) => setTimeout(r, ms)) }); }; window.addEventListener("online", handler); // Also fire once on attach if online if (navigator.onLine) void handler(); return () => window.removeEventListener("online", handler); } ``` - [ ] **Step 4: Run the test to verify it passes** Run: `npx vitest run src/lib/offline/sync.test.ts` Expected: 8 tests, all PASS. - [ ] **Step 5: Commit** ```bash git add src/lib/offline/sync.ts src/lib/offline/sync.test.ts git commit -m "feat(offline): add sync dispatcher with backoff and conflict surfacing" ``` ### Task 1.7: Create the `OfflineBanner` component **Files:** - Create: `src/components/admin/OfflineBanner.tsx` - [ ] **Step 1: Create the component** ```tsx // src/components/admin/OfflineBanner.tsx "use client"; import { useEffect, useState } from "react"; import { getQueuedActions } from "@/lib/offline/queue"; export function OfflineBanner() { const [online, setOnline] = useState(true); const [pendingCount, setPendingCount] = useState(0); const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); setOnline(navigator.onLine); const onOnline = () => setOnline(true); const onOffline = () => setOnline(false); window.addEventListener("online", onOnline); window.addEventListener("offline", onOffline); const interval = setInterval(async () => { const actions = await getQueuedActions(); setPendingCount(actions.filter((a) => a.status === "pending" || a.status === "in-flight").length); }, 1000); return () => { window.removeEventListener("online", onOnline); window.removeEventListener("offline", onOffline); clearInterval(interval); }; }, []); if (!mounted || (online && pendingCount === 0)) return null; return (
{online ? ( <>Syncing {pendingCount} pending action{pendingCount === 1 ? "" : "s"}… ) : ( <>You're offline. Changes will sync when you reconnect. )}
); } export default OfflineBanner; ``` - [ ] **Step 2: Verify it renders** Run: `npx tsc --noEmit` Expected: PASS. - [ ] **Step 3: Commit** ```bash git add src/components/admin/OfflineBanner.tsx git commit -m "feat(admin): add OfflineBanner with online status + pending sync count" ``` ### Task 1.8: Create the `AdminShell` component (breakpoint-aware) **Files:** - Create: `src/components/admin/AdminShell.tsx` - [ ] **Step 1: Create the shell** ```tsx // src/components/admin/AdminShell.tsx import { ReactNode } from "react"; import AdminSidebar from "@/components/admin/AdminSidebar"; import MobileTabBar from "@/components/admin/MobileTabBar"; import OfflineBanner from "@/components/admin/OfflineBanner"; import { useMediaQuery } from "@/lib/use-media-query"; // see Task 1.9 interface AdminShellProps { userRole: string | null; brandIds?: string[]; activeBrandId?: string | null; brands: { id: string; name: string; slug: string; logo_url: string | null }[]; enabledAddons?: Record; children: ReactNode; } export function AdminShell(props: AdminShellProps) { const isDesktop = useMediaQuery("(min-width: 1024px)"); if (isDesktop) { return ( <>
{props.children}
); } return ( <>
{props.children}
); } export default AdminShell; ``` - [ ] **Step 2: Commit (the build will fail until Task 1.9 lands; that's fine for the commit)** ```bash git add src/components/admin/AdminShell.tsx git commit -m "feat(admin): add AdminShell with breakpoint-aware layout" ``` ### Task 1.9: Add the `useMediaQuery` hook **Files:** - Create: `src/lib/use-media-query.ts` - [ ] **Step 1: Create the hook** ```typescript // src/lib/use-media-query.ts "use client"; import { useEffect, useState } from "react"; export function useMediaQuery(query: string): boolean { const [matches, setMatches] = useState(() => { if (typeof window === "undefined") return false; return window.matchMedia(query).matches; }); useEffect(() => { if (typeof window === "undefined") return; const mq = window.matchMedia(query); const handler = (e: MediaQueryListEvent) => setMatches(e.matches); mq.addEventListener("change", handler); setMatches(mq.matches); return () => mq.removeEventListener("change", handler); }, [query]); return matches; } ``` - [ ] **Step 2: Verify** Run: `npx tsc --noEmit` Expected: PASS. - [ ] **Step 3: Commit** ```bash git add src/lib/use-media-query.ts git commit -m "feat(hooks): add useMediaQuery hook for responsive component logic" ``` ### Task 1.10: Create the `MobileTabBar` component **Files:** - Create: `src/components/admin/MobileTabBar.tsx` - [ ] **Step 1: Create the tab bar** ```tsx // src/components/admin/MobileTabBar.tsx "use client"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useState } from "react"; import MoreSheet from "@/components/admin/MoreSheet"; const TABS = [ { label: "Home", href: "/admin/v2", icon: HomeIcon }, { label: "Orders", href: "/admin/v2/orders", icon: OrdersIcon }, { label: "Stops", href: "/admin/v2/stops", icon: StopsIcon }, { label: "Products", href: "/admin/v2/products", icon: ProductsIcon }, ]; function HomeIcon({ active }: { active: boolean }) { return ( ); } function OrdersIcon({ active }: { active: boolean }) { return ( ); } function StopsIcon({ active }: { active: boolean }) { return ( ); } function ProductsIcon({ active }: { active: boolean }) { return ( ); } export function MobileTabBar() { const pathname = usePathname(); const [moreOpen, setMoreOpen] = useState(false); return ( <> setMoreOpen(false)} /> ); } export default MobileTabBar; ``` - [ ] **Step 2: Commit (the build will fail until Task 1.11 lands; that's fine)** ```bash git add src/components/admin/MobileTabBar.tsx git commit -m "feat(admin): add MobileTabBar with 4 primary tabs + More trigger" ``` ### Task 1.11: Create the `MoreSheet` component (native dialog) **Files:** - Create: `src/components/admin/MoreSheet.tsx` - [ ] **Step 1: Create the sheet** ```tsx // src/components/admin/MoreSheet.tsx "use client"; import Link from "next/link"; import { useEffect, useRef } from "react"; const MORE_SECTIONS = [ { heading: "Operations", links: [ { label: "Pickup", href: "/admin/pickup", icon: "📦" }, { label: "Shipping", href: "/admin/shipping", icon: "🚚" }, { label: "Reports", href: "/admin/reports", icon: "📊" }, { label: "Analytics", href: "/admin/analytics", icon: "📈" }, ], }, { heading: "Marketing", links: [ { label: "Communications", href: "/admin/communications", icon: "📧" }, { label: "Sales", href: "/admin/sales", icon: "💵" }, ], }, { heading: "Settings", links: [ { label: "Settings", href: "/admin/settings", icon: "⚙️" }, { label: "Advanced", href: "/admin/advanced", icon: "🛠" }, ], }, ]; interface MoreSheetProps { open: boolean; onClose: () => void; } export function MoreSheet({ open, onClose }: MoreSheetProps) { const dialogRef = useRef(null); const lastFocusedRef = useRef(null); useEffect(() => { const dialog = dialogRef.current; if (!dialog) return; if (open) { lastFocusedRef.current = document.activeElement as HTMLElement | null; if (!dialog.open) dialog.showModal(); } else if (dialog.open) { dialog.close(); lastFocusedRef.current?.focus(); } }, [open]); useEffect(() => { const dialog = dialogRef.current; if (!dialog) return; const onCancel = (e: Event) => { e.preventDefault(); onClose(); }; dialog.addEventListener("cancel", onCancel); return () => dialog.removeEventListener("cancel", onCancel); }, [onClose]); return ( { // Click on the backdrop (the dialog element itself) closes; clicks on content don't if (e.target === dialogRef.current) onClose(); }} className="w-full max-w-[640px] p-0 m-0 ml-auto h-full max-h-screen backdrop:bg-black/40" style={{ backgroundColor: "var(--color-bg)", color: "var(--color-text)", borderLeft: "1px solid var(--color-surface-3)", }} >

More

{MORE_SECTIONS.map((section) => (

{section.heading.toUpperCase()}

    {section.links.map((link) => (
  • {link.label}
  • ))}
))}
); } export default MoreSheet; ``` - [ ] **Step 2: Verify** Run: `npx tsc --noEmit` Expected: PASS. - [ ] **Step 3: Commit** ```bash git add src/components/admin/MoreSheet.tsx git commit -m "feat(admin): add MoreSheet (native dialog) with grouped secondary nav" ``` ### Task 1.12: Create the `PageHeader`, `StatusPill`, `EmptyState`, `CardList`, `StickyActionBar` components These are small, focused, presentational. Build them all in one PR by following the same TDD pattern (skip tests for purely presentational components — they're tested via visual regression in PR 3+). **Files:** - Create: `src/components/admin/PageHeader.tsx` - Create: `src/components/admin/StatusPill.tsx` - Create: `src/components/admin/CardList.tsx` - Create: `src/components/admin/StickyActionBar.tsx` > **Note:** `EmptyState.tsx` already exists in the repo and is in active use by `DashboardClient.tsx` and `ProductsClient.tsx` (re-exported via `design-system/index.tsx`). Reusing that component in the new mobile shell — do not create a duplicate. - [ ] **Step 1: `PageHeader.tsx`** ```tsx // src/components/admin/PageHeader.tsx import { ReactNode } from "react"; interface PageHeaderProps { title: string; subtitle?: string; actions?: ReactNode; } export function PageHeader({ title, subtitle, actions }: PageHeaderProps) { return (

{title}

{subtitle && (

{subtitle}

)}
{actions &&
{actions}
}
); } export default PageHeader; ``` - [ ] **Step 2: `StatusPill.tsx`** ```tsx // src/components/admin/StatusPill.tsx export type StatusKind = "placed" | "ready" | "picked-up" | "cancelled" | "scheduled" | "in-transit" | "completed" | "low" | "out" | "in-stock" | "hidden"; const STATUS_MAP: Record = { placed: { bg: "var(--color-info-soft)", fg: "var(--color-info)", label: "Placed" }, ready: { bg: "var(--color-warning-soft)", fg: "var(--color-warning)", label: "Ready" }, "picked-up": { bg: "var(--color-success-soft)", fg: "var(--color-success)", label: "Picked up" }, cancelled: { bg: "var(--color-surface-2)", fg: "var(--color-text-muted)", label: "Cancelled" }, scheduled: { bg: "var(--color-info-soft)", fg: "var(--color-info)", label: "Scheduled" }, "in-transit": { bg: "var(--color-warning-soft)", fg: "var(--color-warning)", label: "In transit" }, completed: { bg: "var(--color-success-soft)", fg: "var(--color-success)", label: "Completed" }, low: { bg: "var(--color-warning-soft)", fg: "var(--color-warning)", label: "Low stock" }, out: { bg: "var(--color-danger-soft)", fg: "var(--color-danger)", label: "Out" }, "in-stock": { bg: "var(--color-success-soft)", fg: "var(--color-success)", label: "In stock" }, hidden: { bg: "var(--color-surface-2)", fg: "var(--color-text-muted)", label: "Hidden" }, }; interface StatusPillProps { status: StatusKind; label?: string; } export function StatusPill({ status, label }: StatusPillProps) { const cfg = STATUS_MAP[status]; return ( {label ?? cfg.label} ); } export default StatusPill; ``` - [ ] **Step 3: `CardList.tsx` + `CardListItem.tsx`** ```tsx // src/components/admin/CardList.tsx import { ReactNode } from "react"; interface CardListProps { children: ReactNode; ariaLabel?: string; } export function CardList({ children, ariaLabel }: CardListProps) { return (
    {children}
); } interface CardListItemProps { onClick?: () => void; href?: string; children: ReactNode; as?: "li" | "div"; } export function CardListItem({ onClick, href, children, as = "li" }: CardListItemProps) { const content = (
{children}
); if (href) { return (
  • {content}
  • ); } if (onClick) { return (
  • ); } return
  • {content}
  • ; } ``` - [ ] **Step 5: `StickyActionBar.tsx`** ```tsx // src/components/admin/StickyActionBar.tsx import { ReactNode } from "react"; interface StickyActionBarProps { children: ReactNode; pendingSync?: boolean; } export function StickyActionBar({ children, pendingSync = false }: StickyActionBarProps) { return (
    {pendingSync && (
    Pending sync
    )} {children}
    ); } export default StickyActionBar; ``` - [ ] **Step 6: Verify** Run: `npx tsc --noEmit` Expected: PASS. - [ ] **Step 7: Commit** ```bash git add src/components/admin/PageHeader.tsx src/components/admin/StatusPill.tsx src/components/admin/CardList.tsx src/components/admin/StickyActionBar.tsx git commit -m "feat(admin): add PageHeader, StatusPill, CardList, StickyActionBar" ``` ### Task 1.13: Add Lighthouse CI workflow **Files:** - Create: `.gitea/workflows/pwa-audit.yml` - [ ] **Step 1: Install Lighthouse CI** Run: `npm install --save-dev @lhci/cli` - [ ] **Step 2: Create the config** Create `lighthouserc.json` at the project root: ```json { "ci": { "collect": { "url": [ "http://localhost:3000/admin/v2/orders", "http://localhost:3000/admin/v2/stops", "http://localhost:3000/admin/v2/products" ], "numberOfRuns": 1, "settings": { "preset": "mobile", "emulatedFormFactor": "mobile" } }, "assert": { "assertions": { "categories:performance": ["error", { "minScore": 0.9 }], "categories:accessibility": ["error", { "minScore": 0.95 }], "categories:pwa": "error", "installable-manifest": "error", "service-worker": "error", "themed-omnibox": "warn" } } } } ``` - [ ] **Step 3: Create the Gitea workflow** ```yaml # .gitea/workflows/pwa-audit.yml name: PWA + Mobile A11y Audit on: [pull_request] jobs: lighthouse: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - run: npm ci - run: npm run build - run: npm run start & - run: sleep 5 - run: npx lhci autorun env: LHCI_GITHUB_APP_TOKEN: ${{ secrets.GH_TOKEN }} # optional ``` - [ ] **Step 4: Commit** ```bash git add .gitea/workflows/pwa-audit.yml lighthouserc.json package.json package-lock.json git commit -m "ci: add Lighthouse PWA + mobile a11y gate" ``` > **Note on component tests:** The spec's Section 5 lists dedicated component tests for `MobileTabBar`, `StickyActionBar`, `OfflineBanner`, `StatusPill`, and `MoreSheet`. This plan covers their behavior via Playwright E2E tests and visual regression baselines (Tasks 3.4–3.6, 4.2). The OfflineBanner is unit-tested indirectly via the queue + sync tests (Tasks 1.5, 1.6). The visual regression approach is more meaningful for purely presentational components and avoids duplication. If dedicated unit tests are later desired, they're additive — they don't block this plan. PR 1 is complete. Proceed to PR 2. --- ## PR 2 — PWA manifest + service worker + icons + offline page This PR makes the app installable. The PWA infrastructure (icons, screenshots, manifest, SW, offline page, meta tags) is added but no existing routes are modified. ### Task 2.1: Generate the icon set **Files:** - Create: `public/icons/icon-72x72.png` ... `icon-512x512.png` (8 files) - Create: `public/icons/icon-maskable-512x512.png` - Create: `public/icons/badge-72x72.png` - Create: `public/icons/orders-shortcut.png`, `products-shortcut.png`, `stops-shortcut.png` - [ ] **Step 1: Use a Node script to generate all icons from the SVG favicon** Create `scripts/generate-pwa-icons.js`: ```javascript // scripts/generate-pwa-icons.js // Generates the full PWA icon set from public/favicon.svg // Run: node scripts/generate-pwa-icons.js const fs = require("fs"); const path = require("path"); const sharp = require("sharp"); const SIZES = [72, 96, 128, 144, 152, 192, 384, 512]; const OUT_DIR = path.join(__dirname, "..", "public", "icons"); const SVG_PATH = path.join(__dirname, "..", "public", "favicon.svg"); const svg = fs.readFileSync(SVG_PATH); async function main() { fs.mkdirSync(OUT_DIR, { recursive: true }); for (const size of SIZES) { await sharp(svg).resize(size, size).png().toFile(path.join(OUT_DIR, `icon-${size}x${size}.png`)); console.log(`✓ icon-${size}x${size}.png`); } // Maskable: same icon, with 20% safe-area padding baked in await sharp(svg).resize(410, 410).extend({ top: 51, bottom: 51, left: 51, right: 51, background: { r: 22, g: 77, b: 46, alpha: 1 }, }).png().toFile(path.join(OUT_DIR, "icon-maskable-512x512.png")); console.log("✓ icon-maskable-512x512.png"); // Badge await sharp(svg).resize(72, 72).png().toFile(path.join(OUT_DIR, "badge-72x72.png")); console.log("✓ badge-72x72.png"); // Shortcuts (same icon, 96x96) for (const name of ["orders-shortcut", "products-shortcut", "stops-shortcut"]) { await sharp(svg).resize(96, 96).png().toFile(path.join(OUT_DIR, `${name}.png`)); console.log(`✓ ${name}.png`); } } main().catch((err) => { console.error(err); process.exit(1); }); ``` - [ ] **Step 2: Install `sharp` and run the script** Run: `npm install --save-dev sharp` Run: `node scripts/generate-pwa-icons.js` Expected: 13 files generated under `public/icons/`. - [ ] **Step 3: Verify file sizes** Run: `ls -la public/icons/` Expected: 13 PNGs, each 1-50KB. - [ ] **Step 4: Commit** ```bash git add public/icons/ scripts/generate-pwa-icons.js package.json package-lock.json git commit -m "feat(pwa): generate 13-icon set from favicon (sizes 72-512 + maskable + badge + 3 shortcuts)" ``` ### Task 2.2: Generate the screenshots **Files:** - Create: `public/screenshots/dashboard.png` (1280x720, desktop) - Create: `public/screenshots/mobile-storefront.png` (390x844, mobile) - [ ] **Step 1: Create screenshots manually** For now, generate placeholder screenshots using `sharp` from solid backgrounds with the brand color: Create `scripts/generate-pwa-screenshots.js`: ```javascript // scripts/generate-pwa-screenshots.js const fs = require("fs"); const path = require("path"); const sharp = require("sharp"); const OUT_DIR = path.join(__dirname, "..", "public", "screenshots"); fs.mkdirSync(OUT_DIR, { recursive: true }); async function main() { // Desktop dashboard placeholder await sharp({ create: { width: 1280, height: 720, channels: 3, background: { r: 250, g: 248, b: 245 } } }) .composite([{ input: Buffer.from(` Route Commerce `), top: 0, left: 0, }]) .png() .toFile(path.join(OUT_DIR, "dashboard.png")); console.log("✓ dashboard.png"); // Mobile storefront placeholder await sharp({ create: { width: 390, height: 844, channels: 3, background: { r: 250, g: 248, b: 245 } } }) .composite([{ input: Buffer.from(` Route Commerce `), top: 0, left: 0, }]) .png() .toFile(path.join(OUT_DIR, "mobile-storefront.png")); console.log("✓ mobile-storefront.png"); } main().catch(console.error); ``` - [ ] **Step 2: Run the script** Run: `node scripts/generate-pwa-screenshots.js` Expected: 2 PNGs created. - [ ] **Step 3: Replace placeholders with real screenshots before launch** These are placeholders. Before going to production, capture real screenshots: 1. Run `npm run dev` and visit `/admin` (desktop) and `/tuxedo` (mobile) in a real browser 2. Use `npx playwright codegen` or `npx playwright screenshot` to capture 3. Overwrite the placeholders in `public/screenshots/` 4. Re-run `git commit` with the same message - [ ] **Step 4: Commit** ```bash git add public/screenshots/ scripts/generate-pwa-screenshots.js git commit -m "feat(pwa): add manifest screenshot placeholders (replace pre-launch with real captures)" ``` ### Task 2.3: Generate apple-touch-icon variants and favicon.ico **Files:** - Create: `public/apple-touch-icon.png` (180x180) - Create: `public/apple-touch-icon-167.png` (167x167, iPad Pro) - Create: `public/apple-touch-icon-152.png` (152x152, iPad) - Create: `public/favicon.ico` - [ ] **Step 1: Extend the icon generation script** Append to `scripts/generate-pwa-icons.js`: ```javascript // Apple touch icons for (const [name, size] of [["apple-touch-icon", 180], ["apple-touch-icon-167", 167], ["apple-touch-icon-152", 152]]) { await sharp(svg).resize(size, size).png().toFile(path.join(__dirname, "..", "public", `${name}.png`)); console.log(`✓ ${name}.png`); } // favicon.ico (32x32) await sharp(svg).resize(32, 32).png().toFile(path.join(__dirname, "..", "public", "favicon.ico")); console.log("✓ favicon.ico"); ``` - [ ] **Step 2: Run it** Run: `node scripts/generate-pwa-icons.js` Expected: 4 new files (3 apple-touch-icons + favicon.ico). - [ ] **Step 3: Commit** ```bash git add public/apple-touch-icon*.png public/favicon.ico scripts/generate-pwa-icons.js git commit -m "feat(pwa): add apple-touch-icon variants and favicon.ico" ``` ### Task 2.4: Create the offline page **Files:** - Create: `public/offline.html` - [ ] **Step 1: Create the offline page** ```html Offline — Route Commerce

    You're offline

    This page isn't available without a connection. Cached pages will still work. Your changes will sync when you're back online.

    ``` - [ ] **Step 2: Verify** Open `public/offline.html` in a browser directly (or `http://localhost:3000/offline.html` after running dev). Expected: Clean cream-colored offline card, "Try again" button. - [ ] **Step 3: Commit** ```bash git add public/offline.html git commit -m "feat(pwa): add offline.html fallback page (HIG-styled)" ``` ### Task 2.5: Rewrite the service worker **Files:** - Modify: `public/sw.js` (full rewrite) - [ ] **Step 1: Replace the file** ```javascript // public/sw.js // Route Commerce Service Worker // Two-cache strategy: shell (cache-first) + data (stale-while-revalidate) const SHELL_CACHE = "rc-shell-v3"; const DATA_CACHE = "rc-data-v3"; const OFFLINE_URL = "/offline.html"; const SHELL_ASSETS = [ "/", "/manifest.json", "/favicon.svg", "/og-default.svg", "/offline.html", "/icons/icon-192x192.png", "/icons/icon-512x512.png", ]; self.addEventListener("install", (event) => { event.waitUntil( caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL_ASSETS)) ); self.skipWaiting(); }); self.addEventListener("activate", (event) => { event.waitUntil( Promise.all([ caches.keys().then((cacheNames) => Promise.all( cacheNames .filter((name) => name !== SHELL_CACHE && name !== DATA_CACHE) .map((name) => caches.delete(name)) ) ), self.clients.claim(), ]) ); }); self.addEventListener("fetch", (event) => { const req = event.request; if (req.method !== "GET") return; const url = new URL(req.url); if (url.origin !== self.location.origin) return; // Navigations → network-first, fall back to cache, fall back to offline page if (req.mode === "navigate") { event.respondWith( fetch(req) .then((res) => { const clone = res.clone(); caches.open(SHELL_CACHE).then((cache) => cache.put(req, clone)); return res; }) .catch(() => caches.match(req).then((cached) => cached || caches.match(OFFLINE_URL)) ) ); return; } // Static assets → cache-first if ( url.pathname.startsWith("/_next/static/") || url.pathname.startsWith("/icons/") || url.pathname.startsWith("/screenshots/") || url.pathname.endsWith(".svg") || url.pathname.endsWith(".woff2") ) { event.respondWith( caches.match(req).then((cached) => { if (cached) return cached; return fetch(req).then((res) => { const clone = res.clone(); if (res.status === 200) { caches.open(SHELL_CACHE).then((cache) => cache.put(req, clone)); } return res; }); }) ); return; } // API GETs (read-only data) → stale-while-revalidate if (url.pathname.startsWith("/api/")) { event.respondWith( caches.open(DATA_CACHE).then((cache) => cache.match(req).then((cached) => { const network = fetch(req) .then((res) => { if (res.status === 200) cache.put(req, res.clone()); return res; }) .catch(() => cached); return cached || network; }) ) ); return; } // Default: try network, fall back to cache event.respondWith( fetch(req).catch(() => caches.match(req)) ); }); ``` - [ ] **Step 2: Verify in DevTools** Run `npm run dev` and open DevTools → Application → Service Workers. Verify the SW registers. Reload the page and check the SHELL_CACHE contains the 7 listed assets. - [ ] **Step 3: Commit** ```bash git add public/sw.js git commit -m "feat(pwa): rewrite service worker (shell cache + data cache + offline fallback)" ``` ### Task 2.6: Update the manifest **Files:** - Modify: `public/manifest.json` - [ ] **Step 1: Update theme_color, background_color, and screenshot paths** Edit the file to: - Change `theme_color` to `"#166534"` - Change `background_color` to `"#faf8f5"` - Confirm `screenshots` array points to the files we just created ```json { "name": "Route Commerce", "short_name": "Route Commerce", "description": "Multi-tenant B2B e-commerce platform for fresh produce wholesale distribution", "start_url": "/admin", "scope": "/", "display": "standalone", "background_color": "#faf8f5", "theme_color": "#166534", "orientation": "portrait-primary", "icons": [ { "src": "/icons/icon-72x72.png", "sizes": "72x72", "type": "image/png", "purpose": "any" }, { "src": "/icons/icon-96x96.png", "sizes": "96x96", "type": "image/png", "purpose": "any" }, { "src": "/icons/icon-128x128.png", "sizes": "128x128", "type": "image/png", "purpose": "any" }, { "src": "/icons/icon-144x144.png", "sizes": "144x144", "type": "image/png", "purpose": "any" }, { "src": "/icons/icon-152x152.png", "sizes": "152x152", "type": "image/png", "purpose": "any" }, { "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { "src": "/icons/icon-384x384.png", "sizes": "384x384", "type": "image/png", "purpose": "any" }, { "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { "src": "/icons/icon-maskable-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } ], "categories": ["business", "productivity"], "shortcuts": [ { "name": "View Orders", "short_name": "Orders", "url": "/admin/v2/orders", "icons": [{ "src": "/icons/orders-shortcut.png", "sizes": "96x96" }] }, { "name": "Add Product", "short_name": "Add Product", "url": "/admin/v2/products/new", "icons": [{ "src": "/icons/products-shortcut.png", "sizes": "96x96" }] }, { "name": "Create Stop", "short_name": "Create Stop", "url": "/admin/v2/stops/new", "icons": [{ "src": "/icons/stops-shortcut.png", "sizes": "96x96" }] } ], "screenshots": [ { "src": "/screenshots/dashboard.png", "sizes": "1280x720", "type": "image/png", "form_factor": "wide", "label": "Admin Dashboard" }, { "src": "/screenshots/mobile-storefront.png", "sizes": "390x844", "type": "image/png", "form_factor": "narrow", "label": "Mobile Storefront" } ], "related_applications": [], "prefer_related_applications": false } ``` - [ ] **Step 2: Validate the manifest** Open `https://www.pwabuilder.com/reportcard` and paste the manifest URL once the app is deployed. Verify no errors. For local dev: open `http://localhost:3000/manifest.json` and confirm it parses. - [ ] **Step 3: Commit** ```bash git add public/manifest.json git commit -m "feat(pwa): update manifest (theme_color, scope, start_url, maskable icon)" ``` ### Task 2.7: Update root layout with PWA meta + viewportFit **Files:** - Modify: `src/app/layout.tsx` - [ ] **Step 1: Update the viewport export** Replace the existing viewport export (line ~30) with: ```typescript export const viewport: Viewport = { width: "device-width", initialScale: 1, themeColor: "#166534", viewportFit: "cover", }; ``` - [ ] **Step 2: Add manifest link and apple-mobile-web-app meta to ``** After the `` opening tag, add the following (note: in Next.js App Router, you can use the `metadata` export for most of this, but `apple-mobile-web-app-capable` and `apple-mobile-web-app-status-bar-style` need to be in `` directly): In the `metadata` export, add an `appLinks` and `manifest` field. Then in the `` of the body, add the apple-specific tags via a small `` block in the JSX: ```typescript export const metadata: Metadata = { // ... existing metadata manifest: "/manifest.json", applicationName: "Route Commerce", appleWebApp: { capable: true, statusBarStyle: "default", title: "Route Commerce", }, icons: { icon: [ { url: "/favicon.ico", sizes: "any" }, { url: "/icons/icon-192x192.png", sizes: "192x192", type: "image/png" }, ], apple: [ { url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }, { url: "/apple-touch-icon-167.png", sizes: "167x167", type: "image/png" }, { url: "/apple-touch-icon-152.png", sizes: "152x152", type: "image/png" }, ], }, formatDetection: { telephone: false }, }; ``` - [ ] **Step 3: Verify** Run: `npm run dev`, open `http://localhost:3000`, view source, confirm `` and `` are present. - [ ] **Step 4: Commit** ```bash git add src/app/layout.tsx git commit -m "feat(pwa): add manifest, apple-web-app meta, viewportFit=cover" ``` ### Task 2.8: Wire `registerServiceWorker` into the client **Files:** - Modify: `src/components/Providers.tsx` - [ ] **Step 1: Open `src/components/Providers.tsx` and add the registration** ```typescript "use client"; import { useEffect } from "react"; import { registerServiceWorker } from "@/lib/pwa"; export function Providers({ children }: { children: React.ReactNode }) { useEffect(() => { registerServiceWorker(); }, []); return <>{children}; } export default Providers; ``` (If the file already has a `Providers` component, add the `useEffect` import and the `registerServiceWorker()` call inside an existing effect or add a new one. Don't break the existing functionality.) - [ ] **Step 2: Verify in the browser** Run: `npm run dev`, open `http://localhost:3000`, DevTools → Application → Service Workers. Expected: SW registered, status "activated and is running". - [ ] **Step 3: Commit** ```bash git add src/components/Providers.tsx git commit -m "feat(pwa): register service worker from Providers component" ``` ### Task 2.9: Mount the PWAInstallPrompt **Files:** - Modify: `src/app/admin/layout.tsx` - [ ] **Step 1: Add the import and mount the component** At the top of `src/app/admin/layout.tsx`, add: ```typescript import PWAInstallPrompt from "@/components/pwa/InstallPrompt"; ``` In the layout's return (just before the closing tag of the outermost wrapper), add: ```tsx ``` - [ ] **Step 2: Verify** Run: `npm run dev`, open `http://localhost:3000/admin` (you may need to be signed in). The install prompt will only appear 10s after the `beforeinstallprompt` event fires (which is browser-conditional). - [ ] **Step 3: Commit** ```bash git add src/app/admin/layout.tsx git commit -m "feat(pwa): mount PWAInstallPrompt in admin layout" ``` ### Task 2.10: Add cache headers to `next.config.ts` **Files:** - Modify: `next.config.ts` - [ ] **Step 1: Read the existing config** Run: `cat next.config.ts` If there's no `headers()` function, add one. Otherwise extend it. - [ ] **Step 2: Add the headers** ```typescript // next.config.ts (add or extend the existing module.exports) const nextConfig = { // ... existing config async headers() { return [ { source: "/:path*", headers: [ { key: "X-Content-Type-Options", value: "nosniff" }, ], }, { source: "/_next/static/:path*", headers: [ { key: "Cache-Control", value: "public, max-age=31536000, immutable" }, ], }, { source: "/icons/:path*", headers: [ { key: "Cache-Control", value: "public, max-age=31536000, immutable" }, ], }, { source: "/screenshots/:path*", headers: [ { key: "Cache-Control", value: "public, max-age=604800" }, ], }, ]; }, }; export default nextConfig; ``` - [ ] **Step 3: Verify** Run: `npm run build && npm run start`, then `curl -I http://localhost:3000/icons/icon-192x192.png` Expected: `Cache-Control: public, max-age=31536000, immutable` in headers. - [ ] **Step 4: Commit** ```bash git add next.config.ts git commit -m "feat(pwa): add cache headers for static assets" ``` ### Task 2.11: Write the PWA install Playwright spec **Files:** - Create: `tests/mobile-admin/pwa-install.spec.ts` - [ ] **Step 1: Create the directory and test file** ```typescript // tests/mobile-admin/pwa-install.spec.ts import { test, expect } from "@playwright/test"; test.use({ viewport: { width: 390, height: 844 } }); // iPhone 13 test("PWA manifest is valid and all icons resolve", async ({ page, request }) => { await page.goto("/admin/login"); const manifestLink = await page.locator('link[rel="manifest"]').getAttribute("href"); expect(manifestLink).toBe("/manifest.json"); const manifestRes = await request.get("/manifest.json"); expect(manifestRes.status()).toBe(200); const manifest = await manifestRes.json(); expect(manifest.name).toBe("Route Commerce"); expect(manifest.theme_color).toBe("#166534"); for (const icon of manifest.icons) { const r = await request.get(icon.src); expect(r.status(), `icon ${icon.src} should resolve`).toBe(200); } }); test("apple-touch-icon is present in head", async ({ page }) => { await page.goto("/admin/login"); const appleTouch = await page.locator('link[rel="apple-touch-icon"]').count(); expect(appleTouch).toBeGreaterThan(0); }); test("service worker registers successfully", async ({ page }) => { await page.goto("/admin/login"); const swReady = await page.evaluate(async () => { if (!("serviceWorker" in navigator)) return false; const reg = await navigator.serviceWorker.ready; return !!reg.active; }); expect(swReady).toBe(true); }); ``` - [ ] **Step 2: Run the test** Run: `npx playwright test tests/mobile-admin/pwa-install.spec.ts` Expected: 3 tests, all PASS. - [ ] **Step 3: Commit** ```bash git add tests/mobile-admin/pwa-install.spec.ts git commit -m "test(pwa): add PWA install spec (manifest, icons, SW registration)" ``` ### Task 2.12: Manual install + offline verification - [ ] **Step 1: Deploy to a preview URL** Run: `npm run build` then push to a preview deployment (Vercel or the Gitea deploy pipeline per CLAUDE.md). - [ ] **Step 2: On a real iPhone** 1. Open the preview URL in Safari 2. Tap Share → Add to Home Screen 3. Confirm the app icon, name, and splash appear correctly 4. Open the installed PWA from the home screen 5. Toggle Airplane Mode on 6. Navigate to a previously visited admin page — it should render from cache 7. Navigate to a never-visited page — it should show `/offline.html` - [ ] **Step 3: On real Android** Repeat with Chrome on Android. Verify the install prompt appears (within ~10s of page load). - [ ] **Step 4: Document in `docs/pwa-testing.md`** Create the doc with your findings, screenshots, and any device-specific quirks. Commit. ```bash git add docs/pwa-testing.md git commit -m "docs: PWA manual install + offline verification (real device notes)" ``` --- ## PR 3 — Orders v2 (list + detail) This PR creates the mobile-first Orders experience at `/admin/v2/orders` and `/admin/v2/orders/[id]`. The existing v1 routes are untouched. Both layouts exist side-by-side until PR 6 (cutover). ### Task 3.0: Create the v2 admin layout **Files:** - Create: `src/app/admin/v2/layout.tsx` - [ ] **Step 1: Create the layout** ```typescript // src/app/admin/v2/layout.tsx import AdminShell from "@/components/admin/AdminShell"; import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; import { getAdminUser } from "@/lib/admin-permissions"; import { getActiveBrandId } from "@/lib/brand-scope"; import { listBrandsForAdmin } from "@/actions/brands"; import { getEnabledAddons } from "@/actions/billing/stripe-portal"; import { ToastProvider } from "@/components/admin/Toast"; import { ToastContainer } from "@/components/admin/ToastContainer"; import { SmoothViewTransition } from "@/components/transitions/SmoothViewTransition"; import { RouteAnnouncer } from "@/components/transitions/RouteAnnouncer"; import { attachSyncListener } from "@/lib/offline/sync"; import { listClientActions } from "@/actions/offline-dispatcher"; // we'll create this export const dynamic = "force-dynamic"; function ToastProviderWrapper({ children }: { children: React.ReactNode }) { return ( {children} ); } export default async function AdminV2Layout({ children }: { children: React.ReactNode }) { let adminUser = null; try { adminUser = await getAdminUser(); } catch (e) { return ; } if (!adminUser) { return ; } const activeBrandId = await getActiveBrandId(adminUser).catch(() => null); const brands = await listBrandsForAdmin().catch(() => []); const enabledAddons = activeBrandId ? await getEnabledAddons(activeBrandId).catch(() => ({})) : {}; return ( {children} ); } ``` - [ ] **Step 2: Add the offline dispatcher server module** Create `src/actions/offline-dispatcher.ts`: ```typescript // src/actions/offline-dispatcher.ts "use server"; import { revalidatePath } from "next/cache"; import { markOrderReady, markOrderPickedUp } from "@/actions/orders"; import { updateStopStatus } from "@/actions/stops"; import { adjustProductStock } from "@/actions/products"; export async function listClientActions() { return [ { name: "markOrderReady", handler: markOrderReady }, { name: "markOrderPickedUp", handler: markOrderPickedUp }, { name: "updateStopStatus", handler: updateStopStatus }, { name: "adjustProductStock", handler: adjustProductStock }, ]; } export async function dispatchClientAction(actionName: string, payload: unknown, clientActionId: string) { // Idempotency check (simple — production should use a real dedup table) const actions = await listClientActions(); const action = actions.find((a) => a.name === actionName); if (!action) return { ok: false as const, error: "Unknown action" }; try { // @ts-expect-error - the handler signatures are heterogeneous; we cast at the call site const result = await action.handler(payload, clientActionId); if (result?.success === false) { if (result.error?.includes("conflict") || result.error?.includes("stale")) { return { ok: false as const, conflict: result.error }; } return { ok: false as const, error: result.error ?? "Action failed" }; } revalidatePath("/admin/v2/orders"); revalidatePath("/admin/v2/stops"); revalidatePath("/admin/v2/products"); return { ok: true as const }; } catch (err) { return { ok: false as const, error: (err as Error).message }; } } ``` - [ ] **Step 3: Commit** ```bash git add src/app/admin/v2/layout.tsx src/actions/offline-dispatcher.ts git commit -m "feat(admin): add v2 layout with AdminShell and offline dispatcher" ``` ### Task 3.1: Create the Orders list page **Files:** - Create: `src/app/admin/v2/orders/page.tsx` - [ ] **Step 1: Read the existing orders page to understand data shape** Run: `head -100 src/app/admin/orders/page.tsx` Note the function used to fetch orders (likely `getOrdersForBrand` or similar) and the row shape. Use the same server action. - [ ] **Step 2: Create the v2 page** ```typescript // src/app/admin/v2/orders/page.tsx import { getAdminUser } from "@/lib/admin-permissions"; import { getActiveBrandId } from "@/lib/brand-scope"; import { getOrdersForBrand } from "@/actions/orders"; import PageHeader from "@/components/admin/PageHeader"; import { CardList, CardListItem } from "@/components/admin/CardList"; import StatusPill, { type StatusKind } from "@/components/admin/StatusPill"; import EmptyState from "@/components/admin/EmptyState"; import OrdersFilterButton from "@/components/admin/orders/OrdersFilterButton"; import PullToRefresh from "@/components/admin/PullToRefresh"; import { formatRelativeTime } from "@/lib/format-date"; export const dynamic = "force-dynamic"; export default async function OrdersV2Page({ searchParams }: { searchParams: Promise<{ status?: string; customer?: string; from?: string; to?: string }> }) { const adminUser = await getAdminUser(); if (!adminUser) return null; const brandId = (await getActiveBrandId(adminUser)) ?? undefined; const params = await searchParams; const orders = await getOrdersForBrand(brandId!, { limit: 50, status: params.status, customer: params.customer, from: params.from, to: params.to, }); if (orders.length === 0) { return (
    } />
    ); } return (
    } /> {orders.map((order) => (
    #{order.id.slice(0, 8)} {formatRelativeTime(order.created_at)}
    {order.customer_name ?? "Customer"}
    {order.item_count} item{order.item_count === 1 ? "" : "s"}
    ${(order.total_cents / 100).toFixed(2)}
    ))}
    ); } ``` > **Note:** This page references `OrdersFilterButton` and `PullToRefresh` which are created in the tasks below. Create them next. - [ ] **Step 2.1: Verify the server action signature** Open `src/actions/orders.ts` and confirm `getOrdersForBrand` accepts a `brandId` plus a filter object. Adjust the call site to match the real signature. Common variations: - `getOrdersForBrand(brandId: string, options?: { limit?: number; status?: string; customer?: string; from?: string; to?: string })` - `getOrdersForBrand(brandId: string)` returning all orders (filter client-side as a fallback) - The brand ID type may be `string` (not `string | undefined`); in that case, drop the `!` and the brandId resolution stays as is. - [ ] **Step 3: Verify** Run: `npm run dev`, navigate to `http://localhost:3000/admin/v2/orders` (signed in). Expected: Card list of orders, each card showing status, customer, item count, total, time. - [ ] **Step 4: Commit** ```bash git add src/app/admin/v2/orders/page.tsx git commit -m "feat(admin): add v2 orders list (CardList with status pill + relative time)" ``` ### Task 3.1.1: Add `PullToRefresh` component (custom, no library) **Files:** - Create: `src/components/admin/PullToRefresh.tsx` - [ ] **Step 1: Create the component** ```tsx // src/components/admin/PullToRefresh.tsx "use client"; import { ReactNode, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; interface PullToRefreshProps { children: ReactNode; } const THRESHOLD = 80; // px of pull distance to trigger refresh const MAX_PULL = 140; // px before rubberbanding clamps const REFRESH_INDICATOR_HEIGHT = 56; export function PullToRefresh({ children }: PullToRefreshProps) { const router = useRouter(); const containerRef = useRef(null); const startY = useRef(null); const pulling = useRef(false); const [offset, setOffset] = useState(0); const [refreshing, setRefreshing] = useState(false); const [prefersReducedMotion, setPrefersReducedMotion] = useState(false); useEffect(() => { const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); setPrefersReducedMotion(mq.matches); }, []); function onTouchStart(e: TouchEvent) { if (refreshing) return; // Only start a pull if the page is scrolled to the top if (window.scrollY > 0) return; startY.current = e.touches[0].clientY; pulling.current = true; } function onTouchMove(e: TouchEvent) { if (!pulling.current || startY.current === null) return; const dy = e.touches[0].clientY - startY.current; if (dy <= 0) { setOffset(0); return; } // Rubberband: resistance curve so it gets harder to pull further const resistance = 1 - Math.min(1, dy / 400); const next = Math.min(MAX_PULL, dy * resistance); setOffset(next); } async function onTouchEnd() { if (!pulling.current) return; pulling.current = false; startY.current = null; if (offset >= THRESHOLD) { setRefreshing(true); setOffset(REFRESH_INDICATOR_HEIGHT); router.refresh(); // Give the refresh a moment to complete visually await new Promise((r) => setTimeout(r, 600)); setRefreshing(false); setOffset(0); } else { setOffset(0); } } return (
    {offset > 0 && ( ); } export default PullToRefresh; ``` (Add the `spin` keyframe to `globals.css` if not present.) - [ ] **Step 2: Verify** Run: `npm run dev`, open the orders list on a real phone (or with Chrome DevTools mobile emulation), pull down from the top, observe the spinner, release, observe the page refreshes. - [ ] **Step 3: Commit** ```bash git add src/components/admin/PullToRefresh.tsx src/app/globals.css git commit -m "feat(admin): add PullToRefresh (custom, prefers-reduced-motion aware)" ``` ### Task 3.1.2: Add `OrdersFilterButton` and filter sheet **Files:** - Create: `src/components/admin/orders/OrdersFilterButton.tsx` - [ ] **Step 1: Create the button (client component that opens a native dialog)** ```tsx // src/components/admin/orders/OrdersFilterButton.tsx "use client"; import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useRef, useState } from "react"; export function OrdersFilterButton() { const router = useRouter(); const searchParams = useSearchParams(); const dialogRef = useRef(null); const [open, setOpen] = useState(false); useEffect(() => { const dialog = dialogRef.current; if (!dialog) return; if (open && !dialog.open) dialog.showModal(); if (!open && dialog.open) dialog.close(); }, [open]); const currentStatus = searchParams.get("status") ?? ""; function apply(status: string) { const params = new URLSearchParams(searchParams.toString()); if (status) params.set("status", status); else params.delete("status"); router.push(`/admin/v2/orders?${params.toString()}`); setOpen(false); } return ( <> { if (e.target === dialogRef.current) setOpen(false); }} className="w-full max-w-[640px] p-0 m-0 ml-auto h-full max-h-screen backdrop:bg-black/40" style={{ backgroundColor: "var(--color-bg)", color: "var(--color-text)" }} >

    Filter

    {["", "placed", "ready", "picked-up", "cancelled"].map((s) => ( ))}
    ); } export default OrdersFilterButton; ``` - [ ] **Step 2: Verify** Run: `npm run dev`, navigate to `/admin/v2/orders`, tap the Filter button, observe the sheet, select a status, observe the URL updates and the list re-filters. - [ ] **Step 3: Commit** ```bash git add src/components/admin/orders/OrdersFilterButton.tsx git commit -m "feat(admin): add OrdersFilterButton (native dialog sheet for status filter)" ``` ### Task 3.2: Add a `formatRelativeTime` helper **Files:** - Modify: `src/lib/format-date.ts` (extend) - [ ] **Step 1: Read the file** Run: `cat src/lib/format-date.ts` Understand the existing API. - [ ] **Step 2: Add the function** ```typescript // Add to src/lib/format-date.ts export function formatRelativeTime(input: string | Date | number): string { const date = input instanceof Date ? input : new Date(input); const diffMs = Date.now() - date.getTime(); const diffSec = Math.round(diffMs / 1000); if (diffSec < 45) return "just now"; const diffMin = Math.round(diffSec / 60); if (diffMin < 60) return `${diffMin}m ago`; const diffHr = Math.round(diffMin / 60); if (diffHr < 24) return `${diffHr}h ago`; const diffDay = Math.round(diffHr / 24); if (diffDay === 1) return "yesterday"; if (diffDay < 7) return `${diffDay}d ago`; return formatDate(date); } ``` - [ ] **Step 3: Add unit tests** Create `src/lib/format-date.test.ts` (or extend if it exists): ```typescript // src/lib/format-date.test.ts import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { formatRelativeTime } from "./format-date"; describe("formatRelativeTime", () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-06-17T12:00:00Z")); }); afterEach(() => { vi.useRealTimers(); }); it("returns 'just now' for < 45s", () => { expect(formatRelativeTime("2026-06-17T11:59:30Z")).toBe("just now"); }); it("returns '5m ago' for ~5 minutes", () => { expect(formatRelativeTime("2026-06-17T11:55:00Z")).toBe("5m ago"); }); it("returns '2h ago' for ~2 hours", () => { expect(formatRelativeTime("2026-06-17T10:00:00Z")).toBe("2h ago"); }); it("returns 'yesterday' for 1 day", () => { expect(formatRelativeTime("2026-06-16T12:00:00Z")).toBe("yesterday"); }); it("returns '3d ago' for 3 days", () => { expect(formatRelativeTime("2026-06-14T12:00:00Z")).toBe("3d ago"); }); it("returns MM/DD/YYYY for > 7 days", () => { expect(formatRelativeTime("2026-06-01T12:00:00Z")).toBe("06/01/2026"); }); }); ``` - [ ] **Step 4: Run tests** Run: `npx vitest run src/lib/format-date.test.ts` Expected: 6 tests, all PASS. - [ ] **Step 5: Commit** ```bash git add src/lib/format-date.ts src/lib/format-date.test.ts git commit -m "feat(format): add formatRelativeTime (just now, Xm/Xh/Xd ago, MM/DD/YYYY)" ``` ### Task 3.3: Create the Order detail page **Files:** - Create: `src/app/admin/v2/orders/[id]/page.tsx` - [ ] **Step 1: Read the existing detail page** Run: `head -100 src/app/admin/orders/[id]/page.tsx` Understand the data shape and existing status-change action. - [ ] **Step 2: Create the v2 detail page** ```typescript // src/app/admin/v2/orders/[id]/page.tsx import { notFound } from "next/navigation"; import { getAdminUser } from "@/lib/admin-permissions"; import { getOrderDetail } from "@/actions/orders"; import { formatDate } from "@/lib/format-date"; import PageHeader from "@/components/admin/PageHeader"; import StatusPill, { type StatusKind } from "@/components/admin/StatusPill"; import StickyActionBar from "@/components/admin/StickyActionBar"; import OrderActionButtons from "@/components/admin/orders/OrderActionButtons"; export const dynamic = "force-dynamic"; export default async function OrderDetailV2Page({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const adminUser = await getAdminUser(); if (!adminUser) return null; const order = await getOrderDetail(id); if (!order) notFound(); return (

    {order.customer_name}

    {order.customer_phone && ( {order.customer_phone} )} {order.customer_email && ( {order.customer_email} )}

    ITEMS

      {order.items.map((item) => (
    • {item.image_url && ( )}
      {item.name}
      {item.quantity} × ${(item.price_cents / 100).toFixed(2)}
      ${((item.price_cents * item.quantity) / 100).toFixed(2)}
    • ))}
    TOTAL ${(order.total_cents / 100).toFixed(2)}
    {order.notes && (

    "{order.notes}"

    )}
    ); } ``` - [ ] **Step 3: Create the `OrderActionButtons` client component** ```tsx // src/components/admin/orders/OrderActionButtons.tsx "use client"; import { useState } from "react"; import { enqueueAction } from "@/lib/offline/queue"; import { syncPending } from "@/lib/offline/sync"; import { dispatchClientAction } from "@/actions/offline-dispatcher"; interface OrderActionButtonsProps { orderId: string; status: "placed" | "ready" | "picked-up" | "cancelled"; } export function OrderActionButtons({ orderId, status }: OrderActionButtonsProps) { const [pending, setPending] = useState(false); const [conflict, setConflict] = useState(null); async function handleAction(actionName: string) { setPending(true); setConflict(null); try { await enqueueAction(actionName, { orderId }); const result = await syncPending( (name, payload, id) => dispatchClientAction(name, payload, id), { online: navigator.onLine, sleep: (ms) => new Promise((r) => setTimeout(r, ms)) } ); if (result.conflicts > 0) setConflict("This order changed elsewhere. Refresh to see the latest."); } finally { setPending(false); } } if (status === "placed") { return ( ); } if (status === "ready") { return ( ); } if (conflict) { return (
    {conflict}
    ); } return ( View Receipt ); } export default OrderActionButtons; ``` - [ ] **Step 4: Verify** Run: `npm run dev`, navigate to `/admin/v2/orders/`. Expected: Detail view with header, items, notes (if any), sticky action bar at bottom. The "Mark Ready" or "Mark Picked Up" button should work online and queue offline. - [ ] **Step 5: Commit** ```bash git add src/app/admin/v2/orders/[id]/page.tsx src/components/admin/orders/OrderActionButtons.tsx git commit -m "feat(admin): add v2 order detail with StickyActionBar + offline queue integration" ``` ### Task 3.4: Write the Orders list Playwright spec **Files:** - Create: `tests/mobile-admin/orders-list.spec.ts` - [ ] **Step 1: Create the test** ```typescript // tests/mobile-admin/orders-list.spec.ts import { test, expect } from "@playwright/test"; test.use({ viewport: { width: 390, height: 844 } }); test.beforeEach(async ({ page }) => { // Sign in as a test admin (use seed data) await page.goto("/login"); await page.fill('input[name="email"]', "test-admin@example.com"); await page.fill('input[name="password"]', "test-password"); await page.click('button[type="submit"]'); await page.waitForURL(/\/admin/); }); test("orders list renders without horizontal scroll on iPhone 13", async ({ page }) => { await page.goto("/admin/v2/orders"); const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth); const clientWidth = await page.evaluate(() => document.documentElement.clientWidth); expect(scrollWidth).toBeLessThanOrEqual(clientWidth); }); test("orders list has no touch targets smaller than 48pt", async ({ page }) => { await page.goto("/admin/v2/orders"); const violations = await page.evaluate(() => { const interactive = document.querySelectorAll("a, button, input, select, textarea, [role='button']"); const small: string[] = []; interactive.forEach((el) => { const rect = el.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0 && (rect.height < 48 || rect.width < 48)) { small.push(`${el.tagName}.${(el as HTMLElement).className}: ${rect.width}x${rect.height}`); } }); return small; }); expect(violations, `Found ${violations.length} small touch targets: ${violations.join(", ")}`).toHaveLength(0); }); ``` - [ ] **Step 2: Run** Run: `npx playwright test tests/mobile-admin/orders-list.spec.ts` Expected: 2 tests, all PASS (assuming the seed admin and some orders exist; if not, seed first via the project's admin seed script). - [ ] **Step 3: Commit** ```bash git add tests/mobile-admin/orders-list.spec.ts git commit -m "test(admin): add v2 orders list mobile spec (no horizontal scroll, 48pt tap targets)" ``` ### Task 3.5: Write the offline-queue Playwright spec **Files:** - Create: `tests/mobile-admin/offline-queue.spec.ts` - [ ] **Step 1: Create the test** ```typescript // tests/mobile-admin/offline-queue.spec.ts import { test, expect } from "@playwright/test"; test.use({ viewport: { width: 390, height: 844 } }); test.beforeEach(async ({ page }) => { await page.goto("/login"); await page.fill('input[name="email"]', "test-admin@example.com"); await page.fill('input[name="password"]', "test-password"); await page.click('button[type="submit"]'); await page.waitForURL(/\/admin/); }); test("marking an order ready offline queues and syncs on reconnect", async ({ page, context }) => { // Find a "placed" order from the v2 list await page.goto("/admin/v2/orders"); const placedOrderLink = page.locator('a:has(span:has-text("Placed"))').first(); await placedOrderLink.click(); await page.waitForURL(/\/admin\/v2\/orders\/[a-f0-9-]+/); // Go offline await context.setOffline(true); // Click "Mark Ready" const markReady = page.locator('button:has-text("Mark Ready")'); await expect(markReady).toBeVisible(); await markReady.click(); // Optimistic UI: the page should still update (or the button should disable) // and the StickyActionBar should show "Pending sync" await expect(page.locator('text="Pending sync"')).toBeVisible({ timeout: 5000 }); // Reconnect await context.setOffline(false); // Wait up to 10s for sync await expect(page.locator('text="Pending sync"')).toBeHidden({ timeout: 10000 }); }); ``` - [ ] **Step 2: Run** Run: `npx playwright test tests/mobile-admin/offline-queue.spec.ts` Expected: 1 test, PASS. - [ ] **Step 3: Commit** ```bash git add tests/mobile-admin/offline-queue.spec.ts git commit -m "test(admin): add offline mutation queue spec (mark ready offline, sync on reconnect)" ``` ### Task 3.6: Add visual regression baselines for Orders v2 **Files:** - Create: `tests/mobile-admin/orders-visual.spec.ts` - [ ] **Step 1: Create the test** ```typescript // tests/mobile-admin/orders-visual.spec.ts import { test, expect } from "@playwright/test"; test.use({ viewport: { width: 390, height: 844 } }); test.beforeEach(async ({ page }) => { await page.goto("/login"); await page.fill('input[name="email"]', "test-admin@example.com"); await page.fill('input[name="password"]', "test-password"); await page.click('button[type="submit"]'); await page.waitForURL(/\/admin/); }); test("orders list — iPhone 13", async ({ page }) => { await page.goto("/admin/v2/orders"); await page.waitForLoadState("networkidle"); await expect(page).toHaveScreenshot("orders-list-iphone13.png", { fullPage: true, maxDiffPixels: 200 }); }); test("order detail — iPhone 13", async ({ page }) => { await page.goto("/admin/v2/orders"); const firstOrder = page.locator('a[href*="/admin/v2/orders/"]').first(); await firstOrder.click(); await page.waitForLoadState("networkidle"); await expect(page).toHaveScreenshot("order-detail-iphone13.png", { fullPage: true, maxDiffPixels: 200 }); }); ``` - [ ] **Step 2: Run to generate baselines** Run: `npx playwright test tests/mobile-admin/orders-visual.spec.ts --update-snapshots` Expected: 2 snapshots created. - [ ] **Step 3: Commit** ```bash git add tests/mobile-admin/orders-visual.spec.ts tests/mobile-admin/__snapshots__/ git commit -m "test(admin): add visual regression baselines for orders v2 (iPhone 13)" ``` ### Task 3.7: Manual QA checklist for Orders v2 - [ ] **Step 1: Walk through the manual QA checklist** Open [docs/pwa-testing.md](../../pwa-testing.md) and follow the manual checklist. Specifically: - Read `/admin/v2/orders` in direct sunlight or under a 5000K lamp - Tap every interactive element while wearing gloves - Verify the offline flow: toggle airplane mode, mark an order ready, observe "Pending sync", reconnect, observe sync - [ ] **Step 2: Document findings** Append your findings to `docs/pwa-testing.md`. If anything doesn't meet HIG or the design spec, file an issue or follow-up PR. --- ## PR 4 — Stops v2 Same pattern as PR 3. Files: `src/app/admin/v2/stops/page.tsx` and `src/app/admin/v2/stops/[id]/page.tsx` (if a per-stop detail is needed; otherwise the list is enough). ### Task 4.1: Create the Stops list page **Files:** - Create: `src/app/admin/v2/stops/page.tsx` - [ ] **Step 1: Read the existing stops page** Run: `head -80 src/app/admin/stops/page.tsx` Identify the data shape and the `getStopsForBrand` action (or equivalent). - [ ] **Step 2: Create the v2 page** ```typescript // src/app/admin/v2/stops/page.tsx import { getAdminUser } from "@/lib/admin-permissions"; import { getActiveBrandId } from "@/lib/brand-scope"; import { getStopsForBrand } from "@/actions/stops"; import PageHeader from "@/components/admin/PageHeader"; import { CardList, CardListItem } from "@/components/admin/CardList"; import StatusPill, { type StatusKind } from "@/components/admin/StatusPill"; import EmptyState from "@/components/admin/EmptyState"; import StopsDatePicker from "@/components/admin/stops/StopsDatePicker"; export const dynamic = "force-dynamic"; function groupByTimeOfDay(stops: Array<{ id: string; scheduled_at: string; address: string; status: string; customer_count: number; maps_url?: string }>) { const groups: Record = { Morning: [], Afternoon: [], Evening: [] }; stops.forEach((s) => { const hour = new Date(s.scheduled_at).getHours(); const bucket = hour < 12 ? "Morning" : hour < 17 ? "Afternoon" : "Evening"; groups[bucket].push(s); }); return groups; } function formatTime(iso: string) { return new Date(iso).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); } function formatDateHeading(d: Date) { return d.toLocaleDateString("en-US", { weekday: "long", month: "short", day: "numeric" }); } export default async function StopsV2Page({ searchParams }: { searchParams: Promise<{ date?: string }> }) { const adminUser = await getAdminUser(); if (!adminUser) return null; const brandId = (await getActiveBrandId(adminUser)) ?? undefined; const params = await searchParams; const date = params.date ? new Date(params.date) : new Date(); const stops = await getStopsForBrand(brandId!, { date }); if (stops.length === 0) { return (
    } />
    ); } const groups = groupByTimeOfDay(stops); return (
    } /> {Object.entries(groups).map(([time, groupStops]) => groupStops.length > 0 && (

    {time.toUpperCase()}

    {groupStops.map((stop) => (
    {formatTime(stop.scheduled_at)}
    {stop.address}
    {stop.customer_count} customer{stop.customer_count === 1 ? "" : "s"}
    ))}
    ))}
    ); } ``` - [ ] **Step 2.1: Create the `StopsDatePicker` component** ```tsx // src/components/admin/stops/StopsDatePicker.tsx "use client"; import { useRouter } from "next/navigation"; interface StopsDatePickerProps { value: Date; } function toInputValue(d: Date): string { return d.toISOString().slice(0, 10); } export function StopsDatePicker({ value }: StopsDatePickerProps) { const router = useRouter(); return ( ); } export default StopsDatePicker; ``` - [ ] **Step 2.2: Verify the server action signature** Open `src/actions/stops.ts` and confirm `getStopsForBrand` accepts `(brandId, { date })`. Adjust the call site to match the real signature. - [ ] **Step 3: Verify + commit** ```bash npm run dev # Navigate to /admin/v2/stops, confirm card list renders git add src/app/admin/v2/stops/page.tsx git commit -m "feat(admin): add v2 stops list (time-grouped cards with status pill)" ``` ### Task 4.2: Add visual regression baseline ```bash # Add a test in tests/mobile-admin/orders-visual.spec.ts (rename to admin-visual.spec.ts) for stops git add tests/mobile-admin/ git commit -m "test(admin): add v2 stops visual baseline" ``` --- ## PR 5 — Products v2 ### Task 5.1: Create the Products list page **Files:** - Create: `src/app/admin/v2/products/page.tsx` - [ ] **Step 1: Create the page** ```typescript // src/app/admin/v2/products/page.tsx import { getAdminUser } from "@/lib/admin-permissions"; import { getActiveBrandId } from "@/lib/brand-scope"; import { getProductsForBrand } from "@/actions/products"; import PageHeader from "@/components/admin/PageHeader"; import { CardList, CardListItem } from "@/components/admin/CardList"; import StatusPill, { type StatusKind } from "@/components/admin/StatusPill"; import EmptyState from "@/components/admin/EmptyState"; import ProductsFilterChips from "@/components/admin/products/ProductsFilterChips"; import StockAdjustButton from "@/components/admin/products/StockAdjustButton"; export const dynamic = "force-dynamic"; function stockStatus(stock: number): { kind: StatusKind; label: string } { if (stock === 0) return { kind: "out", label: "Out of stock" }; if (stock < 10) return { kind: "low", label: `⚠ ${stock} left` }; return { kind: "in-stock", label: `${stock} in stock` }; } export default async function ProductsV2Page({ searchParams }: { searchParams: Promise<{ filter?: string }> }) { const adminUser = await getAdminUser(); if (!adminUser) return null; const brandId = (await getActiveBrandId(adminUser)) ?? undefined; const params = await searchParams; const products = await getProductsForBrand(brandId!, { limit: 100, filter: params.filter, // "all" | "in-stock" | "low" | "out" | "hidden" }); if (products.length === 0) { return (
    ); } return (
    {products.map((product) => { const stock = stockStatus(product.stock); return (
    {product.image_url && ( )}
    {product.name}
    ${(product.price_cents / 100).toFixed(2)}
    ); })}
    ); } ``` - [ ] **Step 1.1: Create the `ProductsFilterChips` component** ```tsx // src/components/admin/products/ProductsFilterChips.tsx "use client"; import { useRouter, useSearchParams } from "next/navigation"; const FILTERS = [ { value: "all", label: "All" }, { value: "in-stock", label: "In stock" }, { value: "low", label: "Low" }, { value: "out", label: "Out" }, { value: "hidden", label: "Hidden" }, ]; export function ProductsFilterChips({ active }: { active: string }) { const router = useRouter(); const searchParams = useSearchParams(); function apply(value: string) { const params = new URLSearchParams(searchParams.toString()); if (value === "all") params.delete("filter"); else params.set("filter", value); router.push(`/admin/v2/products?${params.toString()}`); } return (
    {FILTERS.map((f) => { const isActive = active === f.value; return ( ); })}
    ); } export default ProductsFilterChips; ``` - [ ] **Step 1.2: Create the `StockAdjustButton` component (long-press to open stepper)** ```tsx // src/components/admin/products/StockAdjustButton.tsx "use client"; import { useRef, useState } from "react"; import { enqueueAction } from "@/lib/offline/queue"; import { syncPending } from "@/lib/offline/sync"; import { dispatchClientAction } from "@/actions/offline-dispatcher"; interface StockAdjustButtonProps { productId: string; currentStock: number; } const LONG_PRESS_MS = 500; export function StockAdjustButton({ productId, currentStock }: StockAdjustButtonProps) { const [open, setOpen] = useState(false); const [stock, setStock] = useState(currentStock); const [pending, setPending] = useState(false); const timer = useRef | null>(null); function onPointerDown() { timer.current = setTimeout(() => setOpen(true), LONG_PRESS_MS); } function onPointerUp() { if (timer.current) clearTimeout(timer.current); } async function adjust(delta: number) { const next = stock + delta; if (next < 0) return; setStock(next); setPending(true); try { await enqueueAction("adjustProductStock", { productId, delta }); await syncPending( (name, payload, id) => dispatchClientAction(name, payload, id), { online: navigator.onLine, sleep: (ms) => new Promise((r) => setTimeout(r, ms)) } ); } finally { setPending(false); } } return ( <> {open && (

    Adjust stock

    {stock}
    )} ); } export default StockAdjustButton; ``` - [ ] **Step 1.3: Verify the server action signature** Open `src/actions/products.ts` and confirm `getProductsForBrand` accepts `(brandId, { limit, filter })`. Adjust to match the real signature. If the existing action doesn't accept a `filter` parameter, either: 1. Add one to the action (preferred — it's a small, useful addition) 2. Filter client-side from `ProductsV2Page` before rendering - [ ] **Step 2: Verify + commit** ```bash npm run dev git add src/app/admin/v2/products/page.tsx src/components/admin/products/ProductsFilterChips.tsx src/components/admin/products/StockAdjustButton.tsx git commit -m "feat(admin): add v2 products list (image-forward cards + filter chips + long-press stock adjust)" ``` - [ ] **Step 2: Verify + commit** ```bash npm run dev git add src/app/admin/v2/products/page.tsx git commit -m "feat(admin): add v2 products list (image-forward cards with stock status)" ``` --- ## PR 6 — Cutover Orders + Stops + Products ### Task 6.1: Add redirects in `next.config.ts` **Files:** - Modify: `next.config.ts` - [ ] **Step 1: Add redirects** In the `next.config.ts` `module.exports` (or however the config is shaped), add: ```typescript async redirects() { return [ { source: "/admin/orders", destination: "/admin/v2/orders", permanent: false }, { source: "/admin/orders/:id", destination: "/admin/v2/orders/:id", permanent: false }, { source: "/admin/stops", destination: "/admin/v2/stops", permanent: false }, { source: "/admin/products", destination: "/admin/v2/products", permanent: false }, ]; }, ``` - [ ] **Step 2: Verify** Run: `npm run dev`, navigate to `/admin/orders` — should redirect to `/admin/v2/orders`. - [ ] **Step 3: Commit** ```bash git add next.config.ts git commit -m "feat(admin): redirect /admin/{orders,stops,products} to /v2" ``` ### Task 6.2: Delete the v1 routes **Files:** - Delete: `src/app/admin/orders/` - Delete: `src/app/admin/stops/` - Delete: `src/app/admin/products/` ⚠️ **Wait at least 3 days after the redirect ships** to catch any 404s in monitoring before deleting the v1 files. - [ ] **Step 1: Verify no 404s in production logs** Check the production error tracker (Sentry, etc.) for any 404s on `/admin/orders`, `/admin/orders/[id]`, `/admin/stops`, `/admin/products`. If clean for 3 days, proceed. - [ ] **Step 2: Delete the directories** ```bash rm -rf src/app/admin/orders src/app/admin/stops src/app/admin/products ``` - [ ] **Step 3: Verify build still passes** Run: `npm run build` Expected: PASS. - [ ] **Step 4: Commit** ```bash git add -A git commit -m "chore(admin): remove v1 orders/stops/products routes (replaced by v2)" ``` ### Task 6.3: Update navigation in `MoreSheet` **Files:** - Modify: `src/components/admin/MoreSheet.tsx` - [ ] **Step 1: Update the secondary nav links to point to v2 where they exist** (The `MoreSheet` currently links to v1 routes for Orders/Stops/Products. Update to `/admin/v2/...`.) ```typescript const MORE_SECTIONS = [ { heading: "Operations", links: [ { label: "Pickup", href: "/admin/pickup", icon: "📦" }, { label: "Shipping", href: "/admin/shipping", icon: "🚚" }, { label: "Reports", href: "/admin/reports", icon: "📊" }, { label: "Analytics", href: "/admin/analytics", icon: "📈" }, ], }, { heading: "Marketing", links: [ { label: "Communications", href: "/admin/communications", icon: "📧" }, { label: "Sales", href: "/admin/sales", icon: "💵" }, ], }, { heading: "Settings", links: [ { label: "Settings", href: "/admin/settings", icon: "⚙️" }, { label: "Advanced", href: "/admin/advanced", icon: "🛠" }, ], }, ]; ``` (Only update Orders/Stops/Products links if they're listed here — they're not, since the bottom tab bar handles them.) - [ ] **Step 2: Commit** ```bash git add src/components/admin/MoreSheet.tsx git commit -m "feat(admin): update MoreSheet links to v2 routes" ``` --- ## PR 7 — Dashboard v2 + dashboard RPC migration ### Task 7.1: Write the `get_dashboard_summary` RPC migration **Files:** - Create: `db/migrations/NNN_dashboard_summary_rpc.sql` (use the next available number, e.g. 0202 if 0201 is the latest in production — check `MEMORY.md` or the prod DB) - [ ] **Step 1: Write the migration** ```sql -- db/migrations/NNN_dashboard_summary_rpc.sql -- Dashboard summary RPC for the mobile-first admin CREATE OR REPLACE FUNCTION get_dashboard_summary(p_brand_id UUID) RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER AS $$ DECLARE result JSONB; BEGIN SELECT jsonb_build_object( 'orders_today', ( SELECT COUNT(*) FROM orders WHERE brand_id = p_brand_id AND created_at::date = CURRENT_DATE ), 'revenue_today', ( SELECT COALESCE(SUM(total_cents), 0) FROM orders WHERE brand_id = p_brand_id AND created_at::date = CURRENT_DATE AND status NOT IN ('cancelled') ), 'pending_fulfillment', ( SELECT COUNT(*) FROM orders WHERE brand_id = p_brand_id AND status IN ('placed', 'ready') ), 'stops_today', ( SELECT COUNT(*) FROM stops WHERE brand_id = p_brand_id AND scheduled_at::date = CURRENT_DATE ), 'orders_last_7_days', ( SELECT jsonb_agg(jsonb_build_object('date', d::date, 'count', COALESCE(o.cnt, 0))) FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') d LEFT JOIN ( SELECT created_at::date AS day, COUNT(*) AS cnt FROM orders WHERE brand_id = p_brand_id AND created_at >= CURRENT_DATE - INTERVAL '7 days' GROUP BY 1 ) o ON o.day = d::date ) ) INTO result; RETURN result; END; $$; ``` - [ ] **Step 2: Add the `total_cents` migration if it doesn't exist** If the `orders` table has `total` (in dollars) and not `total_cents` (in cents), add a migration: ```sql -- db/migrations/NNN_orders_total_cents.sql (only if needed) ALTER TABLE orders ADD COLUMN IF NOT EXISTS total_cents INTEGER; UPDATE orders SET total_cents = (total * 100)::INTEGER WHERE total_cents IS NULL; ALTER TABLE orders ALTER COLUMN total_cents SET NOT NULL; ``` (Verify the actual column name in the orders table before running this. Per CLAUDE.md, the existing code uses RPCs, so `total_cents` may or may not exist.) - [ ] **Step 3: Apply locally and test** Run: `npm run migrate:one NNN` (with the actual number). Expected: Migration applies cleanly. - [ ] **Step 4: Commit** ```bash git add db/migrations/NNN_*.sql git commit -m "feat(db): add get_dashboard_summary RPC and total_cents column if needed" ``` ### Task 7.2: Create the v2 Dashboard page **Files:** - Create: `src/app/admin/v2/page.tsx` - [ ] **Step 1: Create the page** ```typescript // src/app/admin/v2/page.tsx import Link from "next/link"; import { getAdminUser } from "@/lib/admin-permissions"; import { getActiveBrandId } from "@/lib/brand-scope"; import { getDashboardSummary } from "@/actions/dashboard"; import { getStopsForBrand } from "@/actions/stops"; import { getOrdersForBrand } from "@/actions/orders"; import PageHeader from "@/components/admin/PageHeader"; import EmptyState from "@/components/admin/EmptyState"; import { formatDate } from "@/lib/format-date"; export const dynamic = "force-dynamic"; export default async function DashboardV2Page() { const adminUser = await getAdminUser(); if (!adminUser) return null; const brandId = (await getActiveBrandId(adminUser)) ?? undefined; if (!brandId) { return (
    ); } const [summary, stops, pendingOrders] = await Promise.all([ getDashboardSummary(brandId), getStopsForBrand(brandId, { date: new Date() }), getOrdersForBrand(brandId, { status: "placed", limit: 5 }), ]); return (
    {summary.pending_fulfillment > 0 && (
    NEEDS YOU
    {summary.pending_fulfillment} order{summary.pending_fulfillment === 1 ? "" : "s"} waiting
    View all →
    )}
    0} />
    {stops.length > 0 && (

    TODAY'S STOPS

      {stops.slice(0, 5).map((stop) => (
    • {new Date(stop.scheduled_at).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}
      {stop.address}
    • ))}
    )}
    ); } function StatCard({ label, value, accent = false }: { label: string; value: string; accent?: boolean }) { return (
    {label.toUpperCase()}
    {value}
    ); } ``` > **Note on `pendingOrders`:** The `pendingOrders` variable is fetched but not rendered in this skeleton. It's available if you want to show the top 5 pending orders directly on the dashboard. To render them, add a `
    ` between "Needs you" and the stat row, listing the orders as small cards. Optional. - [ ] **Step 2: Add the `getDashboardSummary` action** Create `src/actions/dashboard.ts`: ```typescript // src/actions/dashboard.ts "use server"; import { getAdminUser } from "@/lib/admin-permissions"; import { pool } from "@/lib/db"; export interface DashboardSummary { orders_today: number; revenue_today: number; pending_fulfillment: number; stops_today: number; orders_last_7_days: Array<{ date: string; count: number }>; } export async function getDashboardSummary(brandId: string): Promise { const adminUser = await getAdminUser(); if (!adminUser) throw new Error("Unauthorized"); const result = await pool.query( "SELECT get_dashboard_summary($1::uuid) AS data", [brandId] ); return result.rows[0]?.data ?? { orders_today: 0, revenue_today: 0, pending_fulfillment: 0, stops_today: 0, orders_last_7_days: [], }; } ``` (Verify the existing `pool` import path — per CLAUDE.md, `pg` is used and `pool` is exported from `src/lib/db.ts`. Adjust as needed.) - [ ] **Step 3: Verify** Run: `npm run dev`, navigate to `/admin/v2`. Expected: 4 stat cards, "Today's stops" section. - [ ] **Step 4: Commit** ```bash git add src/app/admin/v2/page.tsx src/actions/dashboard.ts git commit -m "feat(admin): add v2 dashboard with stat cards and stops timeline" ``` --- ## PR 8 — Dashboard cutover ### Task 8.1: Redirect `/admin` to `/admin/v2` **Files:** - Modify: `next.config.ts` - [ ] **Step 1: Add the redirect** ```typescript async redirects() { return [ { source: "/admin", destination: "/admin/v2", permanent: false }, { source: "/admin/orders", destination: "/admin/v2/orders", permanent: false }, { source: "/admin/orders/:id", destination: "/admin/v2/orders/:id", permanent: false }, { source: "/admin/stops", destination: "/admin/v2/stops", permanent: false }, { source: "/admin/products", destination: "/admin/v2/products", permanent: false }, ]; }, ``` - [ ] **Step 2: Commit** ```bash git add next.config.ts git commit -m "feat(admin): redirect /admin to /admin/v2" ``` ### Task 8.2: Delete the v1 dashboard **Files:** - Delete: `src/app/admin/page.tsx` - Delete: `src/app/admin/loading.tsx` (if specific to the v1 dashboard) - Modify: `src/app/admin/layout.tsx` to remove v1-specific logic (the v1 layout is now unreachable) ⚠️ **Wait 3 days after the redirect ships** before deleting. - [ ] **Step 1: Verify no 404s, then delete** ```bash rm src/app/admin/page.tsx # If the layout in src/app/admin/layout.tsx has v1-specific code that the v2 layout doesn't need, simplify it. ``` - [ ] **Step 2: Verify build + commit** ```bash npm run build git add -A git commit -m "chore(admin): remove v1 dashboard (replaced by v2)" ``` ### Task 8.3: Final Lighthouse audit + manual sign-off - [ ] **Step 1: Run Lighthouse mobile audit** ```bash npx lighthouse http://localhost:3000/admin/v2 --preset=mobile --view --output=html --output-path=./lighthouse-report.html ``` Confirm: - Performance ≥ 90 - Accessibility ≥ 95 - PWA: installable, manifest valid, SW registered, themed - [ ] **Step 2: Manual QA on a real device** Follow the full checklist in `docs/pwa-testing.md`. Sign off in the doc. - [ ] **Step 3: Commit the audit + sign-off** ```bash git add docs/pwa-testing.md git commit -m "docs: sign off on mobile PWA rollout" ``` --- ## Definition of done (re-stated) - [ ] All 4 key pages ship the mobile-first treatment at `/admin/v2/*` - [ ] PWA installs on iOS Safari and Android Chrome (verified on real devices) - [ ] Service worker pre-caches the app shell, swr-caches the data, falls back to `/offline.html` on navigation - [ ] Offline mutation queue replays actions on reconnect with no data loss - [ ] All interactive elements ≥ 48pt on mobile (aim 56pt) - [ ] All text passes WCAG AAA (7:1) against its background (CI-enforced) - [ ] Lighthouse mobile audit: Performance ≥ 90, Accessibility ≥ 95, PWA passes - [ ] No horizontal scroll on any of the 4 key pages at iPhone 13 viewport - [ ] `prefers-reduced-motion` honored - [ ] All Playwright specs pass in CI - [ ] Visual regression baselines committed for all 4 key pages × 2 viewports - [ ] Real-device install + offline testing documented and signed off