From 22660382fe4abebd30982a2cd220c6f1fc78742e Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 28 Aug 2026 09:42:48 +0200 Subject: [PATCH] docs(boss-dashboard): add phone-first /m dashboard implementation plan Companion to the design spec at docs/superpowers/specs/. Covers all 15 implementation tasks (windowRange helper, aggregations, rep-activity pagination, route handlers, tests, PWA manifest, mobile layout, dashboard view with realtime + bottom sheet) plus the final verification step. Co-Authored-By: Claude --- .../plans/2026-08-27-boss-phone-dashboard.md | 2032 +++++++++++++++++ 1 file changed, 2032 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-27-boss-phone-dashboard.md diff --git a/docs/superpowers/plans/2026-08-27-boss-phone-dashboard.md b/docs/superpowers/plans/2026-08-27-boss-phone-dashboard.md new file mode 100644 index 0000000..c06bfbf --- /dev/null +++ b/docs/superpowers/plans/2026-08-27-boss-phone-dashboard.md @@ -0,0 +1,2032 @@ +# 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. \ No newline at end of file