WFDASH-WEBAPP/docs/superpowers/plans/2026-09-01-rep-dashboard.md
Dave 5c210546e9 docs: add implementation plan for rep dashboard (dashboard 2)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 01:31:57 +02:00

39 KiB

Rep Dashboard ("Dashboard 2") 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 new page at /dashboard2 that shows the logged-in user their own quotes-loaded trend (with day/week/month deltas), their outstanding quotes and orders, and their month-to-date selling/cost/markup — all scoped to customer.rep = <them>. The existing /dashboard is untouched.

Architecture: One new server aggregation service (rep-dashboard-service.ts) doing narrow, count-only PocketBase queries scoped to one rep — no sampling, unlike the company-wide dashboard-service.ts. One new thin route. One new client page reusing the existing chart components (DashboardCard, KpiCard, BarChart, RadialGauge). One small additive change to browser-session.ts (a new export, existing export untouched) and one small additive prop on RadialGauge (optional, defaults preserve current behavior).

Tech Stack: Next.js Client Components, PocketBase JS SDK, Vitest.

Reference: design spec at docs/superpowers/specs/2026-09-01-rep-dashboard-design.md.

Correction from the spec during planning: the spec said to reuse buildWeekBuckets/isoWeekKey "already exported from dashboard-service.ts" — they are in fact private (unexported) helpers in that file. Rather than exporting internals from a file the spec explicitly promised to leave untouched, this plan defines small local date-boundary helpers directly in rep-dashboard-service.ts (Task 3, Step 1). This keeps dashboard-service.ts completely unmodified, matching the spec's isolation goal even more strictly.


File Structure

  • Modify: src/lib/server/browser-session.ts — add getBrowserSessionUser() (additive).
  • Modify: src/components/charts/radial-gauge.tsx — add optional centerLabelOverride prop (additive).
  • Create: src/lib/server/rep-dashboard-service.ts — data aggregation.
  • Create: src/lib/server/rep-dashboard-service.test.ts — unit tests.
  • Create: src/app/api/v1/rep-dashboard/route.ts — route handler.
  • Create: src/app/(app)/dashboard2/page.tsx — page.

Task 1: getBrowserSessionUser

Files:

  • Modify: src/lib/server/browser-session.ts

  • Test: src/lib/server/browser-session.test.ts

  • Step 1: Write the failing test

// src/lib/server/browser-session.test.ts
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { NextRequest } from "next/server";
import { getBrowserSessionUser } from "./browser-session";

const ORIGINAL_FETCH = global.fetch;

function makeRequest(authHeader: string | null): NextRequest {
  const headers = new Headers();
  if (authHeader !== null) headers.set("authorization", authHeader);
  return new NextRequest("http://localhost:4001/api/v1/rep-dashboard", {
    headers,
  });
}

describe("getBrowserSessionUser", () => {
  beforeEach(() => {
    global.fetch = ORIGINAL_FETCH;
  });
  afterEach(() => {
    global.fetch = ORIGINAL_FETCH;
  });

  it("throws a 401-tagged error when no token is present", async () => {
    await expect(getBrowserSessionUser(makeRequest(null))).rejects.toMatchObject({
      status: 401,
    });
  });

  it("throws a 401-tagged error when PB rejects the token", async () => {
    global.fetch = vi.fn(async () => new Response("", { status: 401 })) as unknown as typeof fetch;
    await expect(
      getBrowserSessionUser(makeRequest("Bearer bad-token")),
    ).rejects.toMatchObject({ status: 401 });
  });

  it("returns the resolved user's id and user_type on success", async () => {
    global.fetch = vi.fn(async () =>
      new Response(
        JSON.stringify({
          token: "refreshed-token",
          record: { id: "user123", user_type: ["REP"], name: "Test Rep" },
        }),
        { status: 200, headers: { "content-type": "application/json" } },
      ),
    ) as unknown as typeof fetch;

    const result = await getBrowserSessionUser(makeRequest("Bearer good-token"));
    expect(result).toEqual({ id: "user123", user_type: ["REP"] });
  });

  it("defaults user_type to null when the record omits it", async () => {
    global.fetch = vi.fn(async () =>
      new Response(
        JSON.stringify({ token: "t", record: { id: "user456" } }),
        { status: 200, headers: { "content-type": "application/json" } },
      ),
    ) as unknown as typeof fetch;

    const result = await getBrowserSessionUser(makeRequest("Bearer good-token"));
    expect(result).toEqual({ id: "user456", user_type: null });
  });
});
  • Step 2: Run the test to verify it fails

Run: npx vitest run src/lib/server/browser-session.test.ts Expected: FAIL — getBrowserSessionUser is not exported yet.

  • Step 3: Add the implementation

Modify src/lib/server/browser-session.ts — keep assertBrowserSession exactly as-is, add this below it:

export interface BrowserSessionUser {
  id: string;
  user_type: string[] | null;
}

/**
 * Same validation as `assertBrowserSession`, but returns the resolved
 * PB user record instead of discarding it — for routes that need to
 * scope queries to "whoever is logged in" (e.g. the rep dashboard).
 */
