diff --git a/docs/superpowers/specs/2026-08-27-boss-phone-dashboard-design.md b/docs/superpowers/specs/2026-08-27-boss-phone-dashboard-design.md new file mode 100644 index 0000000..0de8664 --- /dev/null +++ b/docs/superpowers/specs/2026-08-27-boss-phone-dashboard-design.md @@ -0,0 +1,283 @@ +# 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: + +1. How many quotes did each rep load, do for their accounts, and push through to emailed/approved/sent today / this week / this month? +2. How many orders did each rep place today / this week / this month? +3. 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 + +```ts +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=&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.*` or `app/layout.tsx` — register `/m` layout's manifest link in the + document head. (Confirmed: adding a `` inside + `(mobile)/m/layout.tsx` is enough; Next.js merges per-route head tags.) + +## Architecture & data flow + +1. **First paint (SSR):** + - Server Component reads the PB cookie via `cookies()` from `next/headers` + and calls `assertBrowserSession()` (no-arg variant for server components — + the route-handler variant `assertBrowserSession(req)` takes `NextRequest`). + On failure the page throws a redirect to the PB login route. + - `getBossDashboardData({ window: "today" })` runs against the admin PB client. + - `` renders with data — no spinner. + +2. **Tab change (client):** + - User taps Week or Month. + - Client state updates `activeWindow`. + - `useEffect` on `activeWindow` calls `/api/v1/boss-dashboard?window=week`. + - Response replaces `data`, skeleton flashes for ~80ms while fetch in flight. + +3. **Realtime (client):** + - On mount, subscribe to PB `customers`, `quotes`, `orders` collections + with `*` events. + - Trailing-edge debounce 1000ms → re-fetch current window. + - Unsubscribe on unmount. + +4. **Drill-down:** + - Tap rep row → bottom sheet opens with two tabs (Quotes / Orders). + - Sheet calls `/api/v1/boss-dashboard/rep-activity?rep=&window=&kind=...`. + - Result is a flat array of `{ id, number, customer, status, age }` — page renders rows. + - Tap a row → opens `/quotes/` or `/orders/` in a new tab on the phone. + +5. **Auth:** `assertBrowserSession` against 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 + from `next/headers`. Throws redirect on failure. + - **Route handler:** `assertBrowserSession(req)` takes `NextRequest`, + forwards the bearer token / cookie, returns 401 on failure. + +## Components + +### `(mobile)/m/layout.tsx` + +```tsx +export default function MobileLayout({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} +``` + +Also sets `` and `` via the route's head export. + +### `(mobile)/m/page.tsx` + +Server component: +```tsx +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export default async function BossDashboardPage() { + await assertBrowserSession(); + const initial = await getBossDashboardData({ window: "today" }); + return ; +} +``` + +### `(mobile)/m/dashboard-view.tsx` (client) + +Sections, top to bottom: + +1. **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) and `last-refreshed {timeAgo}` subtitle. + +2. **Time tab bar** — segmented control. Today / Week / Month. Active tab highlighted with accent colour, others muted. Single tap re-fetches. + +3. **Order totals strip** — three small tiles: total / delivered / invoiced for the active window. One row, three cells. + +4. **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 / totalOrdersThisWindow` as percentage + - Tap → bottom sheet + +5. **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 if `stopsLeft > 0 && stopsDone === 0` (nothing started yet), yellow if `stopsLeft > 0 && stopsDone > 0` (in progress), green if `stopsLeft === 0` (all done). + +### Bottom sheet (`` element) + +Reuses the native `` 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/, "_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` + +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 via `pocketbase-admin` test 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_by` strings that don't resolve to a `users.id` land in the `(unknown)` row. + - Empty rep list still returns valid shape. +- **`route.test.ts`** (one test file for both `/api/v1/boss-dashboard` and `/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-browser` configured 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 + +1. `pnpm dev` — confirm no compile errors. +2. Open `http://localhost:4001/m` in the browser. +3. Confirm greeting + tab bar + reps + drivers render. +4. Switch tab — confirm data refreshes. +5. Open DevTools network tab, simulate a quote create in another tab — + confirm debounced re-fetch fires within ~1s. +6. Tap a rep row — confirm bottom sheet opens with quotes + orders. +7. Tap a record inside the sheet — confirm `/quotes/` opens in a new tab. +8. Sign out, navigate to `/m` — confirm redirect to PB login. +9. `pnpm test src/lib/server/boss-dashboard-service.test.ts` — passes. +10. `pnpm test src/app/api/v1/boss-dashboard` — passes. \ No newline at end of file