WFDASH-WEBAPP/docs/superpowers/plans/2026-08-27-boss-phone-dashboard.md
Dave 22660382fe docs(boss-dashboard): add phone-first /m dashboard implementation plan
Companion to the design spec at docs/superpowers/specs/. Covers all 15
implementation tasks (windowRange helper, aggregations, rep-activity
pagination, route handlers, tests, PWA manifest, mobile layout,
dashboard view with realtime + bottom sheet) plus the final
verification step.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 09:42:48 +02:00

66 KiB
Raw Blame History

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:

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 useState declarations):
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:
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):
<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.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.