export async function getBrowserSessionUser(
  req: NextRequest,
): Promise<BrowserSessionUser> {
  const authorization = req.headers.get("authorization") || "";
  const token = authorization.startsWith("Bearer ")
    ? authorization.slice(7)
    : authorization;

  if (!token) {
    throw Object.assign(new Error("Not authenticated."), { status: 401 });
  }

  const pbUrl =
    process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090";
  const response = await fetch(
    `${pbUrl.replace(/\/$/, "")}/api/collections/users/auth-refresh`,
    {
      method: "POST",
      headers: { Authorization: token },
      signal: AbortSignal.timeout(5_000),
    },
  );

  if (!response.ok) {
    throw Object.assign(new Error("Invalid or expired session."), {
      status: 401,
    });
  }

  const body = (await response.json()) as {
    record?: { id?: string; user_type?: string[] | null };
  };
  const id = body.record?.id;
  if (!id) {
    throw Object.assign(new Error("Invalid or expired session."), {
      status: 401,
    });
  }

  return { id, user_type: body.record?.user_type ?? null };
}
  • Step 4: Run the test to verify it passes

Run: npx vitest run src/lib/server/browser-session.test.ts Expected: PASS — all 4 assertions green.

  • Step 5: Commit
git add src/lib/server/browser-session.ts src/lib/server/browser-session.test.ts
git commit -m "feat: add getBrowserSessionUser for routes that need the caller's identity"

Task 2: RadialGauge center-label override

Files:

  • Modify: src/components/charts/radial-gauge.tsx

No test — this component has no existing test coverage (matches the rest of src/components/charts/, which is all presentational and untested); the new prop is optional and additive, verified visually in Task 5.

  • Step 1: Add the optional prop and use it in the center-text render

In src/components/charts/radial-gauge.tsx, change the prop destructuring (currently value, total, color, label, sublabel, size = 180, ticks = 24) to add centerLabelOverride:

export function RadialGauge({
  value,
  total,
  color,
  label,
  sublabel,
  size = 180,
  ticks = 24,
  centerLabelOverride,
}: {
  value: number;
  total: number;
  color: string;
  label?: string;
  sublabel?: string;
  size?: number;
  ticks?: number;
  /**
   * Overrides the big center text (normally the computed `value/total`
   * percentage) with an arbitrary string — for callers where the ring's
   * fraction and the headline number are different things (e.g. the rep
   * dashboard's markup % gauge, where the ring shows cost's share of
   * selling price but the headline is the unbounded markup percentage).
   */
  centerLabelOverride?: string;
}) {

Then change the center-text render (the <text> element currently containing {pct}%):

          {/* Center percentage */}
          <text
            x={cx}
            y={cy - 4}
            textAnchor="middle"
            style={{
              fontSize: size * 0.22,
              fontFamily: "var(--font-jetbrains-mono), monospace",
              fontWeight: 700,
              fill: "var(--foreground)",
            }}
          >
            {centerLabelOverride ?? `${pct}%`}
          </text>
  • Step 2: Commit
git add src/components/charts/radial-gauge.tsx
git commit -m "feat(charts): allow RadialGauge to override its center label"

Task 3: rep-dashboard-service.ts

Files:

  • Create: src/lib/server/rep-dashboard-service.ts

  • Test: src/lib/server/rep-dashboard-service.test.ts

  • Step 1: Write the implementation

// src/lib/server/rep-dashboard-service.ts
import { getAdminPocketBase } from "./pocketbase-admin";
import { getOrderItemsCollectionName } from "./orders-service";
import { tabGroupFilter } from "@/lib/item-status-ui";

const QUOTES_COLLECTION = "quotes";
const ORDERS_COLLECTION = "orders";

const QUOTE_VOLUME_WEEKS = 8;
const OUTSTANDING_LIST_CAP = 8;
const MONTH_ORDER_ITEM_LIMIT = 1000;

type AdminPocketBase = Awaited<ReturnType<typeof getAdminPocketBase>>;

export interface RepDashboardData {
  quotesLoaded: {
    today: number;
    yesterday: number;
    thisWeek: number;
    lastWeek: number;
    thisMonth: number;
    lastMonth: number;
  };
  quoteVolumeTrend: { weekStart: string; count: number }[];
  outstandingQuotes: {
    total: number;
    items: { id: string; quote_number: string; customer_name: string | null }[];
  };
  outstandingOrders: {
    total: number;
    items: { id: string; order_number: string; customer_name: string | null }[];
  };
  monthRevenue: {
    selling: number;
    cost: number;
    markupPct: number | null;
  };
}

/* ---------- date helpers (Monday-anchored weeks, UTC) ---------- */

function startOfDayUtc(d: Date): Date {
  const out = new Date(d);
  out.setUTCHours(0, 0, 0, 0);
  return out;
}

function addDaysUtc(d: Date, days: number): Date {
  const out = new Date(d);
  out.setUTCDate(out.getUTCDate() + days);
  return out;
}

function startOfWeekUtc(d: Date): Date {
  const out = startOfDayUtc(d);
  const day = out.getUTCDay();
  const diff = day === 0 ? -6 : 1 - day; // Monday-anchored week
  out.setUTCDate(out.getUTCDate() + diff);
  return out;
}

function startOfMonthUtc(d: Date): Date {
  return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
}

function addMonthsUtc(d: Date, months: number): Date {
  return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + months, 1));
}

