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>
2032 lines
No EOL
66 KiB
Markdown
2032 lines
No EOL
66 KiB
Markdown
# 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.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=<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`:
|
||
|
||
```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<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`:
|
||
|
||
```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**
|
||
|
||
```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<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**
|
||
|
||
```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=<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**
|
||
|
||
```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 (
|
||
<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**
|
||
|
||
```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 <BossDashboardView />;
|
||
}
|
||
```
|
||
|
||
- [ ] **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<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**
|
||
|
||
```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 `<div className="rounded-xl bg-muted/30 p-3 text-xs text-muted-foreground">Tabs land here in Task 10.</div>` block with:
|
||
|
||
```tsx
|
||
{/* 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**
|
||
|
||
```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 && (
|
||
<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):
|
||
|
||
```tsx
|
||
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**
|
||
|
||
```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 */}
|
||
<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:
|
||
|
||
```tsx
|
||
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**
|
||
|
||
```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 */}
|
||
<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**
|
||
|
||
```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 `<dialog>` 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<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 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<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 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
|
||
<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:
|
||
|
||
```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/<id>` 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. |