12 KiB
Boss phone dashboard — design
Date: 2026-08-27 Status: Approved (pending implementation plan) Owner: Dave (boss's view)
Problem
The boss wants to keep track of where the business stands and what's
happening, on their phone. The existing /dashboard is desktop-first
(1074 lines, 6 chart types, 5 rows of cards) and the /board TV view is
designed for a wall-mounted display. Neither fits a phone glance.
Goal
A standalone, phone-first page at /m that answers, in a single
glance, three questions:
- How many quotes did each rep load, do for their accounts, and push through to emailed/approved/sent today / this week / this month?
- How many orders did each rep place today / this week / this month?
- How many driver stops are scheduled today, and how many are left?
The boss opens /m on their phone, sees the numbers refresh in
realtime, taps a rep row to verify the work behind a number, and
closes the app. Done.
Non-goals (YAGNI)
- PWA install prompt UI (manifest exists but no "Add to home screen" button).
- Push notifications on KPI thresholds.
- Historical week-over-week comparison.
- CSV export / share.
- Settings screen.
- Stock alerts, follow-ups overdue, supplier PO queues — those live on the operator dashboard. Out of scope per the user's explicit "just the KPIs you listed" answer.
Data model
Per-rep aggregations on the rep perspective:
| Stat | Definition |
|---|---|
quotesLoaded |
quotes.claimed_by = rep.id, count of rows where created >= windowStart and created < windowEnd. |
quotesDoneFor |
quotes joined to customer where customer.rep = rep.id, count of rows in window. |
quotesDoneBy |
quotes.claimed_by = rep.id, count of rows in window where status ∈ {emailed, approved, sent}. |
ordersPlaced |
orders.rep matches rep (PB id or resolved name), count of rows in window where item_status ∈ {pending, po_generated, assigned, collection_pool, collected, delivered, invoiced} (i.e. not cancelled). |
window is one of today | week | month:
today:[startOfDay(UTC), startOfTomorrow(UTC))week:[startOfWeek(UTC, monday), startOfNextWeek(UTC, monday))month:[startOfMonth(UTC), startOfNextMonth(UTC))
Driver stops are always today-only regardless of the active tab.
The orders.rep field historically stored a free-text name and
sometimes a 15-char PB id. The service uses the existing
resolveRepDisplayName helper from src/lib/server/order-rep.ts to
normalise to a rep id, then joins on users.id — same pattern the
operator dashboard already uses for per-customer rep resolution.
Users with claimed_by / rep values that no longer resolve to a
users row are bucketed under a single "(unknown)" rep in the output
so the page never silently drops activity.
Output shape
type BossDashboardData = {
window: "today" | "week" | "month";
rangeStart: string; // ISO
rangeEnd: string; // ISO
orderTotals: {
total: number; // orders created in window, not cancelled
delivered: number; // item_status === "delivered"
invoiced: number; // order.invoiced === true
};
reps: Array<{
id: string; // PB users.id, or "" for the (unknown) bucket
name: string; // "Gary Ndlovu" or "(unknown)"
isUnknown: boolean; // true → render muted, no avatar, no drill-down
quotesLoaded: number;
quotesDoneFor: number;
quotesDoneBy: number;
ordersPlaced: number;
}>;
drivers: Array<{
id: string; // PB users.id
name: string;
stopsToday: number;
stopsDone: number;
stopsLeft: number;
}>;
};
The endpoint returns ONE window at a time, not all three. Tab change →
new fetch with ?window=week. Payload stays small (~3-5 KB).
Files to create
src/lib/server/boss-dashboard-service.ts # getBossDashboardData({ window })
src/lib/server/boss-dashboard-service.test.ts # PB mocked, per-rep math + window boundaries
src/app/api/v1/boss-dashboard/route.ts # GET ?window=today|week|month, asserts session
src/app/api/v1/boss-dashboard/
└── rep-activity/route.ts # GET ?rep=<id>&window=<window>&kind=quotes|orders
# → paginated list for the bottom sheet
src/app/(mobile)/m/layout.tsx # phone shell, manifest link, no nav
src/app/(mobile)/m/page.tsx # server component, auth gate, initial fetch
src/app/(mobile)/m/dashboard-view.tsx # client: tabs, realtime, drill-downs
public/manifest-mobile.json # name, short_name, theme_color, display=standalone
Files to modify
next.config.*orapp/layout.tsx— register/mlayout's manifest link in the document head. (Confirmed: adding a<link rel="manifest">inside(mobile)/m/layout.tsxis enough; Next.js merges per-route head tags.)
Architecture & data flow
-
First paint (SSR):
- Server Component reads the PB cookie via
cookies()fromnext/headersand callsassertBrowserSession()(no-arg variant for server components — the route-handler variantassertBrowserSession(req)takesNextRequest). On failure the page throws a redirect to the PB login route. getBossDashboardData({ window: "today" })runs against the admin PB client.<BossDashboardView initial={...} />renders with data — no spinner.
- Server Component reads the PB cookie via
-
Tab change (client):
- User taps Week or Month.
- Client state updates
activeWindow. useEffectonactiveWindowcalls/api/v1/boss-dashboard?window=week.- Response replaces
data, skeleton flashes for ~80ms while fetch in flight.
-
Realtime (client):
- On mount, subscribe to PB
customers,quotes,orderscollections with*events. - Trailing-edge debounce 1000ms → re-fetch current window.
- Unsubscribe on unmount.
- On mount, subscribe to PB
-
Drill-down:
- Tap rep row → bottom sheet opens with two tabs (Quotes / Orders).
- Sheet calls
/api/v1/boss-dashboard/rep-activity?rep=<id>&window=<window>&kind=.... - Result is a flat array of
{ id, number, customer, status, age }— page renders rows. - Tap a row → opens
/quotes/<id>or/orders/<id>in a new tab on the phone.
-
Auth:
assertBrowserSessionagainst the running PB browser session. Boss logs in via the existing PB login page once. No new auth code, no new PIN. Two flavours:- Page (server component):
assertBrowserSession()reads the PB cookie fromnext/headers. Throws redirect on failure. - Route handler:
assertBrowserSession(req)takesNextRequest, forwards the bearer token / cookie, returns 401 on failure.
- Page (server component):
Components
(mobile)/m/layout.tsx
export default function MobileLayout({ children }: { children: React.ReactNode }) {
return (
<main className="min-h-screen bg-background text-foreground">
{children}
</main>
);
}
Also sets <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> and <link rel="manifest" href="/manifest-mobile.json"> via the route's head export.
(mobile)/m/page.tsx
Server component:
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export default async function BossDashboardPage() {
await assertBrowserSession();
const initial = await getBossDashboardData({ window: "today" });
return <BossDashboardView initial={initial} userName={user.name} />;
}
(mobile)/m/dashboard-view.tsx (client)
Sections, top to bottom:
-
Header strip — "Good {morning|afternoon|evening}, {firstName}." (first name =
user.name.split(" ")[0], matching the operator dashboard pattern) with refresh button (rotates icon while loading) andlast-refreshed {timeAgo}subtitle. -
Time tab bar — segmented control. Today / Week / Month. Active tab highlighted with accent colour, others muted. Single tap re-fetches.
-
Order totals strip — three small tiles: total / delivered / invoiced for the active window. One row, three cells.
-
Reps section — header "Sales reps" with count. One card per rep, sorted
ordersPlaced DESC, quotesLoaded DESC. Each card:- Avatar + name (left)
- 4 metric cells (loaded / done-for / done-by / orders) right-aligned with label-under-number
- Progress bar underneath showing
ordersPlaced / totalOrdersThisWindowas percentage - Tap → bottom sheet
-
Drivers section — header "Driver stops today ({totalStopsToday})" — the count in parentheses is the sum of all drivers'
stopsToday. One card per driver with stops-left badge coloured by urgency: red ifstopsLeft > 0 && stopsDone === 0(nothing started yet), yellow ifstopsLeft > 0 && stopsDone > 0(in progress), green ifstopsLeft === 0(all done).
Bottom sheet (<dialog> element)
Reuses the native <dialog> element with [open] toggling. Two tabs inside: Quotes / Orders. Each tab lazy-loads its data via the rep-activity endpoint. Rows render with avatar (initial), number, customer, status pill, relative age ("2h ago"). Tap row → window.open(/quotes/<id>, "_blank").
Empty / loading / error states
| State | Behaviour |
|---|---|
| Loading (first paint) | Server-rendered data, no spinner needed. |
| Loading (tab change) | Card-level skeleton, shimmer, ~80ms. |
| Error (PB 5xx) | Toast with retry button, single "Couldn't load" tile replaces the affected section. View keeps the previous data. |
| Empty reps | "No rep activity in this window." Card centred, muted text. |
| Empty drivers | "No driver stops scheduled today." |
| Network offline | Toast "Reconnecting…", disable refresh button. |
Service contract
getBossDashboardData({ window }: { window: "today" | "week" | "month" }): Promise<BossDashboardData>
Throws on PB errors with a .status property. Caller (route.ts) catches and returns 500 with { error: "..." } body. View renders error state on non-OK responses.
getBossRepActivity({ rep, window, kind, page }): Promise<{ items: Array<...>, hasMore: boolean }> — paginated 25 per page for the drill-down sheet.
Testing
boss-dashboard-service.test.ts— PB mocked viapocketbase-admintest pattern already in the repo. Cases:- Per-rep math correct (loaded / done-for / done-by / orders) for sample fixture.
- Window boundary correctness: today rolls over at UTC midnight, week on Monday, month on day 1.
- Unknown rep bucketing:
claimed_bystrings that don't resolve to ausers.idland in the(unknown)row. - Empty rep list still returns valid shape.
route.test.ts(one test file for both/api/v1/boss-dashboardand/api/v1/boss-dashboard/rep-activity):- 401 when no session.
- 400 on missing/invalid
window. - 200 with valid payload.
- No browser test in v1. User has
agent-browserconfigured and will verify in the running app.
Refresh model
PB realtime + 1s trailing-edge debounce. Same as /dashboard. If the
debounce proves noisy on a phone (battery, data), we can add a "Pause
live updates" toggle as a follow-up.
Out of scope reminders
Already listed. Re-emphasising here because the boss might be tempted to ask for them at demo time:
- PWA install button.
- Per-rep drill-down to "orders by status" — current drill-down is a flat list of records, not a per-status breakdown.
- Date scrubber — fixed today / this week / this month.
- Multi-tenant / white-label — single-tenant WFDASH, single boss.
Open questions
None. All clarifying questions resolved during brainstorm:
- Rep perspective on quote metrics — confirmed.
- Reps-only as the user scope — confirmed.
- Same PB session auth — confirmed.
- Today + week + month tabs (no toggle) — confirmed.
- PB realtime + debounce — confirmed.
- Tap-row-to-list drill-downs — confirmed.
- Standalone PWA at
/m— confirmed. - Layout B (tabs top, single table) — confirmed.
- "Just the KPIs you listed" — confirmed.
How to verify after implementation
pnpm dev— confirm no compile errors.- Open
http://localhost:4001/min the browser. - Confirm greeting + tab bar + reps + drivers render.
- Switch tab — confirm data refreshes.
- Open DevTools network tab, simulate a quote create in another tab — confirm debounced re-fetch fires within ~1s.
- Tap a rep row — confirm bottom sheet opens with quotes + orders.
- Tap a record inside the sheet — confirm
/quotes/<id>opens in a new tab. - Sign out, navigate to
/m— confirm redirect to PB login. pnpm test src/lib/server/boss-dashboard-service.test.ts— passes.pnpm test src/app/api/v1/boss-dashboard— passes.