/** Escapes a value for embedding inside a PB filter string literal. */
function esc(value: string): string {
  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}

function rangeFilter(field: string, start: Date, end: Date): string {
  return `${field} >= "${start.toISOString()}" && ${field} < "${end.toISOString()}"`;
}

/** Count-only query — reads PB's own `totalItems` via a `perPage=1` page. */
async function countWhere(
  pb: AdminPocketBase,
  collection: string,
  filter: string,
): Promise<number> {
  const result = await pb.collection(collection).getList(1, 1, {
    filter,
    fields: "id",
  });
  return result.totalItems || 0;
}

export async function getRepDashboardData(
  repId: string,
  now: Date = new Date(),
  pbArg?: AdminPocketBase,
): Promise<RepDashboardData> {
  const pb = pbArg ?? (await getAdminPocketBase());
  pb.autoCancellation(false);

  const repFilter = `customer.rep = "${esc(repId)}"`;
  const orderRepFilter = `order.customer.rep = "${esc(repId)}"`;

  /* ---------- quotesLoaded (6 count-only queries) ---------- */
  const todayStart = startOfDayUtc(now);
  const yesterdayStart = addDaysUtc(todayStart, -1);
  const tomorrowStart = addDaysUtc(todayStart, 1);
  const thisWeekStart = startOfWeekUtc(now);
  const lastWeekStart = addDaysUtc(thisWeekStart, -7);
  const nextWeekStart = addDaysUtc(thisWeekStart, 7);
  const thisMonthStart = startOfMonthUtc(now);
  const lastMonthStart = addMonthsUtc(thisMonthStart, -1);
  const nextMonthStart = addMonthsUtc(thisMonthStart, 1);

  const quoteBaseFilter = `${repFilter} && is_template != true`;

  const [
    todayCount,
    yesterdayCount,
    thisWeekCount,
    lastWeekCount,
    thisMonthCount,
    lastMonthCount,
  ] = await Promise.all([
    countWhere(pb, QUOTES_COLLECTION, `${quoteBaseFilter} && ${rangeFilter("created", todayStart, tomorrowStart)}`),
    countWhere(pb, QUOTES_COLLECTION, `${quoteBaseFilter} && ${rangeFilter("created", yesterdayStart, todayStart)}`),
    countWhere(pb, QUOTES_COLLECTION, `${quoteBaseFilter} && ${rangeFilter("created", thisWeekStart, nextWeekStart)}`),
    countWhere(pb, QUOTES_COLLECTION, `${quoteBaseFilter} && ${rangeFilter("created", lastWeekStart, thisWeekStart)}`),
    countWhere(pb, QUOTES_COLLECTION, `${quoteBaseFilter} && ${rangeFilter("created", thisMonthStart, nextMonthStart)}`),
    countWhere(pb, QUOTES_COLLECTION, `${quoteBaseFilter} && ${rangeFilter("created", lastMonthStart, thisMonthStart)}`),
  ]);

  /* ---------- quoteVolumeTrend (8 weekly count-only queries) ---------- */
  const weekStarts: Date[] = [];
  for (let i = QUOTE_VOLUME_WEEKS - 1; i >= 0; i--) {
    weekStarts.push(addDaysUtc(thisWeekStart, -7 * i));
  }
  const quoteVolumeTrend = await Promise.all(
    weekStarts.map(async (weekStart) => {
      const weekEnd = addDaysUtc(weekStart, 7);
      const count = await countWhere(
        pb,
        QUOTES_COLLECTION,
        `${quoteBaseFilter} && ${rangeFilter("created", weekStart, weekEnd)}`,
      );
      return { weekStart: weekStart.toISOString().slice(0, 10), count };
    }),
  );

  /* ---------- outstandingQuotes ---------- */
  const outstandingQuotesResult = await pb
    .collection(QUOTES_COLLECTION)
    .getList(1, OUTSTANDING_LIST_CAP, {
      filter: `${repFilter} && (status = "new" || status = "claimed") && is_template != true`,
      sort: "-created",
      fields: "id,quote_number",
      expand: "customer",
    });
  const outstandingQuotes = {
    total: outstandingQuotesResult.totalItems || 0,
    items: (outstandingQuotesResult.items as unknown as Array<{
      id: string;
      quote_number: string;
      expand?: { customer?: { customer?: string } };
    }>).map((q) => ({
      id: q.id,
      quote_number: q.quote_number,
      customer_name: q.expand?.customer?.customer ?? null,
    })),
  };

  /* ---------- outstandingOrders ---------- */
  const outstandingOrderItems = await pb
    .collection(getOrderItemsCollectionName())
    .getFullList<{ order?: string }>({
      filter: `${orderRepFilter} && (${tabGroupFilter("new")})`,
      fields: "order",
    });
  const outstandingOrderIds = Array.from(
    new Set(outstandingOrderItems.map((it) => it.order).filter((id): id is string => !!id)),
  );
  let outstandingOrdersItems: { id: string; order_number: string; customer_name: string | null }[] = [];
  if (outstandingOrderIds.length > 0) {
    const capped = outstandingOrderIds.slice(0, OUTSTANDING_LIST_CAP);
    const idsFilter = capped.map((id) => `id = "${esc(id)}"`).join(" || ");
    const ordersResult = await pb.collection(ORDERS_COLLECTION).getFullList<{
      id: string;
      order_number: string;
      expand?: { customer?: { customer?: string } };
    }>({
      filter: idsFilter,
      sort: "-created",
      fields: "id,order_number",
      expand: "customer",
    });
    outstandingOrdersItems = ordersResult.map((o) => ({
      id: o.id,
      order_number: o.order_number,
      customer_name: o.expand?.customer?.customer ?? null,
    }));
  }
  const outstandingOrders = {
    total: outstandingOrderIds.length,
    items: outstandingOrdersItems,
  };

  /* ---------- monthRevenue ---------- */
  const monthOrderItems = await pb
    .collection(getOrderItemsCollectionName())
    .getList(1, MONTH_ORDER_ITEM_LIMIT, {
      filter: `${orderRepFilter} && ${rangeFilter("created", thisMonthStart, nextMonthStart)}`,
      fields: "id,quantity,order_qty,selling_price,cost_price",
    });
  let selling = 0;
  let cost = 0;
  for (const it of monthOrderItems.items as unknown as Array<{
    quantity?: number | null;
    order_qty?: number | null;
    selling_price?: number | null;
    cost_price?: number | null;
  }>) {
    const qty = Number(it.quantity ?? it.order_qty ?? 0);
    selling += Number(it.selling_price ?? 0) * qty;
    cost += Number(it.cost_price ?? 0) * qty;
  }
  const markupPct = cost > 0 ? ((selling - cost) / cost) * 100 : null;

  return {
    quotesLoaded: {
      today: todayCount,
      yesterday: yesterdayCount,
      thisWeek: thisWeekCount,
      lastWeek: lastWeekCount,
      thisMonth: thisMonthCount,
      lastMonth: lastMonthCount,
    },
    quoteVolumeTrend,
    outstandingQuotes,
    outstandingOrders,
    monthRevenue: { selling, cost, markupPct },
  };
}
  • Step 2: Write the test file
