Files
route-commerce/docs/superpowers/plans/2026-06-17-admin-mobile-pwa.md
T
Tyler 837f7f83cb fix(admin): AdminShell brands type must include logo_url (AdminSidebar requires it)
The plan's AdminShell type for the brands prop was missing the
logo_url field that AdminSidebar requires. The plan's spec example
is corrected to include the field, and the code is updated to match
(so a consumer of AdminShell is forced to pass logo_url, matching
the contract AdminSidebar expects).

Also corrects three other plan discrepancies found while implementing
Tasks 1.7-1.12:
- Task 1.12: existing EmptyState.tsx is in active use; reuse it
  instead of creating a duplicate.
- Task 1.12: CardList.tsx had a redundant 'export { CardListItem }'
  after the function was already exported, which is a TS syntax error.
  Removed the redundant line.
- Plan narrative now reflects all three corrections.

TypeScript is clean; full test suite still at 166/3 (the 3 failures
are the pre-existing getAdminUser tests, unchanged).
2026-06-17 14:25:16 -06:00

133 KiB
Raw Blame History

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

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:

/* ─── 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.4810.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 → <html> → check the :root computed styles to confirm --color-bg, --text-display, --space-4 resolve.

  • Step 3: Commit
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

// 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<string, string> = {
  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<string, string> = {
  bg: "#ffffff",
  surface: "#faf8f5",
};

const BODY_TEXT_AAA: Record<string, string> = {
  text: "#1d1d1f",
  "text-muted": "#424245",
  info: "#1e3a8a",
};

const META_TEXT_AA: Record<string, string> = {
  "text-faint": "#5e5e63",
};

const STATUS_TEXT_AAA: Record<string, string> = {
  accent: "#14532d",
  danger: "#7f1d1d",
  warning: "#5e2a04",
  success: "#14532d",
};

const STATUS_ON_SOFT: Record<string, { text: string; soft: string }> = {
  "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<string, string> = {
      "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
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:

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:

"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)
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
// 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<IDBPDatabase<RouteCommerceOfflineDB>> | null = null;

export function getOfflineDB(): Promise<IDBPDatabase<RouteCommerceOfflineDB>> {
  if (typeof window === "undefined") {
    throw new Error("getOfflineDB called on the server");
  }
  if (!dbPromise) {
    dbPromise = openDB<RouteCommerceOfflineDB>(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
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

// 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
// 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<QueuedAction> {
  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<QueuedAction[]> {
  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<QueuedAction | null> {
  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<void> {
  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<void> {
  const db = await getOfflineDB();
  await db.delete("queue", id);
}

export async function markConflict(id: string, error: string): Promise<void> {
  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<void> {
  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
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

// 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
// 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<void>;
  now?: () => number;
}

export interface SyncResult {
  synced: number;
  conflicts: number;
  failed: number;
}

export async function syncPending(dispatcher: Dispatcher, options: SyncOptions): Promise<SyncResult> {
  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
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

// 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 (
    <div
      role="status"
      aria-live="polite"
      className="sticky top-0 z-40 w-full px-4 py-2 text-sm font-semibold"
      style={{
        backgroundColor: online ? "var(--color-warning-soft)" : "var(--color-danger-soft)",
        color: online ? "var(--color-warning)" : "var(--color-danger)",
      }}
    >
      {online ? (
        <>Syncing {pendingCount} pending action{pendingCount === 1 ? "" : "s"}</>
      ) : (
        <>You're offline. Changes will sync when you reconnect.</>
      )}
    </div>
  );
}

export default OfflineBanner;
  • Step 2: Verify it renders

Run: npx tsc --noEmit Expected: PASS.

  • Step 3: Commit
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

// 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<string, boolean>;
  children: ReactNode;
}

export function AdminShell(props: AdminShellProps) {
  const isDesktop = useMediaQuery("(min-width: 1024px)");

  if (isDesktop) {
    return (
      <>
        <AdminSidebar
          userRole={props.userRole}
          brandIds={props.brandIds}
          activeBrandId={props.activeBrandId}
          brands={props.brands}
          enabledAddons={props.enabledAddons}
        />
        <div
          id="page-content"
          className="min-h-screen lg:pl-60 outline-none"
          style={{ backgroundColor: "var(--color-surface)" }}
        >
          {props.children}
        </div>
      </>
    );
  }

  return (
    <>
      <OfflineBanner />
      <div
        id="page-content"
        className="min-h-screen pb-20 outline-none"
        style={{ backgroundColor: "var(--color-surface)" }}
      >
        {props.children}
      </div>
      <MobileTabBar />
    </>
  );
}

export default AdminShell;
  • Step 2: Commit (the build will fail until Task 1.9 lands; that's fine for the commit)
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

// src/lib/use-media-query.ts
"use client";

import { useEffect, useState } from "react";

export function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState<boolean>(() => {
    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
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

// 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 (
    <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2}>
      <path d="M3 12L12 3l9 9M5 10v10h14V10" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}
function OrdersIcon({ active }: { active: boolean }) {
  return (
    <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2}>
      <path d="M3 3h2l2 13h12l2-9H6" strokeLinecap="round" strokeLinejoin="round" />
      <circle cx="9" cy="20" r="1.5" /><circle cx="18" cy="20" r="1.5" />
    </svg>
  );
}
function StopsIcon({ active }: { active: boolean }) {
  return (
    <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2}>
      <path d="M12 22s8-7.5 8-13a8 8 0 10-16 0c0 5.5 8 13 8 13z" strokeLinecap="round" strokeLinejoin="round" />
      <circle cx="12" cy="9" r="2.5" />
    </svg>
  );
}
function ProductsIcon({ active }: { active: boolean }) {
  return (
    <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2}>
      <path d="M3 7l9-4 9 4v10l-9 4-9-4V7z" strokeLinecap="round" strokeLinejoin="round" />
      <path d="M3 7l9 4 9-4M12 11v10" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

export function MobileTabBar() {
  const pathname = usePathname();
  const [moreOpen, setMoreOpen] = useState(false);

  return (
    <>
      <nav
        aria-label="Primary"
        className="fixed bottom-0 left-0 right-0 z-30 grid grid-cols-5 border-t"
        style={{
          height: "60px",
          backgroundColor: "var(--color-bg)",
          borderColor: "var(--color-surface-3)",
        }}
      >
        {TABS.map((tab) => {
          const active = pathname === tab.href || pathname?.startsWith(tab.href + "/");
          return (
            <Link
              key={tab.href}
              href={tab.href}
              className="flex flex-col items-center justify-center gap-0.5 active:scale-95 transition-transform"
              style={{ color: active ? "var(--color-accent)" : "var(--color-text-muted)" }}
            >
              <tab.icon active={active} />
              <span className="text-[12px] font-semibold" style={{ letterSpacing: "0.02em" }}>{tab.label}</span>
            </Link>
          );
        })}
        <button
          type="button"
          onClick={() => setMoreOpen(true)}
          className="flex flex-col items-center justify-center gap-0.5 active:scale-95 transition-transform"
          style={{ color: "var(--color-text-muted)" }}
          aria-label="Open more menu"
        >
          <svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
            <circle cx="5" cy="12" r="2" /><circle cx="12" cy="12" r="2" /><circle cx="19" cy="12" r="2" />
          </svg>
          <span className="text-[12px] font-semibold" style={{ letterSpacing: "0.02em" }}>More</span>
        </button>
      </nav>
      <MoreSheet open={moreOpen} onClose={() => setMoreOpen(false)} />
    </>
  );
}

export default MobileTabBar;
  • Step 2: Commit (the build will fail until Task 1.11 lands; that's fine)
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

// 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<HTMLDialogElement>(null);
  const lastFocusedRef = useRef<HTMLElement | null>(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 (
    <dialog
      ref={dialogRef}
      onClick={(e) => {
        // 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)",
      }}
    >
      <div className="flex flex-col h-full overflow-y-auto">
        <div className="sticky top-0 px-6 py-4 border-b" style={{ backgroundColor: "var(--color-bg)", borderColor: "var(--color-surface-3)" }}>
          <h2 className="text-h1 font-display" style={{ fontWeight: 600 }}>More</h2>
        </div>
        <div className="px-6 py-6 space-y-8">
          {MORE_SECTIONS.map((section) => (
            <section key={section.heading}>
              <h3 className="text-label font-semibold mb-3" style={{ color: "var(--color-text-muted)", letterSpacing: "0.02em" }}>
                {section.heading.toUpperCase()}
              </h3>
              <ul className="space-y-1">
                {section.links.map((link) => (
                  <li key={link.href}>
                    <Link
                      href={link.href}
                      onClick={onClose}
                      className="flex items-center gap-3 px-4 py-4 rounded-xl active:bg-[var(--color-surface-2)] transition-colors"
                      style={{ minHeight: "56px" }}
                    >
                      <span aria-hidden="true" className="text-2xl">{link.icon}</span>
                      <span className="text-h2" style={{ fontWeight: 700 }}>{link.label}</span>
                    </Link>
                  </li>
                ))}
              </ul>
            </section>
          ))}
        </div>
      </div>
    </dialog>
  );
}

export default MoreSheet;
  • Step 2: Verify

Run: npx tsc --noEmit Expected: PASS.

  • Step 3: Commit
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
// 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 (
    <header className="px-4 pt-6 pb-4 flex items-start justify-between gap-4">
      <div>
        <h1 className="text-display font-display" style={{ fontWeight: 600, lineHeight: 1.15, letterSpacing: "-0.01em" }}>
          {title}
        </h1>
        {subtitle && (
          <p className="text-mono mt-1" style={{ color: "var(--color-text-muted)" }}>{subtitle}</p>
        )}
      </div>
      {actions && <div className="flex items-center gap-2 shrink-0">{actions}</div>}
    </header>
  );
}

export default PageHeader;
  • Step 2: StatusPill.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<StatusKind, { bg: string; fg: string; label: string }> = {
  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 (
    <span
      className="inline-flex items-center px-2.5 py-1 rounded-full text-label font-semibold"
      style={{ backgroundColor: cfg.bg, color: cfg.fg, letterSpacing: "0.02em", minHeight: "24px" }}
      aria-label={cfg.label}
    >
      {label ?? cfg.label}
    </span>
  );
}

export default StatusPill;
  • Step 3: CardList.tsx + CardListItem.tsx
// src/components/admin/CardList.tsx
import { ReactNode } from "react";

interface CardListProps {
  children: ReactNode;
  ariaLabel?: string;
}

export function CardList({ children, ariaLabel }: CardListProps) {
  return (
    <ul role="list" aria-label={ariaLabel} className="space-y-3 px-4 pb-24">
      {children}
    </ul>
  );
}

interface CardListItemProps {
  onClick?: () => void;
  href?: string;
  children: ReactNode;
  as?: "li" | "div";
}

export function CardListItem({ onClick, href, children, as = "li" }: CardListItemProps) {
  const content = (
    <div
      className="rounded-2xl p-4 transition-colors"
      style={{
        backgroundColor: "var(--color-surface-2)",
        minHeight: "72px",
        boxShadow: "0 1px 2px rgba(0,0,0,0.04)",
      }}
    >
      {children}
    </div>
  );

  if (href) {
    return (
      <li>
        <a href={href} className="block active:scale-[0.99] transition-transform" style={{ minHeight: "72px" }}>
          {content}
        </a>
      </li>
    );
  }
  if (onClick) {
    return (
      <li>
        <button
          type="button"
          onClick={onClick}
          className="block w-full text-left active:scale-[0.99] transition-transform"
          style={{ minHeight: "72px" }}
        >
          {content}
        </button>
      </li>
    );
  }
  return <li>{content}</li>;
}
  • Step 5: StickyActionBar.tsx
// src/components/admin/StickyActionBar.tsx
import { ReactNode } from "react";

interface StickyActionBarProps {
  children: ReactNode;
  pendingSync?: boolean;
}

export function StickyActionBar({ children, pendingSync = false }: StickyActionBarProps) {
  return (
    <div
      className="fixed left-0 right-0 z-20 px-4 py-3 border-t"
      style={{
        bottom: "60px", // sits above the mobile tab bar
        backgroundColor: "var(--color-bg)",
        borderColor: "var(--color-surface-3)",
        boxShadow: "0 -4px 12px rgba(0,0,0,0.04)",
      }}
    >
      {pendingSync && (
        <div className="text-label font-semibold mb-2 flex items-center gap-2" style={{ color: "var(--color-text-muted)" }}>
          <span className="inline-block w-3 h-3 rounded-full animate-pulse" style={{ backgroundColor: "var(--color-warning)" }} />
          Pending sync
        </div>
      )}
      {children}
    </div>
  );
}

export default StickyActionBar;
  • Step 6: Verify

Run: npx tsc --noEmit Expected: PASS.

  • Step 7: Commit
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:

{
  "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
# .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
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.43.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:

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

// 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(`<svg width="1280" height="720" xmlns="http://www.w3.org/2000/svg">
      <text x="640" y="360" font-family="serif" font-size="48" fill="#1a4d2e" text-anchor="middle">Route Commerce</text>
    </svg>`),
    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(`<svg width="390" height="844" xmlns="http://www.w3.org/2000/svg">
      <text x="195" y="422" font-family="serif" font-size="32" fill="#1a4d2e" text-anchor="middle">Route Commerce</text>
    </svg>`),
    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
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:

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

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  <meta name="theme-color" content="#166534">
  <title>Offline — Route Commerce</title>
  <style>
    :root {
      --color-bg: #ffffff;
      --color-surface: #faf8f5;
      --color-text: #1d1d1f;
      --color-text-muted: #424245;
      --color-accent: #166534;
      --color-warning: #854d0e;
    }
    * { box-sizing: border-box; }
    html, body { margin: 0; padding: 0; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Manrope, system-ui, sans-serif;
      background: var(--color-surface);
      color: var(--color-text);
      min-height: 100vh;
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 24px;
      -webkit-font-smoothing: antialiased;
    }
    .card {
      max-width: 480px;
      width: 100%;
      background: var(--color-bg);
      border-radius: 24px;
      padding: 48px 32px;
      text-align: center;
      box-shadow: 0 4px 24px rgba(0,0,0,0.06);
    }
    .icon {
      width: 64px;
      height: 64px;
      margin: 0 auto 24px;
      background: var(--color-warning);
      color: white;
      border-radius: 16px;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 32px;
    }
    h1 {
      font-family: Fraunces, Georgia, serif;
      font-size: 32px;
      font-weight: 600;
      margin: 0 0 12px;
      letter-spacing: -0.01em;
    }
    p {
      font-size: 18px;
      line-height: 1.45;
      color: var(--color-text-muted);
      margin: 0 0 24px;
    }
    button {
      background: var(--color-accent);
      color: white;
      border: 0;
      border-radius: 12px;
      padding: 16px 32px;
      font-size: 15px;
      font-weight: 600;
      letter-spacing: 0.02em;
      cursor: pointer;
      min-height: 56px;
    }
    button:active { opacity: 0.85; }
  </style>
</head>
<body>
  <div class="card" role="status">
    <div class="icon" aria-hidden="true">📡</div>
    <h1>You're offline</h1>
    <p>This page isn't available without a connection. Cached pages will still work. Your changes will sync when you're back online.</p>
    <button type="button" onclick="location.reload()">Try again</button>
  </div>
</body>
</html>
  • 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
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

// 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
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
{
  "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
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:

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

After the <html> 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 <head> directly):

In the metadata export, add an appLinks and manifest field. Then in the <head> of the body, add the apple-specific tags via a small <head> block in the JSX:

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 <link rel="manifest" href="/manifest.json"> and <meta name="apple-mobile-web-app-capable" content="yes"> are present.

  • Step 4: Commit
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

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

import PWAInstallPrompt from "@/components/pwa/InstallPrompt";

In the layout's return (just before the closing tag of the outermost wrapper), add:

<PWAInstallPrompt />
  • 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
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
// 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
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

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

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

// 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 (
    <ToastProvider>
      {children}
      <ToastContainer />
    </ToastProvider>
  );
}

export default async function AdminV2Layout({ children }: { children: React.ReactNode }) {
  let adminUser = null;
  try {
    adminUser = await getAdminUser();
  } catch (e) {
    return <ToastProviderWrapper><AdminAccessDenied message="Failed to verify authentication." /></ToastProviderWrapper>;
  }
  if (!adminUser) {
    return <ToastProviderWrapper><AdminAccessDenied message="You don't have admin access." /></ToastProviderWrapper>;
  }

  const activeBrandId = await getActiveBrandId(adminUser).catch(() => null);
  const brands = await listBrandsForAdmin().catch(() => []);
  const enabledAddons = activeBrandId ? await getEnabledAddons(activeBrandId).catch(() => ({})) : {};

  return (
    <ToastProviderWrapper>
      <AdminShell
        userRole={adminUser.role}
        brandIds={adminUser.brand_ids}
        activeBrandId={activeBrandId}
        brands={brands}
        enabledAddons={enabledAddons}
      >
        <SmoothViewTransition>{children}</SmoothViewTransition>
        <RouteAnnouncer />
      </AdminShell>
    </ToastProviderWrapper>
  );
}
  • Step 2: Add the offline dispatcher server module

Create src/actions/offline-dispatcher.ts:

// 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
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
// 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 (
      <main>
        <PageHeader title="Orders" actions={<OrdersFilterButton />} />
        <EmptyState title="No orders yet" description="When customers place orders, they'll show up here." icon="📦" />
      </main>
    );
  }

  return (
    <main>
      <PageHeader title="Orders" subtitle={`${orders.length} order${orders.length === 1 ? "" : "s"}`} actions={<OrdersFilterButton />} />
      <PullToRefresh>
        <CardList ariaLabel="Orders">
          {orders.map((order) => (
            <CardListItem key={order.id} href={`/admin/v2/orders/${order.id}`}>
              <div className="flex items-start justify-between gap-3 mb-2">
                <StatusPill status={order.status as StatusKind} />
                <span className="text-mono" style={{ color: "var(--color-text-faint)" }}>#{order.id.slice(0, 8)}</span>
                <span className="text-meta ml-auto" style={{ color: "var(--color-text-muted)" }}>{formatRelativeTime(order.created_at)}</span>
              </div>
              <div className="text-h2" style={{ fontWeight: 700, lineHeight: 1.3 }}>{order.customer_name ?? "Customer"}</div>
              <div className="text-meta mt-1" style={{ color: "var(--color-text-muted)" }}>{order.item_count} item{order.item_count === 1 ? "" : "s"}</div>
              <div className="flex items-center justify-between mt-2">
                <span className="text-h1 font-display" style={{ fontWeight: 600 }}>
                  ${(order.total_cents / 100).toFixed(2)}
                </span>
                <span aria-hidden="true" style={{ color: "var(--color-text-faint)" }}></span>
              </div>
            </CardListItem>
          ))}
        </CardList>
      </PullToRefresh>
    </main>
  );
}

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

// 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<HTMLDivElement>(null);
  const startY = useRef<number | null>(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 (
    <div
      ref={containerRef}
      onTouchStart={onTouchStart}
      onTouchMove={onTouchMove}
      onTouchEnd={onTouchEnd}
      onTouchCancel={onTouchEnd}
      style={{
        transform: `translateY(${offset}px)`,
        transition: pulling.current || prefersReducedMotion ? "none" : "transform 200ms var(--ease-apple)",
        overscrollBehavior: "contain",
      }}
    >
      {offset > 0 && (
        <div
          aria-hidden="true"
          className="flex items-center justify-center"
          style={{
            position: "absolute",
            top: 0,
            left: 0,
            right: 0,
            height: `${REFRESH_INDICATOR_HEIGHT}px`,
            transform: `translateY(-${REFRESH_INDICATOR_HEIGHT}px)`,
          }}
        >
          <div
            className="rounded-full"
            style={{
              width: "32px",
              height: "32px",
              border: "3px solid var(--color-surface-3)",
              borderTopColor: "var(--color-accent)",
              animation: refreshing ? "spin 0.8s linear infinite" : "none",
            }}
          />
        </div>
      )}
      {children}
    </div>
  );
}

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

// 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<HTMLDialogElement>(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 (
    <>
      <button
        type="button"
        onClick={() => setOpen(true)}
        aria-label="Filter orders"
        className="rounded-xl flex items-center gap-2"
        style={{
          minHeight: "56px",
          padding: "0 16px",
          backgroundColor: "var(--color-surface-2)",
          color: "var(--color-text)",
          fontWeight: 600,
          letterSpacing: "0.02em",
        }}
      >
        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
          <path d="M3 6h18M6 12h12M10 18h4" strokeLinecap="round" />
        </svg>
        Filter
      </button>
      <dialog
        ref={dialogRef}
        onClick={(e) => { 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)" }}
      >
        <div className="flex flex-col h-full overflow-y-auto">
          <div className="sticky top-0 px-6 py-4 border-b flex items-center justify-between" style={{ backgroundColor: "var(--color-bg)", borderColor: "var(--color-surface-3)" }}>
            <h2 className="text-h1 font-display" style={{ fontWeight: 600 }}>Filter</h2>
            <button type="button" onClick={() => setOpen(false)} aria-label="Close" className="p-2"></button>
          </div>
          <div className="px-6 py-6 space-y-3">
            {["", "placed", "ready", "picked-up", "cancelled"].map((s) => (
              <button
                key={s || "all"}
                type="button"
                onClick={() => apply(s)}
                className="w-full text-left rounded-xl px-4 py-4"
                style={{
                  minHeight: "56px",
                  backgroundColor: currentStatus === s ? "var(--color-accent-soft)" : "var(--color-surface-2)",
                  fontWeight: 600,
                }}
              >
                {s ? s.charAt(0).toUpperCase() + s.slice(1) : "All orders"}
              </button>
            ))}
          </div>
        </div>
      </dialog>
    </>
  );
}

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

// 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
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
// 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 (
    <main className="pb-40">
      <PageHeader title={`Order #${order.id.slice(0, 8)}`} subtitle={formatDate(order.created_at)} />
      <div className="px-4 space-y-4">
        <section className="rounded-2xl p-4" style={{ backgroundColor: "var(--color-surface-2)" }}>
          <h2 className="text-h1 font-display" style={{ fontWeight: 600 }}>{order.customer_name}</h2>
          <div className="mt-2 space-y-1">
            {order.customer_phone && (
              <a href={`tel:${order.customer_phone}`} className="block text-body" style={{ color: "var(--color-accent)" }}>
                {order.customer_phone}
              </a>
            )}
            {order.customer_email && (
              <a href={`mailto:${order.customer_email}`} className="block text-body" style={{ color: "var(--color-accent)" }}>
                {order.customer_email}
              </a>
            )}
          </div>
          <div className="mt-3">
            <StatusPill status={order.status as StatusKind} />
          </div>
        </section>

        <section className="rounded-2xl p-4" style={{ backgroundColor: "var(--color-surface-2)" }}>
          <h3 className="text-label font-semibold mb-3" style={{ color: "var(--color-text-muted)", letterSpacing: "0.02em" }}>
            ITEMS
          </h3>
          <ul className="space-y-3">
            {order.items.map((item) => (
              <li key={item.id} className="flex items-center gap-3">
                {item.image_url && (
                  <img src={item.image_url} alt="" className="w-14 h-14 rounded-xl object-cover shrink-0" />
                )}
                <div className="flex-1 min-w-0">
                  <div className="text-h2" style={{ fontWeight: 700 }}>{item.name}</div>
                  <div className="text-meta" style={{ color: "var(--color-text-muted)" }}>
                    {item.quantity} × ${(item.price_cents / 100).toFixed(2)}
                  </div>
                </div>
                <div className="text-body font-semibold">
                  ${((item.price_cents * item.quantity) / 100).toFixed(2)}
                </div>
              </li>
            ))}
          </ul>
          <div className="mt-4 pt-3 border-t flex items-center justify-between" style={{ borderColor: "var(--color-surface-3)" }}>
            <span className="text-label font-semibold" style={{ color: "var(--color-text-muted)", letterSpacing: "0.02em" }}>TOTAL</span>
            <span className="text-h1 font-display" style={{ fontWeight: 600 }}>${(order.total_cents / 100).toFixed(2)}</span>
          </div>
        </section>

        {order.notes && (
          <section className="rounded-2xl p-4 italic" style={{ backgroundColor: "var(--color-surface-2)" }}>
            <p className="text-body">"{order.notes}"</p>
          </section>
        )}
      </div>

      <StickyActionBar>
        <OrderActionButtons orderId={order.id} status={order.status as StatusKind} />
      </StickyActionBar>
    </main>
  );
}
  • Step 3: Create the OrderActionButtons client component
// 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<string | null>(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 (
      <button
        type="button"
        onClick={() => handleAction("markOrderReady")}
        disabled={pending}
        className="w-full rounded-xl text-label font-semibold active:opacity-80 transition-opacity"
        style={{ backgroundColor: "var(--color-accent)", color: "white", minHeight: "56px", letterSpacing: "0.02em" }}
      >
        {pending ? "Marking…" : "Mark Ready"}
      </button>
    );
  }
  if (status === "ready") {
    return (
      <button
        type="button"
        onClick={() => handleAction("markOrderPickedUp")}
        disabled={pending}
        className="w-full rounded-xl text-label font-semibold active:opacity-80 transition-opacity"
        style={{ backgroundColor: "var(--color-accent)", color: "white", minHeight: "56px", letterSpacing: "0.02em" }}
      >
        {pending ? "Marking…" : "Mark Picked Up"}
      </button>
    );
  }
  if (conflict) {
    return (
      <div className="text-body" style={{ color: "var(--color-danger)" }}>{conflict}</div>
    );
  }
  return (
    <a
      href={`/admin/orders/${orderId}/receipt`}
      className="block w-full text-center rounded-xl text-label font-semibold active:opacity-80 transition-opacity"
      style={{ border: "1px solid var(--color-surface-3)", minHeight: "56px", lineHeight: "56px", letterSpacing: "0.02em" }}
    >
      View Receipt
    </a>
  );
}

export default OrderActionButtons;
  • Step 4: Verify

Run: npm run dev, navigate to /admin/v2/orders/<some-id>. 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
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

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

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

// 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
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 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
// 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<string, typeof stops> = { 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 (
      <main>
        <PageHeader title="Stops" subtitle={formatDateHeading(date)} actions={<StopsDatePicker value={date} />} />
        <EmptyState title="No stops scheduled" description="Add a stop to start routing." icon="🚚" />
      </main>
    );
  }

  const groups = groupByTimeOfDay(stops);
  return (
    <main className="pb-24">
      <PageHeader title="Stops" subtitle={formatDateHeading(date)} actions={<StopsDatePicker value={date} />} />
      {Object.entries(groups).map(([time, groupStops]) => groupStops.length > 0 && (
        <section key={time}>
          <h2 className="sticky top-0 px-4 py-2 text-label font-semibold" style={{ backgroundColor: "var(--color-surface)", color: "var(--color-text-muted)", letterSpacing: "0.02em" }}>
            {time.toUpperCase()}
          </h2>
          <CardList ariaLabel={`${time} stops`}>
            {groupStops.map((stop) => (
              <CardListItem key={stop.id} href={stop.maps_url ?? "#"}>
                <div className="flex items-center gap-3">
                  <div className="text-h1 font-display shrink-0" style={{ fontWeight: 600, minWidth: "72px" }}>
                    {formatTime(stop.scheduled_at)}
                  </div>
                  <div className="flex-1 min-w-0">
                    <div className="text-h2" style={{ fontWeight: 700, lineHeight: 1.3 }}>{stop.address}</div>
                    <div className="text-meta mt-1" style={{ color: "var(--color-text-muted)" }}>
                      {stop.customer_count} customer{stop.customer_count === 1 ? "" : "s"}
                    </div>
                  </div>
                  <StatusPill status={stop.status as StatusKind} />
                </div>
              </CardListItem>
            ))}
          </CardList>
        </section>
      ))}
    </main>
  );
}
  • Step 2.1: Create the StopsDatePicker component
// 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 (
    <label
      className="rounded-xl flex items-center gap-2"
      style={{
        minHeight: "56px",
        padding: "0 16px",
        backgroundColor: "var(--color-surface-2)",
        fontWeight: 600,
        letterSpacing: "0.02em",
      }}
    >
      <span className="text-label" style={{ color: "var(--color-text-muted)" }}>Date</span>
      <input
        type="date"
        value={toInputValue(value)}
        onChange={(e) => {
          const newDate = e.target.value;
          router.push(`/admin/v2/stops?date=${newDate}`);
        }}
        style={{
          background: "transparent",
          border: 0,
          outline: "none",
          fontSize: "15px",
          fontWeight: 600,
          color: "var(--color-text)",
        }}
      />
    </label>
  );
}

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

# 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

// 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 (
      <main>
        <PageHeader title="Products" />
        <ProductsFilterChips active={params.filter ?? "all"} />
        <EmptyState title="No products" description="Add your first product to start selling." icon="🥬" />
      </main>
    );
  }

  return (
    <main className="pb-24">
      <PageHeader title="Products" subtitle={`${products.length} product${products.length === 1 ? "" : "s"}`} />
      <ProductsFilterChips active={params.filter ?? "all"} />
      <CardList ariaLabel="Products">
        {products.map((product) => {
          const stock = stockStatus(product.stock);
          return (
            <CardListItem key={product.id} href={`/admin/v2/products/${product.id}`}>
              <div className="flex items-start gap-3">
                {product.image_url && (
                  <img src={product.image_url} alt="" className="w-16 h-16 rounded-2xl object-cover shrink-0" />
                )}
                <div className="flex-1 min-w-0">
                  <div className="flex items-start justify-between gap-2">
                    <div className="text-h2" style={{ fontWeight: 700, lineHeight: 1.3 }}>{product.name}</div>
                    <StockAdjustButton productId={product.id} currentStock={product.stock} />
                  </div>
                  <div className="text-h1 font-display mt-1" style={{ fontWeight: 600 }}>${(product.price_cents / 100).toFixed(2)}</div>
                  <div className="mt-2">
                    <StatusPill status={stock.kind} label={stock.label} />
                  </div>
                </div>
              </div>
            </CardListItem>
          );
        })}
      </CardList>
    </main>
  );
}
  • Step 1.1: Create the ProductsFilterChips component
// 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 (
    <div className="px-4 pb-3 flex gap-2 overflow-x-auto" role="tablist" aria-label="Filter products">
      {FILTERS.map((f) => {
        const isActive = active === f.value;
        return (
          <button
            key={f.value}
            type="button"
            role="tab"
            aria-selected={isActive}
            onClick={() => apply(f.value)}
            className="rounded-full text-label font-semibold whitespace-nowrap"
            style={{
              minHeight: "40px",
              padding: "0 16px",
              backgroundColor: isActive ? "var(--color-accent)" : "var(--color-surface-2)",
              color: isActive ? "white" : "var(--color-text)",
              letterSpacing: "0.02em",
            }}
          >
            {f.label}
          </button>
        );
      })}
    </div>
  );
}

export default ProductsFilterChips;
  • Step 1.2: Create the StockAdjustButton component (long-press to open stepper)
// 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<ReturnType<typeof setTimeout> | 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 (
    <>
      <button
        type="button"
        onPointerDown={onPointerDown}
        onPointerUp={onPointerUp}
        onPointerLeave={onPointerUp}
        aria-label={`Adjust stock for product ${productId}`}
        className="rounded-full"
        style={{
          minWidth: "40px",
          minHeight: "40px",
          backgroundColor: "var(--color-surface-3)",
          color: "var(--color-text)",
        }}
      >
        ±
      </button>
      {open && (
        <div role="dialog" aria-label="Adjust stock" className="fixed inset-0 z-50 flex items-end justify-center backdrop-blur-sm" style={{ backgroundColor: "rgba(0,0,0,0.4)" }}>
          <div className="w-full max-w-[640px] rounded-t-3xl p-6" style={{ backgroundColor: "var(--color-bg)", paddingBottom: "calc(env(safe-area-inset-bottom, 0) + 24px)" }}>
            <div className="w-12 h-1.5 rounded-full mx-auto mb-4" style={{ backgroundColor: "var(--color-surface-3)" }} />
            <h2 className="text-h1 font-display" style={{ fontWeight: 600 }}>Adjust stock</h2>
            <div className="mt-6 flex items-center justify-center gap-6">
              <button type="button" onClick={() => adjust(-1)} disabled={pending} className="rounded-full" style={{ width: "64px", height: "64px", backgroundColor: "var(--color-surface-2)", fontSize: "32px", fontWeight: 700 }}></button>
              <div className="text-display font-display" style={{ fontWeight: 600, minWidth: "100px", textAlign: "center" }}>{stock}</div>
              <button type="button" onClick={() => adjust(1)} disabled={pending} className="rounded-full" style={{ width: "64px", height: "64px", backgroundColor: "var(--color-accent)", color: "white", fontSize: "32px", fontWeight: 700 }}>+</button>
            </div>
            <button type="button" onClick={() => setOpen(false)} className="w-full mt-6 rounded-xl text-label font-semibold" style={{ minHeight: "56px", backgroundColor: "var(--color-surface-2)", letterSpacing: "0.02em" }}>Done</button>
          </div>
        </div>
      )}
    </>
  );
}

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

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

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

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

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

// 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 (
      <main>
        <PageHeader title="Today" />
        <EmptyState title="No brand selected" description="Select a brand to view its dashboard." icon="🌾" />
      </main>
    );
  }
  const [summary, stops, pendingOrders] = await Promise.all([
    getDashboardSummary(brandId),
    getStopsForBrand(brandId, { date: new Date() }),
    getOrdersForBrand(brandId, { status: "placed", limit: 5 }),
  ]);

  return (
    <main className="pb-24">
      <PageHeader title="Today" subtitle={formatDate(new Date())} />
      <div className="px-4 space-y-4">
        {summary.pending_fulfillment > 0 && (
          <Link
            href="/admin/v2/orders?status=placed"
            className="block rounded-2xl p-4 active:opacity-80 transition-opacity"
            style={{ backgroundColor: "var(--color-warning-soft)", borderLeft: "4px solid var(--color-warning)" }}
          >
            <div className="text-label font-semibold" style={{ color: "var(--color-warning)", letterSpacing: "0.02em" }}>
              NEEDS YOU
            </div>
            <div className="text-h2 mt-1" style={{ fontWeight: 700 }}>
              {summary.pending_fulfillment} order{summary.pending_fulfillment === 1 ? "" : "s"} waiting
            </div>
            <div className="text-meta mt-1" style={{ color: "var(--color-text-muted)" }}>View all </div>
          </Link>
        )}

        <section className="grid grid-cols-2 gap-3">
          <StatCard label="Orders" value={String(summary.orders_today)} />
          <StatCard label="Revenue" value={`$${(summary.revenue_today / 100).toFixed(2)}`} />
          <StatCard label="Pending" value={String(summary.pending_fulfillment)} accent={summary.pending_fulfillment > 0} />
          <StatCard label="Stops" value={String(summary.stops_today)} />
        </section>

        {stops.length > 0 && (
          <section>
            <h2 className="text-label font-semibold mb-3" style={{ color: "var(--color-text-muted)", letterSpacing: "0.02em" }}>
              TODAY'S STOPS
            </h2>
            <ul className="space-y-2">
              {stops.slice(0, 5).map((stop) => (
                <li key={stop.id} className="rounded-2xl p-4" style={{ backgroundColor: "var(--color-surface-2)" }}>
                  <div className="text-meta" style={{ color: "var(--color-text-muted)" }}>
                    {new Date(stop.scheduled_at).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}
                  </div>
                  <div className="text-h2 mt-1" style={{ fontWeight: 700 }}>{stop.address}</div>
                </li>
              ))}
            </ul>
          </section>
        )}
      </div>
    </main>
  );
}

function StatCard({ label, value, accent = false }: { label: string; value: string; accent?: boolean }) {
  return (
    <div className="rounded-2xl p-4" style={{ backgroundColor: accent ? "var(--color-warning-soft)" : "var(--color-surface-2)" }}>
      <div className="text-label font-semibold" style={{ color: "var(--color-text-muted)", letterSpacing: "0.02em" }}>
        {label.toUpperCase()}
      </div>
      <div className="text-h1 font-display mt-1" style={{ fontWeight: 600, color: accent ? "var(--color-warning)" : "var(--color-text)" }}>
        {value}
      </div>
    </div>
  );
}

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

// 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<DashboardSummary> {
  const adminUser = await getAdminUser();
  if (!adminUser) throw new Error("Unauthorized");
  const result = await pool.query<DashboardSummary>(
    "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
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

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