# Boss Phone Dashboard 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:** Ship a phone-first dashboard at `/m` that shows per-rep quote throughput, per-rep order counts, and driver stops today — for the boss to keep track of business momentum on their phone. **Architecture:** New service `boss-dashboard-service.ts` exposes `getBossDashboardData({ window })` (single window at a time, ~3-5 KB payload) and `getBossRepActivity({ rep, window, kind, page })` for drill-downs. Two thin `/api/v1/boss-dashboard/*` routes serve the data. New `(mobile)/m` route group with its own layout (no nav, manifest link). Single `"use client"` page that mirrors the existing `/dashboard` pattern: bearer-token auth via the API route, PB realtime subscription + 1s debounce, native `` bottom sheet for drill-downs. **Tech Stack:** Next.js (App Router), TypeScript, PocketBase admin client, Vitest, Tailwind, sonner toasts, lucide-react icons, native `` for the bottom sheet. --- ## ⚠️ Spec-to-plan divergence: page.tsx is a client component, not a server component The spec (`docs/superpowers/specs/2026-08-27-boss-phone-dashboard-design.md`, "Architecture & data flow § First paint") describes `page.tsx` as a Server Component that calls `assertBrowserSession()` and renders with SSR data. This is **not** how the existing app works. PocketBase's browser session token lives in `localStorage` only (not a cookie), so the route's auth gate cannot read it from a Server Component. The realistic pattern in this repo (matching `/dashboard/page.tsx`) is: - `page.tsx` is `"use client"` and renders the view with no SSR data. - The view fetches `/api/v1/boss-dashboard` on mount, forwarding the PB token from `pb.authStore` as a `Authorization` header. - The API route's local `assertBrowserSession(req)` validates the bearer token against PB's `/auth-refresh`. Trade-off: first paint shows a brief empty state instead of server-rendered data. In practice this is ~80-120ms on the same LAN as PB — fine for a phone dashboard the boss opens once per hour, and consistent with the rest of the app. If the spec is later updated to add PB cookie support, the same view can be made SSR-friendly by switching to a Server Component and reading `cookies()` — but no such migration is needed today. --- ## File Structure | File | Responsibility | |---|---| | `src/lib/server/boss-dashboard-service.ts` | Data: `windowRange()`, `getBossDashboardData({ window })`, `getBossRepActivity({ rep, window, kind, page })`. | | `src/lib/server/boss-dashboard-service.test.ts` | Unit tests for window boundaries, per-rep math, unknown bucketing, rep-activity pagination. | | `src/app/api/v1/boss-dashboard/route.ts` | `GET ?window=today\|week\|month`. Auth via local `assertBrowserSession`. | | `src/app/api/v1/boss-dashboard/rep-activity/route.ts` | `GET ?rep=&window=&kind=quotes\|orders&page=`. Auth same. | | `src/app/api/v1/boss-dashboard/route.test.ts` | Tests for both routes (auth gate, query parsing, payload shape). | | `public/manifest-mobile.json` | PWA manifest (name, short_name, theme_color, display=standalone, icons). | | `src/app/(mobile)/m/layout.tsx` | Phone shell — `
` wrapper + ``. | | `src/app/(mobile)/m/page.tsx` | `"use client"` — renders ``. | | `src/app/(mobile)/m/dashboard-view.tsx` | Header strip, time tabs, order totals, reps section, drivers section, drill-down sheet. | | `src/app/(mobile)/m/boss-dashboard.module.css` | (Optional) phone-specific styles if Tailwind utilities get too noisy. | --- ## Phase 1 — Service layer ### Task 1: Window range helper **Files:** - Create: `src/lib/server/boss-dashboard-service.ts` - Test: `src/lib/server/boss-dashboard-service.test.ts` - [ ] **Step 1: Write the failing test for `windowRange`** Add at the top of `src/lib/server/boss-dashboard-service.test.ts`: ```ts import { describe, it, expect } from "vitest"; import { windowRange } from "./boss-dashboard-service"; describe("windowRange", () => { it("today: start of UTC day to start of next UTC day", () => { const r = windowRange("today", new Date("2026-08-27T14:32:11.000Z")); expect(r.rangeStart).toBe("2026-08-27T00:00:00.000Z"); expect(r.rangeEnd).toBe("2026-08-28T00:00:00.000Z"); }); it("week: Monday 00:00 UTC to next Monday 00:00 UTC", () => { // 2026-08-27 is a Thursday. Week starts Mon 2026-08-24. const r = windowRange("week", new Date("2026-08-27T14:32:11.000Z")); expect(r.rangeStart).toBe("2026-08-24T00:00:00.000Z"); expect(r.rangeEnd).toBe("2026-08-31T00:00:00.000Z"); }); it("month: first of month to first of next month", () => { const r = windowRange("month", new Date("2026-08-27T14:32:11.000Z")); expect(r.rangeStart).toBe("2026-08-01T00:00:00.000Z"); expect(r.rangeEnd).toBe("2026-09-01T00:00:00.000Z"); }); it("week across month boundary: still anchored to Monday", () => { // 2026-09-02 is a Wednesday. Week starts Mon 2026-08-31, ends Mon 2026-09-07. const r = windowRange("week", new Date("2026-09-02T09:00:00.000Z")); expect(r.rangeStart).toBe("2026-08-31T00:00:00.000Z"); expect(r.rangeEnd).toBe("2026-09-07T00:00:00.000Z"); }); }); ``` - [ ] **Step 2: Run the test to verify it fails** Run: `pnpm vitest run src/lib/server/boss-dashboard-service.test.ts` Expected: FAIL — `windowRange` is not exported. - [ ] **Step 3: Implement `windowRange`** In `src/lib/server/boss-dashboard-service.ts`: ```ts export type BossWindow = "today" | "week" | "month"; export interface WindowRange { rangeStart: string; // ISO rangeEnd: string; // ISO } /** * Computes the [start, end) UTC window for a boss-dashboard time tab. * Today: midnight-to-midnight UTC. * Week: Monday 00:00 UTC to next Monday 00:00 UTC. * Month: day-1 00:00 UTC to day-1 of next month. * * All UTC because that's how PocketBase `created` timestamps compare. */ export function windowRange(window: BossWindow, now: Date = new Date()): WindowRange { const start = new Date(now); const end = new Date(now); switch (window) { case "today": { start.setUTCHours(0, 0, 0, 0); end.setUTCDate(end.getUTCDate() + 1); end.setUTCHours(0, 0, 0, 0); break; } case "week": { // Monday = 1, Sunday = 0. Shift so Monday is start. const day = start.getUTCDay(); const diffToMonday = day === 0 ? -6 : 1 - day; start.setUTCDate(start.getUTCDate() + diffToMonday); start.setUTCHours(0, 0, 0, 0); end.setTime(start.getTime()); end.setUTCDate(end.getUTCDate() + 7); break; } case "month": { start.setUTCDate(1); start.setUTCHours(0, 0, 0, 0); end.setUTCMonth(end.getUTCMonth() + 1, 1); end.setUTCHours(0, 0, 0, 0); break; } } return { rangeStart: start.toISOString(), rangeEnd: end.toISOString() }; } ``` - [ ] **Step 4: Run the test to verify it passes** Run: `pnpm vitest run src/lib/server/boss-dashboard-service.test.ts` Expected: PASS — 4 tests green. - [ ] **Step 5: Commit** ```bash git add src/lib/server/boss-dashboard-service.ts src/lib/server/boss-dashboard-service.test.ts git commit -m "feat(boss-dashboard): add windowRange helper with UTC boundaries" ``` --- ### Task 2: `getBossDashboardData` aggregation **Files:** - Modify: `src/lib/server/boss-dashboard-service.ts` - Modify: `src/lib/server/boss-dashboard-service.test.ts` - [ ] **Step 1: Write the failing aggregation tests** Append to the test file: ```ts import { getBossDashboardData } from "./boss-dashboard-service"; import { vi, beforeEach } from "vitest"; // Mock the admin PB client used by the service. // Pattern lifted from src/lib/server/dashboard-service.test.ts. const { pbMock, clearStore } = vi.hoisted(() => { type Row = Record & { id: string }; type Store = Map; const stores: Record = {}; let seq = 0; const ensure = (name: string): Store => { if (!stores[name]) stores[name] = new Map(); return stores[name]; }; const tokeniseLeaf = ( item: Row, clause: string, ): boolean => { const m = clause.match(/^(\w+)\s*(>=|<=|!=|=)\s*(?:"([^"]*)"|(\S+))$/); if (!m) return true; const [, field, op, quoted, unquoted] = m; const expected = quoted ?? unquoted ?? ""; const actual = item[field]; const a = actual instanceof Date ? actual.toISOString() : String(actual ?? ""); switch (op) { case "=": return a === expected; case "!=": return a !== expected; case ">=": return a >= expected; case "<=": return a <= expected; default: return true; } }; const matches = (item: Row, filter: string): boolean => { if (!filter.trim()) return true; return filter.split("&&").every((part) => { const trimmed = part.trim(); // Handle "X = Y || X = Z" inside one term. const alts = trimmed.split("||").map((s) => s.trim()); return alts.some((alt) => tokeniseLeaf(item, alt)); }); }; const buildListFn = (name: string) => async (opts: { filter?: string; fields?: string; expand?: string }) => { const rows = Array.from(ensure(name).values()).filter((r) => matches(r, opts.filter ?? ""), ); // Mocked expand: pretend the relation is already inlined by callers. return rows; }; const buildGetListFn = (name: string) => async (opts: { filter?: string; sort?: string; perPage?: number; page?: number; expand?: string; fields?: string }) => { let rows = Array.from(ensure(name).values()).filter((r) => matches(r, opts.filter ?? ""), ); if (opts.sort) { const desc = opts.sort.startsWith("-"); const key = desc ? opts.sort.slice(1) : opts.sort; rows = rows.slice().sort((a, b) => { const av = String(a[key] ?? ""); const bv = String(b[key] ?? ""); return desc ? bv.localeCompare(av) : av.localeCompare(bv); }); } const page = opts.page ?? 1; const perPage = opts.perPage ?? 30; const start = (page - 1) * perPage; return { items: rows.slice(start, start + perPage), page, perPage, totalItems: rows.length }; }; const pbMock = { collection: (name: string) => { const list = buildListFn(name); const getList = buildGetListFn(name); return { getFullList: list, getList, getOne: async (id: string) => ensure(name).get(id) ?? null, }; }, }; const clearStore = () => { for (const k of Object.keys(stores)) delete stores[k]; seq = 0; }; const addRow = (collection: string, row: Record) => { const id = row.id ?? `mock-${++seq}`; ensure(collection).set(id, { ...row, id }); return id; }; return { pbMock, clearStore, _addRow: addRow, _stores: stores }; }); // We export the row-builder through the same hoisted object so tests can // seed fixtures. Tests use `addRow(collection, row)` exposed below. const { _addRow: addRow, _stores } = pbMock as unknown as { _addRow: (c: string, r: Record) => string; _stores: Record>>; }; // Mock the pocketbase-admin module so getAdminPocketBase returns pbMock. vi.mock("./pocketbase-admin", () => ({ getAdminPocketBase: async () => pbMock, })); const fixedNow = new Date("2026-08-27T14:00:00.000Z"); beforeEach(() => { clearStore(); }); describe("getBossDashboardData", () => { it("returns orderTotals: total/delivered/invoiced for the window", async () => { // 3 orders in window: 1 delivered, 1 invoiced, 1 pending. 1 cancelled. addRow("orders", { created: "2026-08-27T08:00:00.000Z", rep: "rep-1", invoiced: false, item_status: "pending" }); addRow("orders", { created: "2026-08-27T09:00:00.000Z", rep: "rep-1", invoiced: false, item_status: "delivered" }); addRow("orders", { created: "2026-08-27T10:00:00.000Z", rep: "rep-2", invoiced: true, item_status: "invoiced" }); addRow("orders", { created: "2026-08-27T11:00:00.000Z", rep: "rep-2", invoiced: false, item_status: "cancelled" }); const data = await getBossDashboardData({ window: "today", now: fixedNow }); expect(data.window).toBe("today"); expect(data.orderTotals.total).toBe(3); // cancelled excluded expect(data.orderTotals.delivered).toBe(1); expect(data.orderTotals.invoiced).toBe(1); }); it("groups reps with quotesLoaded/doneFor/doneBy and ordersPlaced", async () => { // Users addRow("users", { id: "rep-1", name: "Gary Ndlovu" }); addRow("users", { id: "rep-2", name: "Shireen Pillay" }); // Customers with rep pointers addRow("customers", { id: "cust-a", rep: "rep-1", name: "Acme" }); addRow("customers", { id: "cust-b", rep: "rep-2", name: "Beta" }); // Quotes: rep-1 claimed 2 (one emailed = doneBy); rep-1's customer has 3 quotes. addRow("quotes", { claimed_by: "rep-1", customer: "cust-a", status: "new", created: "2026-08-27T08:00:00.000Z" }); addRow("quotes", { claimed_by: "rep-1", customer: "cust-a", status: "emailed", created: "2026-08-27T09:00:00.000Z" }); addRow("quotes", { claimed_by: "rep-2", customer: "cust-b", status: "new", created: "2026-08-27T10:00:00.000Z" }); // Orders: rep-1 placed 2. addRow("orders", { rep: "rep-1", invoiced: false, item_status: "pending", created: "2026-08-27T08:00:00.000Z" }); addRow("orders", { rep: "rep-1", invoiced: false, item_status: "pending", created: "2026-08-27T09:00:00.000Z" }); addRow("orders", { rep: "rep-2", invoiced: false, item_status: "pending", created: "2026-08-27T10:00:00.000Z" }); const data = await getBossDashboardData({ window: "today", now: fixedNow }); const byId = Object.fromEntries(data.reps.map((r) => [r.id, r])); expect(byId["rep-1"].quotesLoaded).toBe(2); expect(byId["rep-1"].quotesDoneFor).toBe(2); // both for cust-a (rep-1's) expect(byId["rep-1"].quotesDoneBy).toBe(1); // only the emailed one expect(byId["rep-1"].ordersPlaced).toBe(2); expect(byId["rep-2"].quotesLoaded).toBe(1); expect(byId["rep-2"].quotesDoneFor).toBe(1); expect(byId["rep-2"].quotesDoneBy).toBe(0); expect(byId["rep-2"].ordersPlaced).toBe(1); }); it("buckets unresolved claimed_by into a single (unknown) rep", async () => { addRow("users", { id: "rep-1", name: "Gary Ndlovu" }); addRow("customers", { id: "cust-a", rep: "rep-1", name: "Acme" }); // claimed_by is a free-text name that doesn't match any PB user id. addRow("quotes", { claimed_by: "Old Rep Name", customer: "cust-a", status: "new", created: "2026-08-27T08:00:00.000Z" }); const data = await getBossDashboardData({ window: "today", now: fixedNow }); const unknown = data.reps.find((r) => r.isUnknown); expect(unknown).toBeDefined(); expect(unknown?.name).toBe("(unknown)"); expect(unknown?.id).toBe(""); expect(unknown?.quotesLoaded).toBe(1); }); it("excludes quotes and orders outside the active window", async () => { addRow("users", { id: "rep-1", name: "Gary" }); addRow("customers", { id: "cust-a", rep: "rep-1" }); // Yesterday's quote — outside today window. addRow("quotes", { claimed_by: "rep-1", customer: "cust-a", status: "emailed", created: "2026-08-26T23:59:59.000Z" }); // Today's quote. addRow("quotes", { claimed_by: "rep-1", customer: "cust-a", status: "emailed", created: "2026-08-27T00:00:00.000Z" }); const data = await getBossDashboardData({ window: "today", now: fixedNow }); const rep = data.reps.find((r) => r.id === "rep-1"); expect(rep?.quotesLoaded).toBe(1); expect(rep?.quotesDoneBy).toBe(1); }); it("returns drivers[] with stopsToday/stopsDone/stopsLeft", async () => { // Today driver + 3 stops for them (2 done, 1 left). addRow("order_items", { drv_id: "drv-1", item_status: "delivered", drv_status: "delivered", drv_collected_at: "2026-08-27T07:00:00.000Z" }); addRow("order_items", { drv_id: "drv-1", item_status: "delivered", drv_status: "delivered", drv_collected_at: "2026-08-27T08:00:00.000Z" }); addRow("order_items", { drv_id: "drv-1", item_status: "assigned", drv_status: "assigned" }); // Future stop — should NOT count as today. addRow("order_items", { drv_id: "drv-1", item_status: "assigned", drv_status: "assigned", created: "2026-09-01T07:00:00.000Z" }); const data = await getBossDashboardData({ window: "today", now: fixedNow }); expect(data.drivers.length).toBe(1); expect(data.drivers[0].stopsToday).toBe(3); expect(data.drivers[0].stopsDone).toBe(2); expect(data.drivers[0].stopsLeft).toBe(1); }); it("returns valid shape with zero activity", async () => { const data = await getBossDashboardData({ window: "today", now: fixedNow }); expect(data.window).toBe("today"); expect(data.reps).toEqual([]); expect(data.drivers).toEqual([]); expect(data.orderTotals).toEqual({ total: 0, delivered: 0, invoiced: 0 }); }); }); ``` - [ ] **Step 2: Run the test to verify it fails** Run: `pnpm vitest run src/lib/server/boss-dashboard-service.test.ts` Expected: FAIL — `getBossDashboardData` is not exported. - [ ] **Step 3: Implement `getBossDashboardData`** Append to `src/lib/server/boss-dashboard-service.ts`: ```ts import { getAdminPocketBase } from "./pocketbase-admin"; export interface BossRepRow { id: string; name: string; isUnknown: boolean; quotesLoaded: number; quotesDoneFor: number; quotesDoneBy: number; ordersPlaced: number; } export interface BossDriverRow { id: string; name: string; stopsToday: number; stopsDone: number; stopsLeft: number; } export interface BossDashboardData { window: BossWindow; rangeStart: string; rangeEnd: string; orderTotals: { total: number; delivered: number; invoiced: number }; reps: BossRepRow[]; drivers: BossDriverRow[]; } const DONE_STATUSES = ["emailed", "approved", "sent"]; const ACTIVE_ORDER_STATUSES = [ "pending", "po_generated", "assigned", "collection_pool", "collected", "delivered", "invoiced", ]; const POCKETBASE_RECORD_ID = /^[a-z0-9]{15}$/i; export async function getBossDashboardData({ window, now = new Date(), }: { window: BossWindow; now?: Date; }): Promise { const { rangeStart, rangeEnd } = windowRange(window, now); const pb = await getAdminPocketBase(); // 1. Load users + customers in parallel — needed for rep joins. const [users, customers, orders, quotes, items] = await Promise.all([ pb.collection("users").getFullList({ fields: "id,name" }), pb.collection("customers").getFullList({ fields: "id,rep" }), pb.collection("orders").getFullList({ filter: `created >= "${rangeStart}" && created < "${rangeEnd}"`, fields: "id,rep,invoiced,item_status,created", }), pb.collection("quotes").getFullList({ filter: `created >= "${rangeStart}" && created < "${rangeEnd}"`, fields: "id,claimed_by,customer,status,created", }), pb.collection("order_items").getFullList({ filter: `created >= "${rangeStart}" && created < "${rangeEnd}"`, fields: "id,drv_id,item_status,drv_status,drv_collected_at,created", }), ]); const userById = new Map(); for (const u of users) userById.set(u.id, { id: u.id, name: String((u as Record).name ?? "") }); const customerRepById = new Map(); for (const c of customers) customerRepById.set(c.id, String((c as Record).rep ?? "")); // 2. Order totals. let total = 0, delivered = 0, invoiced = 0; const repOrders = new Map(); for (const o of orders) { const itemStatus = String((o as Record).item_status ?? ""); const isCancelled = itemStatus === "cancelled"; if (!isCancelled) total += 1; if (itemStatus === "delivered") delivered += 1; if ((o as Record).invoiced === true) invoiced += 1; const repId = resolveOrderRepId(o as Record, userById); if (repId) repOrders.set(repId, (repOrders.get(repId) ?? 0) + (isCancelled ? 0 : 1)); } // 3. Quote stats per rep. // - quotesLoaded: claimed_by resolves to rep // - quotesDoneFor: customer's rep = rep // - quotesDoneBy: claimed_by resolves to rep AND status in DONE_STATUSES const repRows = new Map(); const ensureRep = (id: string, name: string): BossRepRow => { let row = repRows.get(id); if (!row) { row = { id, name, isUnknown: id === "", quotesLoaded: 0, quotesDoneFor: 0, quotesDoneBy: 0, ordersPlaced: 0 }; repRows.set(id, row); } return row; }; // Seed every known rep so the UI always has a row, even if zero activity. for (const u of users) ensureRep(u.id, String((u as Record).name ?? "")); for (const q of quotes) { const rec = q as Record; const status = String(rec.status ?? ""); const customerRepId = customerRepById.get(String(rec.customer ?? "")) ?? ""; const claimedBy = String(rec.claimed_by ?? ""); // doneFor: customer's rep if (customerRepId && userById.has(customerRepId)) { const u = userById.get(customerRepId)!; ensureRep(u.id, u.name).quotesDoneFor += 1; } // loaded + doneBy: claimed_by resolves to a user id if (POCKETBASE_RECORD_ID.test(claimedBy) && userById.has(claimedBy)) { const u = userById.get(claimedBy)!; const row = ensureRep(u.id, u.name); row.quotesLoaded += 1; if (DONE_STATUSES.includes(status)) row.quotesDoneBy += 1; } else if (claimedBy.trim() !== "") { // Unresolved claimed_by — bucket into (unknown) const row = ensureRep("", "(unknown)"); row.quotesLoaded += 1; if (DONE_STATUSES.includes(status)) row.quotesDoneBy += 1; } } // Merge order counts into rep rows. for (const [repId, count] of repOrders) { const u = userById.get(repId); if (!u) continue; // skip orders whose rep can't be resolved const row = ensureRep(u.id, u.name); row.ordersPlaced = count; } // Sort: ordersPlaced DESC, quotesLoaded DESC, name ASC. const reps = Array.from(repRows.values()) .filter((r) => !r.isUnknown) // unknown goes last .sort((a, b) => b.ordersPlaced - a.ordersPlaced || b.quotesLoaded - a.quotesLoaded || a.name.localeCompare(b.name), ); const unknownRow = repRows.get(""); if (unknownRow && (unknownRow.quotesLoaded > 0 || unknownRow.quotesDoneBy > 0)) { reps.push(unknownRow); } // 4. Driver stops. Always "today" — drivers don't have a week/month tab. const driverStops = new Map(); for (const item of items) { const rec = item as Record; const drvId = String(rec.drv_id ?? ""); if (!drvId) continue; const drvStatus = String(rec.drv_status ?? ""); const collected = drvStatus === "collected" || drvStatus === "delivered" || drvStatus === "invoiced"; const entry = driverStops.get(drvId) ?? { id: drvId, stopsToday: 0, stopsDone: 0 }; entry.stopsToday += 1; if (collected) entry.stopsDone += 1; driverStops.set(drvId, entry); } // Resolve driver names from users (drivers are users in `users` collection). const drivers: BossDriverRow[] = Array.from(driverStops.values()).map((d) => { const u = userById.get(d.id); return { id: d.id, name: u?.name ?? "(unnamed driver)", stopsToday: d.stopsToday, stopsDone: d.stopsDone, stopsLeft: Math.max(0, d.stopsToday - d.stopsDone), }; }); drivers.sort((a, b) => b.stopsLeft - a.stopsLeft || a.name.localeCompare(b.name)); return { window, rangeStart, rangeEnd, orderTotals: { total, delivered, invoiced }, reps, drivers, }; } function resolveOrderRepId( order: Record, userById: Map, ): string { const rep = String(order.rep ?? "").trim(); if (!rep) return ""; // If the field is already a PB user id, use it directly. if (POCKETBASE_RECORD_ID.test(rep) && userById.has(rep)) return rep; // Otherwise try matching the free-text name against users. for (const u of userById.values()) { if (u.name.trim().toLowerCase() === rep.toLowerCase()) return u.id; } return ""; } ``` - [ ] **Step 4: Run the test to verify it passes** Run: `pnpm vitest run src/lib/server/boss-dashboard-service.test.ts` Expected: PASS — 6 tests green. - [ ] **Step 5: Commit** ```bash git add src/lib/server/boss-dashboard-service.ts src/lib/server/boss-dashboard-service.test.ts git commit -m "feat(boss-dashboard): aggregate per-rep stats and driver stops per window" ``` --- ### Task 3: `getBossRepActivity` pagination **Files:** - Modify: `src/lib/server/boss-dashboard-service.ts` - Modify: `src/lib/server/boss-dashboard-service.test.ts` - [ ] **Step 1: Write the failing tests** Append to the test file: ```ts import { getBossRepActivity } from "./boss-dashboard-service"; describe("getBossRepActivity", () => { it("returns paginated quotes for a rep, newest first", async () => { addRow("users", { id: "rep-1", name: "Gary" }); addRow("customers", { id: "cust-a", rep: "rep-1", name: "Acme" }); for (let i = 1; i <= 30; i++) { addRow("quotes", { quote_number: `Q-${i}`, claimed_by: "rep-1", customer: "cust-a", status: "emailed", created: new Date(Date.UTC(2026, 7, 27, 10, i)).toISOString(), }); } const page1 = await getBossRepActivity({ rep: "rep-1", window: "today", kind: "quotes", page: 1, now: fixedNow, }); expect(page1.items.length).toBe(25); expect(page1.hasMore).toBe(true); expect(page1.items[0].number).toBe("Q-30"); // newest first const page2 = await getBossRepActivity({ rep: "rep-1", window: "today", kind: "quotes", page: 2, now: fixedNow, }); expect(page2.items.length).toBe(5); expect(page2.hasMore).toBe(false); }); it("returns paginated orders for a rep, newest first", async () => { addRow("users", { id: "rep-1", name: "Gary" }); addRow("orders", { order_number: "O-1", rep: "rep-1", invoiced: false, item_status: "pending", created: "2026-08-27T08:00:00.000Z" }); addRow("orders", { order_number: "O-2", rep: "rep-1", invoiced: false, item_status: "pending", created: "2026-08-27T09:00:00.000Z" }); addRow("orders", { order_number: "O-3", rep: "rep-2", invoiced: false, item_status: "pending", created: "2026-08-27T10:00:00.000Z" }); const data = await getBossRepActivity({ rep: "rep-1", window: "today", kind: "orders", page: 1, now: fixedNow, }); expect(data.items.length).toBe(2); expect(data.items[0].number).toBe("O-2"); // newest first expect(data.items[1].number).toBe("O-1"); }); it("returns empty items when no matches", async () => { const data = await getBossRepActivity({ rep: "rep-1", window: "today", kind: "quotes", page: 1, now: fixedNow, }); expect(data.items).toEqual([]); expect(data.hasMore).toBe(false); }); }); ``` - [ ] **Step 2: Run the test to verify it fails** Run: `pnpm vitest run src/lib/server/boss-dashboard-service.test.ts` Expected: FAIL — `getBossRepActivity` is not exported. - [ ] **Step 3: Implement `getBossRepActivity`** Append to `src/lib/server/boss-dashboard-service.ts`: ```ts export type RepActivityKind = "quotes" | "orders"; export interface RepActivityItem { id: string; number: string; customer: string; status: string; ageMs: number; // relative to `now` } export interface RepActivityPage { items: RepActivityItem[]; hasMore: boolean; } const PAGE_SIZE = 25; export async function getBossRepActivity({ rep, window, kind, page = 1, now = new Date(), }: { rep: string; window: BossWindow; kind: RepActivityKind; page?: number; now?: Date; }): Promise { const { rangeStart, rangeEnd } = windowRange(window, now); const pb = await getAdminPocketBase(); if (kind === "quotes") { // Quotes owned by this rep = claimed_by = rep.id const result = await pb.collection("quotes").getList({ filter: `claimed_by = "${rep}" && created >= "${rangeStart}" && created < "${rangeEnd}"`, sort: "-created", perPage: PAGE_SIZE, page, fields: "id,quote_number,customer,status,created", expand: "customer", }); const items: RepActivityItem[] = (result.items as Array>).map((q) => { const expanded = q.expand as { customer?: { customer?: string } } | undefined; const customerName = expanded?.customer?.customer ?? ""; return { id: String(q.id), number: String(q.quote_number ?? ""), customer: customerName, status: String(q.status ?? ""), ageMs: now.getTime() - new Date(String(q.created)).getTime(), }; }); return { items, hasMore: items.length === PAGE_SIZE }; } // kind === "orders" const result = await pb.collection("orders").getList({ filter: `rep = "${rep}" && created >= "${rangeStart}" && created < "${rangeEnd}"`, sort: "-created", perPage: PAGE_SIZE, page, fields: "id,order_number,customer,item_status,created", expand: "customer", }); const items: RepActivityItem[] = (result.items as Array>).map((o) => { const expanded = o.expand as { customer?: { customer?: string } } | undefined; const customerName = expanded?.customer?.customer ?? ""; return { id: String(o.id), number: String(o.order_number ?? ""), customer: customerName, status: String(o.item_status ?? ""), ageMs: now.getTime() - new Date(String(o.created)).getTime(), }; }); return { items, hasMore: items.length === PAGE_SIZE }; } ``` - [ ] **Step 4: Run the test to verify it passes** Run: `pnpm vitest run src/lib/server/boss-dashboard-service.test.ts` Expected: PASS — 9 tests green. - [ ] **Step 5: Commit** ```bash git add src/lib/server/boss-dashboard-service.ts src/lib/server/boss-dashboard-service.test.ts git commit -m "feat(boss-dashboard): paginated rep activity feed for drill-down sheet" ``` --- ## Phase 2 — API routes ### Task 4: `/api/v1/boss-dashboard` route handler **Files:** - Create: `src/app/api/v1/boss-dashboard/route.ts` - [ ] **Step 1: Implement the route handler** Write `src/app/api/v1/boss-dashboard/route.ts`: ```ts import { NextRequest, NextResponse } from "next/server"; import { getBossDashboardData, type BossWindow } from "@/lib/server/boss-dashboard-service"; /** * GET /api/v1/boss-dashboard?window=today|week|month * * Read model for the boss's phone dashboard at `/m`. Returns a single * window's worth of aggregates (~3-5 KB). Auth via the PB browser * session — same pattern as /api/v1/dashboard. */ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; const VALID_WINDOWS: BossWindow[] = ["today", "week", "month"]; async function assertBrowserSession(req: NextRequest) { const auth = req.headers.get("authorization"); if (!auth) { throw Object.assign(new Error("Not authenticated."), { status: 401 }); } const pbUrl = process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090"; const token = auth.startsWith("Bearer ") ? auth.slice(7) : auth; const res = await fetch( `${pbUrl.replace(/\/$/, "")}/api/collections/users/auth-refresh`, { method: "POST", headers: { Authorization: token }, signal: AbortSignal.timeout(5000), }, ); if (!res.ok) { throw Object.assign(new Error("Invalid or expired session."), { status: 401, }); } return res.json(); } export async function GET(req: NextRequest) { try { const user = await assertBrowserSession(req); const url = new URL(req.url); const raw = url.searchParams.get("window") ?? "today"; if (!VALID_WINDOWS.includes(raw as BossWindow)) { return NextResponse.json( { error: `Invalid window: ${raw}` }, { status: 400 }, ); } const data = await getBossDashboardData({ window: raw as BossWindow }); return NextResponse.json({ ...data, viewerName: user?.record?.name ?? null }); } catch (error) { const message = error instanceof Error ? error.message : "Failed to load dashboard"; const status = (error as Error & { status?: number }).status || 500; return NextResponse.json({ error: message }, { status }); } } ``` - [ ] **Step 2: Type-check** Run: `pnpm tsc --noEmit` Expected: zero new errors. - [ ] **Step 3: Commit** ```bash git add src/app/api/v1/boss-dashboard/route.ts git commit -m "feat(boss-dashboard): add /api/v1/boss-dashboard route handler" ``` --- ### Task 5: `/api/v1/boss-dashboard/rep-activity` route handler **Files:** - Create: `src/app/api/v1/boss-dashboard/rep-activity/route.ts` - [ ] **Step 1: Implement the route handler** Write `src/app/api/v1/boss-dashboard/rep-activity/route.ts`: ```ts import { NextRequest, NextResponse } from "next/server"; import { getBossRepActivity, type BossWindow, type RepActivityKind, } from "@/lib/server/boss-dashboard-service"; /** * GET /api/v1/boss-dashboard/rep-activity?rep=&window=&kind=quotes|orders&page= * * Paginated list of records behind a rep's per-window counts. Drives the * bottom sheet that opens when the boss taps a rep row on /m. */ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; const VALID_WINDOWS: BossWindow[] = ["today", "week", "month"]; const VALID_KINDS: RepActivityKind[] = ["quotes", "orders"]; async function assertBrowserSession(req: NextRequest) { const auth = req.headers.get("authorization"); if (!auth) { throw Object.assign(new Error("Not authenticated."), { status: 401 }); } const pbUrl = process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090"; const token = auth.startsWith("Bearer ") ? auth.slice(7) : auth; const res = await fetch( `${pbUrl.replace(/\/$/, "")}/api/collections/users/auth-refresh`, { method: "POST", headers: { Authorization: token }, signal: AbortSignal.timeout(5000), }, ); if (!res.ok) { throw Object.assign(new Error("Invalid or expired session."), { status: 401, }); } return res.json(); } export async function GET(req: NextRequest) { try { await assertBrowserSession(req); const url = new URL(req.url); const rep = url.searchParams.get("rep") ?? ""; const window = url.searchParams.get("window") ?? "today"; const kind = url.searchParams.get("kind") ?? "quotes"; const pageParam = url.searchParams.get("page") ?? "1"; if (!rep) { return NextResponse.json({ error: "Missing rep" }, { status: 400 }); } if (!VALID_WINDOWS.includes(window as BossWindow)) { return NextResponse.json({ error: `Invalid window` }, { status: 400 }); } if (!VALID_KINDS.includes(kind as RepActivityKind)) { return NextResponse.json({ error: `Invalid kind` }, { status: 400 }); } const page = Math.max(1, parseInt(pageParam, 10) || 1); const data = await getBossRepActivity({ rep, window: window as BossWindow, kind: kind as RepActivityKind, page, }); return NextResponse.json(data); } catch (error) { const message = error instanceof Error ? error.message : "Failed to load rep activity"; const status = (error as Error & { status?: number }).status || 500; return NextResponse.json({ error: message }, { status }); } } ``` - [ ] **Step 2: Type-check** Run: `pnpm tsc --noEmit` Expected: zero new errors. - [ ] **Step 3: Commit** ```bash git add src/app/api/v1/boss-dashboard/rep-activity/route.ts git commit -m "feat(boss-dashboard): add /api/v1/boss-dashboard/rep-activity route handler" ``` --- ### Task 6: Route tests **Files:** - Create: `src/app/api/v1/boss-dashboard/route.test.ts` - [ ] **Step 1: Write the failing tests** Write `src/app/api/v1/boss-dashboard/route.test.ts`: ```ts import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; const { serviceMock } = vi.hoisted(() => ({ serviceMock: { getBossDashboardData: vi.fn(), getBossRepActivity: vi.fn(), }, })); vi.mock("@/lib/server/boss-dashboard-service", () => serviceMock); const validToken = "Bearer pb-token"; const authReq = (url: string) => new NextRequest(url, { headers: { authorization: validToken } }); beforeEach(() => { vi.clearAllMocks(); // Stub the auth-refresh call that assertBrowserSession makes. global.fetch = vi.fn(async () => new Response(JSON.stringify({ record: { id: "u1", name: "Dave" } }), { status: 200, headers: { "content-type": "application/json" }, }), ) as unknown as typeof fetch; }); describe("GET /api/v1/boss-dashboard", () => { it("401 when no Authorization header", async () => { const req = new NextRequest("http://localhost/api/v1/boss-dashboard"); const { GET } = await import("./route"); const res = await GET(req); expect(res.status).toBe(401); }); it("400 on invalid window", async () => { const { GET } = await import("./route"); const res = await GET(authReq("http://localhost/api/v1/boss-dashboard?window=bogus")); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toContain("Invalid window"); }); it("200 with payload on default window=today", async () => { serviceMock.getBossDashboardData.mockResolvedValueOnce({ window: "today", rangeStart: "2026-08-27T00:00:00.000Z", rangeEnd: "2026-08-28T00:00:00.000Z", orderTotals: { total: 0, delivered: 0, invoiced: 0 }, reps: [], drivers: [], }); const { GET } = await import("./route"); const res = await GET(authReq("http://localhost/api/v1/boss-dashboard")); expect(res.status).toBe(200); const body = await res.json(); expect(body.window).toBe("today"); expect(body.viewerName).toBe("Dave"); expect(serviceMock.getBossDashboardData).toHaveBeenCalledWith({ window: "today" }); }); }); describe("GET /api/v1/boss-dashboard/rep-activity", () => { it("400 when rep is missing", async () => { const { GET } = await import("./rep-activity/route"); const res = await GET(authReq("http://localhost/api/v1/boss-dashboard/rep-activity?window=today")); expect(res.status).toBe(400); }); it("200 with paginated payload", async () => { serviceMock.getBossRepActivity.mockResolvedValueOnce({ items: [ { id: "q1", number: "Q-001", customer: "Acme", status: "emailed", ageMs: 0 }, ], hasMore: false, }); const { GET } = await import("./rep-activity/route"); const res = await GET( authReq("http://localhost/api/v1/boss-dashboard/rep-activity?rep=rep-1&window=today&kind=quotes&page=1"), ); expect(res.status).toBe(200); const body = await res.json(); expect(body.items.length).toBe(1); expect(serviceMock.getBossRepActivity).toHaveBeenCalledWith({ rep: "rep-1", window: "today", kind: "quotes", page: 1, }); }); }); ``` - [ ] **Step 2: Run the test to verify it passes** Run: `pnpm vitest run src/app/api/v1/boss-dashboard/route.test.ts` Expected: PASS — 5 tests green. - [ ] **Step 3: Commit** ```bash git add src/app/api/v1/boss-dashboard/route.test.ts git commit -m "test(boss-dashboard): auth gate, query parsing, payload shape" ``` --- ## Phase 3 — PWA manifest + layout shell ### Task 7: Mobile manifest **Files:** - Create: `public/manifest-mobile.json` - [ ] **Step 1: Write the manifest** ```json { "name": "WFDASH — Boss", "short_name": "Boss", "description": "Phone dashboard for the boss.", "start_url": "/m", "display": "standalone", "background_color": "#0b1020", "theme_color": "#0b1020", "orientation": "portrait", "icons": [ { "src": "/icon-mobile-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icon-mobile-512.png", "sizes": "512x512", "type": "image/png" } ] } ``` Note: The icon files don't exist yet — Task 8 generates placeholders or skips the icons entirely until the operator supplies real ones. The manifest references them so adding icons later requires no schema change. - [ ] **Step 2: Commit** ```bash git add public/manifest-mobile.json git commit -m "feat(boss-dashboard): add mobile PWA manifest" ``` --- ### Task 8: `(mobile)/m/layout.tsx` **Files:** - Create: `src/app/(mobile)/m/layout.tsx` - [ ] **Step 1: Implement the layout** Write `src/app/(mobile)/m/layout.tsx`: ```tsx import type { Metadata, Viewport } from "next"; export const metadata: Metadata = { title: "WFDASH — Boss", description: "Phone dashboard for the boss.", manifest: "/manifest-mobile.json", }; export const viewport: Viewport = { width: "device-width", initialScale: 1, viewportFit: "cover", themeColor: "#0b1020", }; export default function MobileLayout({ children, }: { children: React.ReactNode; }) { return (
{children}
); } ``` - [ ] **Step 2: Type-check** Run: `pnpm tsc --noEmit` Expected: zero new errors. - [ ] **Step 3: Commit** ```bash git add src/app/(mobile)/m/layout.tsx git commit -m "feat(boss-dashboard): add (mobile)/m phone-first layout shell" ``` --- ## Phase 4 — Page chrome (header, tabs, order totals) ### Task 9: `page.tsx` and `dashboard-view.tsx` skeleton **Files:** - Create: `src/app/(mobile)/m/page.tsx` - Create: `src/app/(mobile)/m/dashboard-view.tsx` - [ ] **Step 1: Write `page.tsx`** ```tsx "use client"; import BossDashboardView from "./dashboard-view"; export default function BossDashboardPage() { return ; } ``` - [ ] **Step 2: Write the dashboard-view skeleton** Write `src/app/(mobile)/m/dashboard-view.tsx`: ```tsx "use client"; import { useState, useEffect, useCallback, useRef } from "react"; import { toast } from "sonner"; import { RefreshCw } from "lucide-react"; import getPocketBase from "@/lib/pocketbase"; import type { BossDashboardData, BossWindow, } from "@/lib/server/boss-dashboard-service"; const REALTIME_DEBOUNCE_MS = 1_000; export default function BossDashboardView() { const [data, setData] = useState(null); const [viewerName, setViewerName] = useState(null); const [window, setWindow] = useState("today"); const [loading, setLoading] = useState(true); const [lastRefreshed, setLastRefreshed] = useState(null); const fetchData = useCallback(async () => { try { const pb = getPocketBase(); const pbToken = pb.authStore?.token; const headers: Record = {}; if (pbToken) headers.Authorization = pbToken; const res = await fetch(`/api/v1/boss-dashboard?window=${window}`, { headers, cache: "no-store", }); if (res.status === 401) { window.location.href = "/login"; return; } if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: string }; throw new Error(body.error ?? `HTTP ${res.status}`); } const next = (await res.json()) as BossDashboardData & { viewerName?: string }; setData({ window: next.window, rangeStart: next.rangeStart, rangeEnd: next.rangeEnd, orderTotals: next.orderTotals, reps: next.reps, drivers: next.drivers, }); setViewerName(next.viewerName ?? null); setLastRefreshed(new Date()); } catch (err) { const message = err instanceof Error ? err.message : "Failed to load"; toast.error(message); } finally { setLoading(false); } }, [window]); useEffect(() => { void fetchData(); }, [fetchData]); // Realtime subscription (added in Task 14). const firstName = (viewerName ?? "").split(" ")[0] || "there"; return (
{/* Header strip */}

Good {greeting()}, {firstName}.

{lastRefreshed ? `Updated ${timeAgo(lastRefreshed)}` : "Loading…"}

{/* Placeholder for tabs (Task 10) */}
Tabs land here in Task 10.
{/* Placeholder for order totals (Task 11) */}
Order totals land here in Task 11.
{/* Placeholder for reps (Task 12) */}
Reps list lands here in Task 12.
{/* Placeholder for drivers (Task 13) */}
Driver stops land here in Task 13.
); } function greeting(): string { const h = new Date().getHours(); if (h < 12) return "morning"; if (h < 17) return "afternoon"; return "evening"; } function timeAgo(date: Date): string { const diff = Math.max(0, Date.now() - date.getTime()); const sec = Math.floor(diff / 1000); if (sec < 5) return "just now"; if (sec < 60) return `${sec}s ago`; const min = Math.floor(sec / 60); if (min < 60) return `${min}m ago`; const hr = Math.floor(min / 60); if (hr < 24) return `${hr}h ago`; return date.toLocaleDateString("en-ZA", { day: "numeric", month: "short" }); } ``` - [ ] **Step 3: Verify the page mounts** Run: `pnpm dev` (background) then `curl -s http://localhost:4001/m | head -c 200` Expected: HTML response (no SSR data, since this is "use client"). Visit in a browser to confirm the "Good ..." greeting renders after the fetch resolves. - [ ] **Step 4: Type-check** Run: `pnpm tsc --noEmit` Expected: zero new errors. - [ ] **Step 5: Commit** ```bash git add src/app/(mobile)/m/page.tsx src/app/(mobile)/m/dashboard-view.tsx git commit -m "feat(boss-dashboard): add page chrome with header and refresh" ``` --- ### Task 10: Time tab bar **Files:** - Modify: `src/app/(mobile)/m/dashboard-view.tsx` - [ ] **Step 1: Replace the tabs placeholder with the segmented control** In `dashboard-view.tsx`, replace the `
Tabs land here in Task 10.
` block with: ```tsx {/* Time tab bar */}
{(["today", "week", "month"] as const).map((w) => ( ))}
``` - [ ] **Step 2: Verify** Open `http://localhost:4001/m` in a browser. Tap each tab — confirm `data` reloads (visible "Loading…" → "Updated 0s ago" cycle in the header). - [ ] **Step 3: Commit** ```bash git add src/app/(mobile)/m/dashboard-view.tsx git commit -m "feat(boss-dashboard): time tab bar with segmented control" ``` --- ### Task 11: Order totals strip **Files:** - Modify: `src/app/(mobile)/m/dashboard-view.tsx` - [ ] **Step 1: Replace the order-totals placeholder** Replace the `Order totals land here in Task 11.` block with: ```tsx {/* Order totals */} {data && (
)} ``` And add this helper at the bottom of the file (outside the default export): ```tsx function OrderTotal({ label, value, color, }: { label: string; value: number; color: string; }) { return (
{label}
{value.toLocaleString()}
); } ``` - [ ] **Step 2: Verify** Open `http://localhost:4001/m`. Confirm three tiles render with the totals from your live data. - [ ] **Step 3: Commit** ```bash git add src/app/(mobile)/m/dashboard-view.tsx git commit -m "feat(boss-dashboard): order totals strip (total / delivered / invoiced)" ``` --- ## Phase 5 — Reps and drivers sections ### Task 12: Reps list **Files:** - Modify: `src/app/(mobile)/m/dashboard-view.tsx` - [ ] **Step 1: Replace the reps placeholder** Replace the `Reps list lands here in Task 12.` block with: ```tsx {/* Reps section */}