// src/lib/server/rep-dashboard-service.test.ts
import { describe, it, expect, vi } from "vitest";
import { getRepDashboardData } from "./rep-dashboard-service";

// Local mock — same shape as dashboard-service.test.ts's, extended to
// treat dotted field names (e.g. "customer.rep", "order.customer.rep")
// as literal keys on the flat fixture objects. This doesn't model real
// PB relation traversal; it validates that the service builds the
// right filter clauses and aggregates correctly, with fixtures that
// pre-flatten the relation chain onto each row (e.g. an order_items
// fixture carries an "order.customer.rep" key directly).
type StoreMap = Map<string, Record<string, unknown>>;
const store: Record<string, StoreMap> = {};

function seed(collection: string, rows: Record<string, unknown>[]) {
  const map: StoreMap = new Map();
  for (const row of rows) map.set(row.id as string, row);
  store[collection] = map;
}

/**
 * Depth-aware filter evaluator. Unlike a naive `split("||")` /
 * `split("&&")`, this respects parens (so `A && (B || C)` is parsed
 * as AND(A, OR(B, C)), not mis-split into three top-level OR
 * branches) and skips operators inside quoted string literals.
 * Needed because this plan's queries mix top-level `&&` with a
 * parenthesized `||` group (e.g. the outstanding-quotes/orders
 * filters) — a naive splitter silently mis-evaluates that shape and
 * would let a wrong-rep row match via a malformed leaf that "fails
 * open" (see the comment on `evalLeaf` below).
 */
function splitTopLevel(expr: string, op: "||" | "&&"): string[] {
  const parts: string[] = [];
  let depth = 0;
  let start = 0;
  for (let i = 0; i < expr.length; i++) {
    const c = expr[i];
    if (c === '"') {
      i++;
      while (i < expr.length && expr[i] !== '"') i++;
      continue;
    }
    if (c === "(") depth++;
    else if (c === ")") depth--;
    else if (depth === 0 && expr.startsWith(op, i)) {
      parts.push(expr.slice(start, i).trim());
      i += op.length - 1;
      start = i + 1;
    }
  }
  parts.push(expr.slice(start).trim());
  return parts;
}

function stripParens(expr: string): string {
  const e = expr.trim();
  if (!e.startsWith("(") || !e.endsWith(")")) return e;
  let depth = 0;
  for (let i = 0; i < e.length; i++) {
    if (e[i] === "(") depth++;
    else if (e[i] === ")") {
      depth--;
      if (depth === 0 && i !== e.length - 1) return e; // not a single wrapping pair
    }
  }
  return e.slice(1, -1).trim();
}

function evalLeaf(item: Record<string, unknown>, leaf: string): boolean {
  const m = leaf.match(/^([\w.]+)\s*(>=|<=|!=|=)\s*(?:"([^"]*)"|(\S+))$/);
  if (!m) return true; // unrecognised leaf — fail open (matches everything)
  const [, field, op, quoted, unquoted] = m;
  const expected = quoted ?? unquoted ?? "";
  const actual = String(item[field] ?? "");
  switch (op) {
    case "=": return actual === expected;
    case "!=": return actual !== expected;
    case ">=": return actual >= expected;
    case "<=": return actual <= expected;
    default: return true;
  }
}

