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 <noreply@anthropic.com>
66 KiB
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 <dialog> bottom sheet for drill-downs.
Tech Stack: Next.js (App Router), TypeScript, PocketBase admin client, Vitest, Tailwind, sonner toasts, lucide-react icons, native <dialog> 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.tsxis"use client"and renders the view with no SSR data.- The view fetches
/api/v1/boss-dashboardon mount, forwarding the PB token frompb.authStoreas aAuthorizationheader. - 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=<id>&window=<window>&kind=quotes|orders&page=<n>. 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 — <main> wrapper + <link rel="manifest">. |
src/app/(mobile)/m/page.tsx |
"use client" — renders <BossDashboardView />. |
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:
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:
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
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:
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<string, unknown> & { id: string };
type Store = Map<string, Row>;
const stores: Record<string, Store> = {};
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<string, unknown>) => {
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, unknown>) => string;
_stores: Record<string, Map<string, Record<string, unknown>>>;
};
// 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:
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<BossDashboardData> {
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<string, { id: string; name: string }>();
for (const u of users) userById.set(u.id, { id: u.id, name: String((u as Record<string, unknown>).name ?? "") });
const customerRepById = new Map<string, string>();
for (const c of customers) customerRepById.set(c.id, String((c as Record<string, unknown>).rep ?? ""));
// 2. Order totals.
let total = 0, delivered = 0, invoiced = 0;
const repOrders = new Map<string, number>();
for (const o of orders) {
const itemStatus = String((o as Record<string, unknown>).item_status ?? "");
const isCancelled = itemStatus === "cancelled";
if (!isCancelled) total += 1;
if (itemStatus === "delivered") delivered += 1;
if ((o as Record<string, unknown>).invoiced === true) invoiced += 1;
const repId = resolveOrderRepId(o as Record<string, unknown>, 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<string, BossRepRow>();
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<string, unknown>).name ?? ""));
for (const q of quotes) {
const rec = q as Record<string, unknown>;
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<string, { id: string; stopsToday: number; stopsDone: number }>();
for (const item of items) {
const rec = item as Record<string, unknown>;
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<string, unknown>,
userById: Map<string, { id: string; name: string }>,
): 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
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:
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:
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<RepActivityPage> {
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<Record<string, unknown>>).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<Record<string, unknown>>).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
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:
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
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:
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=<id>&window=<w>&kind=quotes|orders&page=<n>
*
* 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
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:
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
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
{
"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
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:
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 (
<main className="min-h-screen bg-background text-foreground antialiased">
{children}
</main>
);
}
- Step 2: Type-check
Run: pnpm tsc --noEmit
Expected: zero new errors.
- Step 3: Commit
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
"use client";
import BossDashboardView from "./dashboard-view";
export default function BossDashboardPage() {
return <BossDashboardView />;
}
- Step 2: Write the dashboard-view skeleton
Write src/app/(mobile)/m/dashboard-view.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<BossDashboardData | null>(null);
const [viewerName, setViewerName] = useState<string | null>(null);
const [window, setWindow] = useState<BossWindow>("today");
const [loading, setLoading] = useState(true);
const [lastRefreshed, setLastRefreshed] = useState<Date | null>(null);
const fetchData = useCallback(async () => {
try {
const pb = getPocketBase();
const pbToken = pb.authStore?.token;
const headers: Record<string, string> = {};
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 (
<div className="mx-auto max-w-md px-4 py-4 space-y-4">
{/* Header strip */}
<header className="flex items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Good {greeting()}, <span className="text-cyan-400">{firstName}</span>.
</h1>
<p className="text-xs text-muted-foreground mt-0.5">
{lastRefreshed
? `Updated ${timeAgo(lastRefreshed)}`
: "Loading…"}
</p>
</div>
<button
type="button"
onClick={() => void fetchData()}
disabled={loading}
aria-label="Refresh"
className="grid h-9 w-9 place-items-center rounded-full border border-cyan-500/30 text-cyan-400 disabled:opacity-50"
>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</button>
</header>
{/* Placeholder for tabs (Task 10) */}
<div className="rounded-xl bg-muted/30 p-3 text-xs text-muted-foreground">
Tabs land here in Task 10.
</div>
{/* Placeholder for order totals (Task 11) */}
<div className="rounded-xl bg-muted/30 p-3 text-xs text-muted-foreground">
Order totals land here in Task 11.
</div>
{/* Placeholder for reps (Task 12) */}
<div className="rounded-xl bg-muted/30 p-3 text-xs text-muted-foreground">
Reps list lands here in Task 12.
</div>
{/* Placeholder for drivers (Task 13) */}
<div className="rounded-xl bg-muted/30 p-3 text-xs text-muted-foreground">
Driver stops land here in Task 13.
</div>
</div>
);
}
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
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 <div className="rounded-xl bg-muted/30 p-3 text-xs text-muted-foreground">Tabs land here in Task 10.</div> block with:
{/* Time tab bar */}
<div className="flex gap-1 rounded-xl bg-muted/30 p-1" role="tablist">
{(["today", "week", "month"] as const).map((w) => (
<button
key={w}
type="button"
role="tab"
aria-selected={window === w}
onClick={() => setWindow(w)}
className={`flex-1 rounded-lg px-3 py-2 text-sm font-medium capitalize transition-colors ${
window === w
? "bg-cyan-500/15 text-cyan-300"
: "text-muted-foreground hover:text-foreground"
}`}
>
{w}
</button>
))}
</div>
- 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
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:
{/* Order totals */}
{data && (
<div className="grid grid-cols-3 gap-2">
<OrderTotal label="Total" value={data.orderTotals.total} color="text-foreground" />
<OrderTotal label="Delivered" value={data.orderTotals.delivered} color="text-orange-400" />
<OrderTotal label="Invoiced" value={data.orderTotals.invoiced} color="text-emerald-400" />
</div>
)}
And add this helper at the bottom of the file (outside the default export):
function OrderTotal({
label,
value,
color,
}: {
label: string;
value: number;
color: string;
}) {
return (
<div className="rounded-xl bg-card border border-border/60 p-3">
<div className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{label}
</div>
<div className={`mt-1 text-xl font-semibold tabular-nums ${color}`}>
{value.toLocaleString()}
</div>
</div>
);
}
- Step 2: Verify
Open http://localhost:4001/m. Confirm three tiles render with the totals from your live data.
- Step 3: Commit
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:
{/* Reps section */}
<section>
<div className="flex items-center justify-between mb-2">
<h2 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
Sales reps
</h2>
{data && (
<span className="text-xs text-muted-foreground">
{data.reps.length}
</span>
)}
</div>
{data && data.reps.length === 0 ? (
<div className="rounded-xl border border-border/60 bg-card/50 p-6 text-center text-sm text-muted-foreground">
No rep activity in this window.
</div>
) : (
<ul className="space-y-2">
{data?.reps.map((rep) => (
<li key={rep.id || "unknown"}>
<button
type="button"
disabled={rep.isUnknown}
onClick={() => openSheet(rep)}
className="w-full rounded-xl border border-border/60 bg-card p-3 text-left transition-colors hover:bg-card/70 disabled:opacity-50 disabled:cursor-not-allowed"
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<div className="grid h-8 w-8 place-items-center rounded-full bg-cyan-500/15 text-cyan-300 text-xs font-semibold shrink-0">
{(rep.name[0] ?? "?").toUpperCase()}
</div>
<div className="min-w-0">
<div className="text-sm font-medium truncate">{rep.name}</div>
</div>
</div>
<div className="grid grid-cols-4 gap-2 text-center shrink-0">
<Metric label="loaded" value={rep.quotesLoaded} color="text-cyan-400" />
<Metric label="for" value={rep.quotesDoneFor} color="text-violet-400" />
<Metric label="done" value={rep.quotesDoneBy} color="text-emerald-400" />
<Metric label="orders" value={rep.ordersPlaced} color="text-orange-400" />
</div>
</div>
{data.orderTotals.total > 0 && (
<div className="mt-2 h-1 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-orange-400"
style={{
width: `${Math.min(100, Math.round((rep.ordersPlaced / data.orderTotals.total) * 100))}%`,
}}
/>
</div>
)}
</button>
</li>
))}
</ul>
)}
</section>
And add these helpers at the bottom of the file:
function Metric({
label,
value,
color,
}: {
label: string;
value: number;
color: string;
}) {
return (
<div>
<div className={`text-sm font-semibold tabular-nums ${color}`}>
{value.toLocaleString()}
</div>
<div className="text-[9px] uppercase tracking-wider text-muted-foreground">
{label}
</div>
</div>
);
}
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
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:
{/* Drivers section */}
<section>
<div className="flex items-center justify-between mb-2">
<h2 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
Driver stops today
</h2>
{data && (
<span className="text-xs text-muted-foreground">
{data.drivers.reduce((s, d) => s + d.stopsToday, 0)}
</span>
)}
</div>
{data && data.drivers.length === 0 ? (
<div className="rounded-xl border border-border/60 bg-card/50 p-6 text-center text-sm text-muted-foreground">
No driver stops scheduled today.
</div>
) : (
<ul className="space-y-2">
{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 (
<li
key={drv.id}
className="flex items-center justify-between gap-3 rounded-xl border border-border/60 bg-card p-3"
>
<div className="flex items-center gap-2 min-w-0">
<div className="grid h-8 w-8 place-items-center rounded-full bg-cyan-500/15 text-cyan-300 text-xs font-semibold shrink-0">
{(drv.name[0] ?? "?").toUpperCase()}
</div>
<div className="min-w-0">
<div className="text-sm font-medium truncate">{drv.name}</div>
<div className="text-[11px] text-muted-foreground">
{drv.stopsToday} stops · {drv.stopsDone} done
</div>
</div>
</div>
<span className={`rounded-full px-2.5 py-1 text-[11px] font-medium ${badgeColor}`}>
{badgeLabel}
</span>
</li>
);
})}
</ul>
)}
</section>
- Step 2: Verify
Open http://localhost:4001/m. Confirm driver cards render with the urgency-colored badge.
- Step 3: Commit
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
<dialog>render
In dashboard-view.tsx:
- Add these state variables at the top of the component (next to the existing
useStatedeclarations):
const [sheetRep, setSheetRep] = useState<BossDashboardData["reps"][number] | null>(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<HTMLDialogElement>(null);
- Replace the placeholder
function openSheetbody with:
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<string, string> = {};
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
<dialog>to the rendered output. Insert this after the closing</div>of the outermx-auto max-w-md px-4 py-4 space-y-4wrapper (so the dialog is a sibling of the page content):
<dialog
ref={dialogRef}
className="fixed inset-x-0 bottom-0 w-full max-w-md mx-auto rounded-t-2xl bg-card border border-border/60 p-0 backdrop:bg-black/60"
onClose={() => setSheetRep(null)}
>
{sheetRep && (
<div className="p-4 space-y-3">
<header className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold">{sheetRep.name}</div>
<div className="text-xs text-muted-foreground">
{sheetRep.quotesLoaded} loaded · {sheetRep.quotesDoneBy} done
· {sheetRep.ordersPlaced} orders
</div>
</div>
<button
type="button"
onClick={closeSheet}
aria-label="Close"
className="grid h-8 w-8 place-items-center rounded-full border border-border/60"
>
×
</button>
</header>
{/* Tab bar */}
<div className="flex gap-1 rounded-lg bg-muted/30 p-1" role="tablist">
{(["quotes", "orders"] as const).map((k) => (
<button
key={k}
type="button"
role="tab"
aria-selected={sheetKind === k}
onClick={async () => {
setSheetKind(k);
setSheetPage(1);
await loadSheetPage(sheetRep.id, k, 1, true);
}}
className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium capitalize transition-colors ${
sheetKind === k
? "bg-cyan-500/15 text-cyan-300"
: "text-muted-foreground hover:text-foreground"
}`}
>
{k}
</button>
))}
</div>
{/* List */}
<ul className="max-h-80 overflow-y-auto divide-y divide-border/40 -mx-1">
{sheetItems.map((item) => (
<li key={item.id}>
<button
type="button"
onClick={() => {
const path =
sheetKind === "quotes"
? `/quotes/${item.id}`
: `/orders/${item.id}`;
window.open(path, "_blank");
}}
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left hover:bg-muted/30"
>
<div className="min-w-0">
<div className="text-sm font-medium truncate">{item.number}</div>
<div className="text-xs text-muted-foreground truncate">
{item.customer || "—"}
</div>
</div>
<div className="shrink-0 text-right">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">
{item.status}
</div>
<div className="text-[11px] text-muted-foreground">
{formatAge(item.ageMs)}
</div>
</div>
</button>
</li>
))}
</ul>
{sheetHasMore && (
<button
type="button"
disabled={sheetLoading}
onClick={async () => {
const nextPage = sheetPage + 1;
setSheetPage(nextPage);
await loadSheetPage(sheetRep.id, sheetKind, nextPage, false);
}}
className="w-full rounded-lg border border-border/60 py-2 text-sm text-muted-foreground hover:bg-muted/30 disabled:opacity-50"
>
{sheetLoading ? "Loading…" : "Load more"}
</button>
)}
{sheetItems.length === 0 && !sheetLoading && (
<div className="text-center text-sm text-muted-foreground py-6">
No {sheetKind} in this window.
</div>
)}
</div>
)}
</dialog>
- Add the helper at the bottom of the file:
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/<id> opens in a new tab.
- Step 3: Commit
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:
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
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
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.mdmaps 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:
BossWindowdefined in Task 1, used in Tasks 2, 3, 4, 5.BossDashboardDatadefined in Task 2, used in Tasks 9-15.RepActivityKinddefined in Task 3, used in Tasks 5, 14.BossRepRow,BossDriverRowdefined in Task 2, used in Tasks 9-15. -
Pattern consistency: mirrors the existing
/api/v1/dashboardroute (localassertBrowserSession,force-dynamic,runtime = "nodejs"), the existing/dashboardpage (PB realtime + 1s debounce + bearer token forward), the existing dashboard service tests (in-memory PB mock withvi.hoisted). -
No big-bang rewrite: no file outside the new
(mobile)/route group, the new service, and the new API routes is modified.