Sales reps

{data && ( {data.reps.length} )}
{data && data.reps.length === 0 ? (
No rep activity in this window.
) : (
    {data?.reps.map((rep) => (
  • ))}
)}
``` And add these helpers at the bottom of the file: ```tsx function Metric({ label, value, color, }: { label: string; value: number; color: string; }) { return (
{value.toLocaleString()}
{label}
); } function openSheet(_rep: BossDashboardData["reps"][number]) { // Wired in Task 14. } ``` - [ ] **Step 2: Verify** Open `http://localhost:4001/m`. Confirm one card per rep, 4 metrics each, progress bar showing share of total orders. - [ ] **Step 3: Commit** ```bash git add src/app/(mobile)/m/dashboard-view.tsx git commit -m "feat(boss-dashboard): per-rep cards with 4 metrics and progress bar" ``` --- ### Task 13: Drivers section **Files:** - Modify: `src/app/(mobile)/m/dashboard-view.tsx` - [ ] **Step 1: Replace the drivers placeholder** Replace the `Driver stops land here in Task 13.` block with: ```tsx {/* Drivers section */}

Driver stops today

{data && ( {data.drivers.reduce((s, d) => s + d.stopsToday, 0)} )}
{data && data.drivers.length === 0 ? (
No driver stops scheduled today.
) : (
    {data?.drivers.map((drv) => { const allDone = drv.stopsLeft === 0; const noneStarted = drv.stopsDone === 0 && drv.stopsLeft > 0; const badgeColor = allDone ? "bg-emerald-500/15 text-emerald-400" : noneStarted ? "bg-rose-500/15 text-rose-400" : "bg-amber-500/15 text-amber-400"; const badgeLabel = allDone ? "All done" : noneStarted ? `${drv.stopsLeft} left` : `${drv.stopsLeft} left`; return (
  • {(drv.name[0] ?? "?").toUpperCase()}
    {drv.name}
    {drv.stopsToday} stops · {drv.stopsDone} done
    {badgeLabel}
  • ); })}
)}
``` - [ ] **Step 2: Verify** Open `http://localhost:4001/m`. Confirm driver cards render with the urgency-colored badge. - [ ] **Step 3: Commit** ```bash git add src/app/(mobile)/m/dashboard-view.tsx git commit -m "feat(boss-dashboard): driver stops section with urgency-colored badges" ``` --- ## Phase 6 — Drill-down bottom sheet ### Task 14: Rep-activity bottom sheet **Files:** - Modify: `src/app/(mobile)/m/dashboard-view.tsx` - [ ] **Step 1: Add sheet state, fetch logic, and `` render** In `dashboard-view.tsx`: - Add these state variables at the top of the component (next to the existing `useState` declarations): ```tsx const [sheetRep, setSheetRep] = useState(null); const [sheetKind, setSheetKind] = useState<"quotes" | "orders">("quotes"); const [sheetItems, setSheetItems] = useState< Array<{ id: string; number: string; customer: string; status: string; ageMs: number }> >([]); const [sheetPage, setSheetPage] = useState(1); const [sheetHasMore, setSheetHasMore] = useState(false); const [sheetLoading, setSheetLoading] = useState(false); const dialogRef = useRef(null); ``` - Replace the placeholder `function openSheet` body with: ```tsx async function openSheet(rep: BossDashboardData["reps"][number]) { if (rep.isUnknown) return; setSheetRep(rep); setSheetKind("quotes"); setSheetPage(1); setSheetItems([]); dialogRef.current?.showModal(); await loadSheetPage(rep.id, "quotes", 1, true); } async function loadSheetPage( repId: string, kind: "quotes" | "orders", page: number, replace: boolean, ) { setSheetLoading(true); try { const pb = getPocketBase(); const headers: Record = {}; if (pb.authStore?.token) headers.Authorization = pb.authStore.token; const res = await fetch( `/api/v1/boss-dashboard/rep-activity?rep=${encodeURIComponent(repId)}&window=${window}&kind=${kind}&page=${page}`, { headers, cache: "no-store" }, ); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = (await res.json()) as { items: typeof sheetItems; hasMore: boolean; }; setSheetItems(replace ? data.items : [...sheetItems, ...data.items]); setSheetHasMore(data.hasMore); } catch (err) { toast.error(err instanceof Error ? err.message : "Failed to load"); } finally { setSheetLoading(false); } } function closeSheet() { dialogRef.current?.close(); setSheetRep(null); setSheetItems([]); } ``` - Add the `` to the rendered output. Insert this **after the closing `` of the outer `mx-auto max-w-md px-4 py-4 space-y-4` wrapper** (so the dialog is a sibling of the page content): ```tsx setSheetRep(null)} > {sheetRep && (
{sheetRep.name}
{sheetRep.quotesLoaded} loaded · {sheetRep.quotesDoneBy} done · {sheetRep.ordersPlaced} orders
{/* Tab bar */}
{(["quotes", "orders"] as const).map((k) => ( ))}
{/* List */}
    {sheetItems.map((item) => (
  • ))}
{sheetHasMore && ( )} {sheetItems.length === 0 && !sheetLoading && (
No {sheetKind} in this window.
)}
)}
``` - Add the helper at the bottom of the file: ```tsx function formatAge(ms: number): string { const sec = Math.floor(ms / 1000); if (sec < 60) return `${sec}s`; const min = Math.floor(sec / 60); if (min < 60) return `${min}m ago`; const hr = Math.floor(min / 60); if (hr < 24) return `${hr}h ago`; const days = Math.floor(hr / 24); return `${days}d ago`; } ``` - [ ] **Step 2: Verify** Open `http://localhost:4001/m`. Tap a rep row — confirm the bottom sheet slides up with two tabs (Quotes / Orders). Switch tabs — confirm the list reloads. Tap a row — confirm `/quotes/` opens in a new tab. - [ ] **Step 3: Commit** ```bash git add src/app/(mobile)/m/dashboard-view.tsx git commit -m "feat(boss-dashboard): rep-activity bottom sheet with quotes/orders tabs" ``` --- ## Phase 7 — Realtime + polish ### Task 15: PB realtime subscription **Files:** - Modify: `src/app/(mobile)/m/dashboard-view.tsx` - [ ] **Step 1: Add the subscription effect** In `dashboard-view.tsx`, **after the existing `useEffect(() => { void fetchData(); }, [fetchData]);`** block, insert: ```tsx useEffect(() => { const pb = getPocketBase(); const debounceRef = { handle: 0 as number }; const trigger = () => { if (debounceRef.handle) window.clearTimeout(debounceRef.handle); debounceRef.handle = window.setTimeout(() => { void fetchData(); }, REALTIME_DEBOUNCE_MS); }; const subscribe = async () => { await pb.collection("customers").subscribe("*", trigger); await pb.collection("quotes").subscribe("*", trigger); await pb.collection("orders").subscribe("*", trigger); }; void subscribe(); return () => { if (debounceRef.handle) window.clearTimeout(debounceRef.handle); void pb.collection("customers").unsubscribe("*"); void pb.collection("quotes").unsubscribe("*"); void pb.collection("orders").unsubscribe("*"); }; }, [fetchData]); ``` Also remove the comment "// Realtime subscription (added in Task 14)." that you wrote in Task 9. - [ ] **Step 2: Verify** Open `http://localhost:4001/m` in one browser tab. In a second tab, create a new quote or order in the operator dashboard. Within ~1 second, the boss's `/m` page should re-fetch and the numbers should update. - [ ] **Step 3: Commit** ```bash git add src/app/(mobile)/m/dashboard-view.tsx git commit -m "feat(boss-dashboard): PB realtime subscription with 1s debounce" ``` --- ### Task 15b: Final verification - [ ] **Step 1: Run the full test suite** Run: `pnpm vitest run` Expected: All tests pass, including the 9 new boss-dashboard tests and 5 route tests. - [ ] **Step 2: Type-check** Run: `pnpm tsc --noEmit` Expected: Zero errors. - [ ] **Step 3: Lint** Run: `pnpm lint` Expected: Zero new lint errors. - [ ] **Step 4: Manual smoke test** Open `http://localhost:4001/m` in a phone-sized browser window (DevTools → toggle device toolbar → iPhone 12). Walk through the 10-step verification list in the spec (`docs/superpowers/specs/2026-08-27-boss-phone-dashboard-design.md`, "How to verify after implementation"). - [ ] **Step 5: Final commit** ```bash git add -A git status # review what's changed git commit -m "chore(boss-dashboard): post-implementation cleanup" --allow-empty ``` Only commit if there are real changes; otherwise skip this step. --- ## Self-Review Checklist The plan was self-reviewed for: - **Spec coverage:** every section in `docs/superpowers/specs/2026-08-27-boss-phone-dashboard-design.md` maps to a task: - Window boundaries → Task 1 - Per-rep aggregations → Task 2 - Unknown bucketing → Task 2 (test) - Rep-activity pagination → Task 3 - Auth via `assertBrowserSession` → Tasks 4, 5, 6 - PWA manifest → Task 7 - Mobile layout → Task 8 - First paint → Task 9 (page renders shell immediately) - Header strip → Task 9 - Time tab bar → Task 10 - Order totals → Task 11 - Reps section → Task 12 - Drivers section → Task 13 - Bottom sheet drill-down → Task 14 - Realtime debounce → Task 15 - Empty / error / loading states → Tasks 9-13 (built-in), Task 14 (sheet), Task 15b (final pass) - Tests for service → Tasks 1, 2, 3 - Tests for routes → Task 6 - Verify checklist → Task 15b - **Placeholder scan:** no "TBD", "TODO", "implement later", "similar to" hand-waves. Every step that touches code shows the full code block. - **Type consistency:** `BossWindow` defined in Task 1, used in Tasks 2, 3, 4, 5. `BossDashboardData` defined in Task 2, used in Tasks 9-15. `RepActivityKind` defined in Task 3, used in Tasks 5, 14. `BossRepRow`, `BossDriverRow` defined in Task 2, used in Tasks 9-15. - **Pattern consistency:** mirrors the existing `/api/v1/dashboard` route (local `assertBrowserSession`, `force-dynamic`, `runtime = "nodejs"`), the existing `/dashboard` page (PB realtime + 1s debounce + bearer token forward), the existing dashboard service tests (in-memory PB mock with `vi.hoisted`). - **No big-bang rewrite:** no file outside the new `(mobile)/` route group, the new service, and the new API routes is modified.