function evalOr(item: Record<string, unknown>, expr: string): boolean {
  const parts = splitTopLevel(expr, "||");
  if (parts.length > 1) return parts.some((p) => evalOr(item, p));
  return evalAnd(item, expr);
}

function evalAnd(item: Record<string, unknown>, expr: string): boolean {
  const parts = splitTopLevel(expr, "&&");
  if (parts.length > 1) return parts.every((p) => evalOr(item, p));
  const stripped = stripParens(expr);
  if (stripped !== expr.trim()) return evalOr(item, stripped);
  return evalLeaf(item, stripped);
}

function where(collection: string, filter: string): Record<string, unknown>[] {
  const items = Array.from((store[collection] ?? new Map()).values());
  if (!filter || !filter.trim()) return items;
  return items.filter((item) => evalOr(item, filter.trim()));
}

const pbMock = {
  autoCancellation: () => undefined,
  collection: (name: string) => ({
    async getList(page = 1, perPage = 25, opts: { filter?: string } = {}) {
      const all = where(name, opts.filter || "");
      const totalItems = all.length;
      const start = (page - 1) * perPage;
      return {
        items: all.slice(start, start + perPage),
        totalItems,
        totalPages: Math.max(1, Math.ceil(totalItems / perPage)),
        page,
        perPage,
      };
    },
    async getFullList(opts: { filter?: string } = {}) {
      return where(name, opts.filter || "");
    },
  }),
};

vi.mock("./pocketbase-admin", () => ({
  getAdminPocketBase: vi.fn().mockResolvedValue(pbMock),
}));

const REP_ID = "rep1";
const OTHER_REP_ID = "rep2";
const NOW = new Date("2026-08-20T12:00:00.000Z"); // a Thursday

describe("getRepDashboardData", () => {
  it("scopes quote counts to the given rep and computes day/week/month buckets", async () => {
    seed("quotes", [
      { id: "q1", "customer.rep": REP_ID, is_template: false, created: "2026-08-20T09:00:00.000Z", status: "new", quote_number: "Q1" }, // today
      { id: "q2", "customer.rep": REP_ID, is_template: false, created: "2026-08-19T09:00:00.000Z", status: "new", quote_number: "Q2" }, // yesterday
      { id: "q3", "customer.rep": REP_ID, is_template: false, created: "2026-08-15T09:00:00.000Z", status: "new", quote_number: "Q3" }, // a prior week — not asserted on directly, just adds noise the filters must exclude from today/yesterday
      { id: "q4", "customer.rep": OTHER_REP_ID, is_template: false, created: "2026-08-20T09:00:00.000Z", status: "new", quote_number: "Q4" }, // different rep
      { id: "q5", "customer.rep": REP_ID, is_template: true, created: "2026-08-20T09:00:00.000Z", status: "new", quote_number: "Q5" }, // template, excluded
    ]);
    seed("order_items", []);
    seed("orders", []);

    const data = await getRepDashboardData(REP_ID, NOW, pbMock as never);

    expect(data.quotesLoaded.today).toBe(1);
    expect(data.quotesLoaded.yesterday).toBe(1);
    expect(data.quoteVolumeTrend).toHaveLength(8);
  });

  it("only counts outstanding quotes in new/claimed status for this rep", async () => {
    seed("quotes", [
      { id: "q1", "customer.rep": REP_ID, is_template: false, created: "2026-08-01T00:00:00.000Z", status: "new", quote_number: "Q1" },
      { id: "q2", "customer.rep": REP_ID, is_template: false, created: "2026-08-01T00:00:00.000Z", status: "claimed", quote_number: "Q2" },
      { id: "q3", "customer.rep": REP_ID, is_template: false, created: "2026-08-01T00:00:00.000Z", status: "complete", quote_number: "Q3" },
      { id: "q4", "customer.rep": OTHER_REP_ID, is_template: false, created: "2026-08-01T00:00:00.000Z", status: "new", quote_number: "Q4" },
    ]);
    seed("order_items", []);
    seed("orders", []);

    const data = await getRepDashboardData(REP_ID, NOW, pbMock as never);

    expect(data.outstandingQuotes.total).toBe(2);
    expect(data.outstandingQuotes.items.map((i) => i.quote_number).sort()).toEqual(["Q1", "Q2"]);
  });

  it("counts outstanding orders as distinct orders with a 'new'-tab item for this rep", async () => {
    seed("quotes", []);
    seed("order_items", [
      { id: "i1", order: "o1", "order.customer.rep": REP_ID, item_status: "pending" },
      { id: "i2", order: "o1", "order.customer.rep": REP_ID, item_status: "processed" }, // same order, not new-tab — still counted once via i1
      { id: "i3", order: "o2", "order.customer.rep": REP_ID, item_status: "pending" },
      { id: "i4", order: "o3", "order.customer.rep": OTHER_REP_ID, item_status: "pending" }, // different rep
    ]);
    seed("orders", [
      { id: "o1", order_number: "ORD1" },
      { id: "o2", order_number: "ORD2" },
    ]);

    const data = await getRepDashboardData(REP_ID, NOW, pbMock as never);

    expect(data.outstandingOrders.total).toBe(2);
    expect(data.outstandingOrders.items.map((i) => i.order_number).sort()).toEqual(["ORD1", "ORD2"]);
  });

  it("sums selling/cost and computes markup % for this month's order items", async () => {
    seed("quotes", []);
    seed("orders", []);
    seed("order_items", [
      { id: "i1", "order.customer.rep": REP_ID, created: "2026-08-05T00:00:00.000Z", quantity: 2, selling_price: 100, cost_price: 50 },
      { id: "i2", "order.customer.rep": REP_ID, created: "2026-08-10T00:00:00.000Z", quantity: 1, selling_price: 40, cost_price: 20 },
      { id: "i3", "order.customer.rep": REP_ID, created: "2026-07-31T00:00:00.000Z", quantity: 1, selling_price: 999, cost_price: 1 }, // last month, excluded
      { id: "i4", "order.customer.rep": OTHER_REP_ID, created: "2026-08-05T00:00:00.000Z", quantity: 1, selling_price: 999, cost_price: 1 }, // different rep
    ]);

    const data = await getRepDashboardData(REP_ID, NOW, pbMock as never);

    // selling = 2*100 + 1*40 = 240; cost = 2*50 + 1*20 = 120
    expect(data.monthRevenue.selling).toBe(240);
    expect(data.monthRevenue.cost).toBe(120);
    expect(data.monthRevenue.markupPct).toBe(100); // (240-120)/120 * 100
  });

  it("returns markupPct: null when there's no cost this month (no div-by-zero)", async () => {
    seed("quotes", []);
    seed("orders", []);
    seed("order_items", []);

    const data = await getRepDashboardData(REP_ID, NOW, pbMock as never);

    expect(data.monthRevenue.selling).toBe(0);
    expect(data.monthRevenue.cost).toBe(0);
    expect(data.monthRevenue.markupPct).toBeNull();
  });
});
  • Step 3: Run the tests

