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— addgetBrowserSessionUser()(additive). - Modify:
src/components/charts/radial-gauge.tsx— add optionalcenterLabelOverrideprop (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’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
- Start the dev server and navigate to
/dashboard2while logged in as a user tied to at least one customer asrep. - Confirm the 3 KPI cards show sensible today/week/month counts with deltas.
- Confirm the quote-volume bar chart renders 8 weeks.
- Confirm outstanding quotes/orders lists show only that rep's records, and clicking a row navigates to
/quotes/[id]or/orders/[id]. - 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.
- 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
/dashboardis 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"