Run: npx vitest run src/lib/server/rep-dashboard-service.test.ts Expected: PASS — all 5 tests green. If today/yesterday counts don't match, double check NOW's UTC date against the fixture timestamps (the test uses 2026-08-20T12:00:00Z as "today", 2026-08-19 as "yesterday" — adjust fixture dates to stay consistent if you change NOW).

  • Step 4: Commit
git add src/lib/server/rep-dashboard-service.ts src/lib/server/rep-dashboard-service.test.ts
git commit -m "feat: add rep-scoped dashboard data aggregation service"

Task 4: /api/v1/rep-dashboard route

Files:

  • Create: src/app/api/v1/rep-dashboard/route.ts

  • Step 1: Write the route

// src/app/api/v1/rep-dashboard/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getBrowserSessionUser } from "@/lib/server/browser-session";
import { getRepDashboardData } from "@/lib/server/rep-dashboard-service";

/**
 * GET /api/v1/rep-dashboard
 *
 * Read model for `/dashboard2`. Scopes every query to the logged-in
 * user's own id (via `customer.rep`) — unlike `/api/v1/dashboard`,
 * there is no role gate: a non-rep just gets zeros everywhere.
 */
export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export async function GET(req: NextRequest) {
  try {
    const sessionUser = await getBrowserSessionUser(req);
    const data = await getRepDashboardData(sessionUser.id);
    return NextResponse.json(data);
  } catch (error) {
    const message =
      error instanceof Error ? error.message : "Failed to load rep dashboard";
    const status = (error as Error & { status?: number }).status || 500;
    return NextResponse.json({ error: message }, { status });
  }
}
  • Step 2: Commit
git add src/app/api/v1/rep-dashboard/route.ts
git commit -m "feat: add /api/v1/rep-dashboard route"

Task 5: /dashboard2 page

Files:

  • Create: src/app/(app)/dashboard2/page.tsx

  • Step 1: Write the page

// src/app/(app)/dashboard2/page.tsx
"use client";

import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import {
  FileText,
  ShoppingCart,
  CalendarDays,
  TrendingUp,
  Gauge,
  CheckCircle,
  AlertCircle,
} from "lucide-react";
import { toast } from "sonner";
import getPocketBase from "@/lib/pocketbase";
import { CHART, withAlpha } from "@/components/charts/palette";
import { DashboardCard } from "@/components/charts/dashboard-card";
import { BarChart } from "@/components/charts/bar-chart";
import { RadialGauge } from "@/components/charts/radial-gauge";
import type { RepDashboardData } from "@/lib/server/rep-dashboard-service";

function weekLabel(iso: string): string {
  const d = new Date(iso);
  return d.toLocaleDateString("en-ZA", { day: "numeric", month: "short" });
}

function deltaPct(current: number, previous: number): number {
  if (previous === 0) return current > 0 ? 100 : 0;
  return Math.round(((current - previous) / previous) * 100);
}

function formatZar(value: number): string {
  return new Intl.NumberFormat("en-ZA", {
    style: "currency",
    currency: "ZAR",
    maximumFractionDigits: 0,
  }).format(value);
}

const REALTIME_DEBOUNCE_MS = 1_000;

export default function RepDashboardPage() {
  const router = useRouter();
  const [data, setData] = useState<RepDashboardData | null>(null);
  const [loading, setLoading] = useState(true);

  const fetchData = useCallback(async () => {
    try {
      const pb = getPocketBase();
      const pbToken = pb.authStore?.token;
      const headers: Record<string, string> = {};
      if (pbToken) headers.Authorization = pbToken;

      const response = await fetch("/api/v1/rep-dashboard", {
        headers,
        cache: "no-store",
      });
      if (!response.ok) {
        const body = (await response.json().catch(() => ({}))) as { error?: string };
        throw new Error(body.error || `Rep dashboard failed (HTTP ${response.status})`);
      }
      setData((await response.json()) as RepDashboardData);
    } catch (err: unknown) {
      toast.error(err instanceof Error ? err.message : "Failed to load rep dashboard");
    } finally {
      setLoading(false);
    }
  }, []);

  useEffect(() => {
    void fetchData();
  }, [fetchData]);

  useEffect(() => {
    const pb = getPocketBase();
    let handle = 0;
    const trigger = () => {
      if (handle) window.clearTimeout(handle);
      handle = window.setTimeout(() => void fetchData(), REALTIME_DEBOUNCE_MS);
    };
    const subscribe = async () => {
      await pb.collection("quotes").subscribe("*", trigger);
      await pb.collection("orders").subscribe("*", trigger);
      await pb.collection("order_items").subscribe("*", trigger);
    };
    void subscribe();
    return () => {
      if (handle) window.clearTimeout(handle);
      void pb.collection("quotes").unsubscribe("*");
      void pb.collection("orders").unsubscribe("*");
      void pb.collection("order_items").unsubscribe("*");
    };
  }, [fetchData]);

  if (loading || !data) {
    return (
      <div className="py-16 text-center text-muted-foreground">
        Loading your dashboard…
      </div>
    );
  }

  const { quotesLoaded, quoteVolumeTrend, outstandingQuotes, outstandingOrders, monthRevenue } = data;

  return (
    <div className="space-y-5">
      <div>
        <h1 className="text-2xl font-bold tracking-tight">My Dashboard</h1>
        <p className="text-muted-foreground text-sm">
          Your quotes, outstanding work, and this month&rsquo;s numbers.
        </p>
      </div>

      {/* Row 1: quotes-loaded KPIs */}
      <div className="grid gap-3 grid-cols-1 sm:grid-cols-3">
        <RepKpiCard
          label="Quotes today"
          value={quotesLoaded.today}
          delta={deltaPct(quotesLoaded.today, quotesLoaded.yesterday)}
          icon={CalendarDays}
          accentColor={CHART.cyan}
        />
        <RepKpiCard
          label="Quotes this week"
          value={quotesLoaded.thisWeek}
          delta={deltaPct(quotesLoaded.thisWeek, quotesLoaded.lastWeek)}
          icon={FileText}
          accentColor={CHART.pink}
        />
        <RepKpiCard
          label="Quotes this month"
          value={quotesLoaded.thisMonth}
          delta={deltaPct(quotesLoaded.thisMonth, quotesLoaded.lastMonth)}
          icon={TrendingUp}
          accentColor={CHART.orange}
        />
      </div>

      {/* Row 2: trend + outstanding lists */}
      <div className="grid gap-3 lg:grid-cols-3">
        <DashboardCard title="Quote volume trend" subtitle="8-week view" icon={TrendingUp} accentColor={CHART.orange}>
          <BarChart
            data={quoteVolumeTrend.map((d) => ({ label: weekLabel(d.weekStart), values: [d.count] }))}
            series={[{ label: "Quotes", color: CHART.orange }]}
            height={180}
          />
        </DashboardCard>

        <DashboardCard
          title="Outstanding quotes"
          subtitle={`${outstandingQuotes.total} need attention`}
          icon={FileText}
          accentColor={CHART.pink}
        >
          <RepList
            items={outstandingQuotes.items.map((q) => ({ id: q.id, primary: q.quote_number, secondary: q.customer_name }))}
            emptyLabel="No outstanding quotes"
            onClick={(id) => router.push(`/quotes/${id}`)}
          />
        </DashboardCard>

        <DashboardCard
          title="Outstanding orders"
          subtitle={`${outstandingOrders.total} need attention`}
          icon={ShoppingCart}
          accentColor={CHART.gold}
        >
          <RepList
            items={outstandingOrders.items.map((o) => ({ id: o.id, primary: o.order_number, secondary: o.customer_name }))}
            emptyLabel="No outstanding orders"
            onClick={(id) => router.push(`/orders/${id}`)}
          />
        </DashboardCard>
      </div>

      {/* Row 3: this month's money */}
      <div className="grid gap-3 lg:grid-cols-2">
        <DashboardCard title="Selling vs Cost" subtitle="This month" icon={ShoppingCart} accentColor={CHART.green}>
          <BarChart
            data={[{ label: "This month", values: [monthRevenue.selling, monthRevenue.cost] }]}
            series={[
              { label: "Selling", color: CHART.green },
              { label: "Cost", color: CHART.muted },
            ]}
            height={180}
          />
        </DashboardCard>

        <DashboardCard title="Markup %" subtitle="This month" icon={Gauge} accentColor={CHART.purple}>
          {monthRevenue.markupPct === null ? (
            <div className="py-8 text-center text-muted-foreground">
              <CheckCircle className="h-7 w-7 mx-auto mb-1.5" style={{ color: CHART.green }} />
              <p className="text-sm">No orders yet this month</p>
            </div>
          ) : (
            <RadialGauge
              value={monthRevenue.cost}
              total={monthRevenue.selling}
              color={CHART.purple}
              label="Markup"
              sublabel={`${formatZar(monthRevenue.selling)} selling · ${formatZar(monthRevenue.cost)} cost`}
              centerLabelOverride={`${monthRevenue.markupPct.toFixed(1)}%`}
              size={180}
            />
          )}
        </DashboardCard>
      </div>
    </div>
  );
}

function RepKpiCard({
  label,
  value,
  delta,
  icon: Icon,
  accentColor,
}: {
  label: string;
  value: number;
  delta: number;
  icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
  accentColor: string;
}) {
  const positive = delta >= 0;
  return (
    <DashboardCard accentColor={accentColor} padding="p-4">
      <div className="flex items-center justify-between gap-2 mb-2">
        <div className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{label}</div>
        <div
          className="grid h-7 w-7 place-items-center rounded-md shrink-0"
          style={{ background: withAlpha(accentColor, 14), color: accentColor }}
        >
          <Icon className="h-3.5 w-3.5" style={{ color: accentColor }} />
        </div>
      </div>
      <div className="text-2xl font-bold tracking-tight tabular-nums" style={{ fontFamily: "var(--font-jetbrains-mono), monospace" }}>
        {value.toLocaleString()}
      </div>
      <div className="mt-0.5 text-[11px] font-semibold" style={{ color: positive ? CHART.green : CHART.red }}>
        {positive ? "+" : ""}
        {delta}% vs previous
      </div>
    </DashboardCard>
  );
}

function RepList({
  items,
  emptyLabel,
  onClick,
}: {
  items: { id: string; primary: string; secondary: string | null }[];
  emptyLabel: string;
  onClick: (id: string) => void;
}) {
  if (items.length === 0) {
    return (
      <div className="py-6 text-center text-muted-foreground">
        <AlertCircle className="h-7 w-7 mx-auto mb-1.5 opacity-50" />
        <p className="text-sm">{emptyLabel}</p>
      </div>
    );
  }
  return (
    <div className="space-y-1.5">
      {items.map((item) => (
        <button
          key={item.id}
          type="button"
          onClick={() => onClick(item.id)}
          className="flex w-full items-center gap-2.5 rounded-md border p-2 text-left transition-colors hover:bg-muted/40"
          style={{ borderColor: "var(--border)" }}
        >
          <Avatar className="h-7 w-7 shrink-0">
            <AvatarFallback className="text-[10px] font-semibold">
              {(item.secondary || "?").charAt(0).toUpperCase()}
            </AvatarFallback>
          </Avatar>
          <div className="min-w-0 flex-1">
            <div className="text-sm font-medium truncate">{item.primary}</div>
            <div className="text-xs text-muted-foreground truncate">{item.secondary || "—"}</div>
          </div>
          <Badge variant="outline" className="text-[10px] shrink-0">Open</Badge>
        </button>
      ))}
    </div>
  );
}
  • Step 2: Manual verification
  1. Start the dev server and navigate to /dashboard2 while logged in as a user tied to at least one customer as rep.
  2. Confirm the 3 KPI cards show sensible today/week/month counts with deltas.
  3. Confirm the quote-volume bar chart renders 8 weeks.
  4. Confirm outstanding quotes/orders lists show only that rep's records, and clicking a row navigates to /quotes/[id] or /orders/[id].
  5. Confirm the Selling vs Cost bar chart and Markup % gauge render sensible numbers for orders created this month. If no orders exist this month, confirm the "No orders yet this month" empty state shows instead of a broken gauge.
  6. Log in as a user with no customers assigned as rep — confirm every card shows zeros/empty states without erroring.
  • Step 3: Commit
git add src/app/\(app\)/dashboard2/page.tsx
git commit -m "feat: add /dashboard2 rep-scoped dashboard page"

Task 6: Full regression pass

Files: none (verification only)

  • Step 1: Run the full test suite

Run: npx vitest run Expected: PASS — no regressions in existing suites, including dashboard-service.test.ts (untouched) and anything exercising browser-session.ts's assertBrowserSession (untouched).

  • Step 2: Confirm /dashboard is unaffected

Load /dashboard (the existing page) and confirm it looks and behaves exactly as before — no shared code was modified except the additive RadialGauge prop, which the existing "Approval rate" card doesn't pass, so its behavior is unchanged.

  • Step 3: Commit (if any fixes were needed)
git add -A
git commit -m "test: regression pass for rep dashboard"