From 244fb14ce5dc1b99eabb3e51124eb4e5cc561814 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 2 Jun 2026 09:57:12 +0200 Subject: [PATCH] docs(reports): design spec for beta-finish polish (empty states + Operational area filter) Locked decisions from brainstorming: report-level empty states across Sales/Operational/Financial gated on a window-independent hasData flag; Operational gains an Area-only berth-scope filter (Status dropped as a light filter in this report); rep/source confirmed not applicable to Operational. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-06-02-reports-polish-design.md | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-02-reports-polish-design.md diff --git a/docs/superpowers/specs/2026-06-02-reports-polish-design.md b/docs/superpowers/specs/2026-06-02-reports-polish-design.md new file mode 100644 index 00000000..592ad19d --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-reports-polish-design.md @@ -0,0 +1,154 @@ +# Reports polish — beta-finish design + +**Date:** 2026-06-02 +**Initiative:** Launch-readiness Initiative 1 (Reports overhaul) — "Reports — what's left" gap audit. +**Goal (locked with user):** make the reports surface _feel finished for beta_ — every report opens cleanly even on an empty port, plus a modest, obviously-useful Operational filter. Not a deep power-filtering pass. + +## Scope + +Two pieces: + +1. **Report-level empty states** across Sales · Operational · Financial — one friendly "add X to see this" hero when the port has no underlying data, instead of a page scattered with per-chart "No data" badges. +2. **Operational Area filter** — a single berth-area multi-select that scopes the whole Operational report's berth-derived surfaces. + +### Out of scope (deferred, recorded in launch-readiness) + +- **Status filter** on Operational — turned out to be a _light_ filter here (can't retro-apply to historical trend charts; the vacant lists are available-by-definition). Defer until there's a general berth-inventory table where Status is genuinely useful. +- Other Operational dimensions (tenure type, document type). +- Rep / source filters on Operational — they don't map (berths have no assigned rep; tenancies have no lead source). +- Custom-builder, scheduling, and template gaps from the same audit. + +## Decisions locked + +| Question | Decision | +| ----------------------------- | ------------------------------------------------------------------------------------- | +| Polish goal | "Make reports feel finished for beta" (empty states + modest filter) | +| Operational filter dimensions | Area + Status chosen → **narrowed to Area only** after the Status-is-light finding | +| Operational filter reach | **Approach A — berth scope**: filters re-query the berth-derived surfaces server-side | +| Status handling | **Drop for now**; ship Area as the real scope | + +--- + +## Piece 1 — Report-level empty states + +### Data flow + +Each report's GET route adds one field, `hasData: boolean`, to its `data` payload. It is a **window-independent, port-scoped existence check** (ignores the selected date range) via a tiny `SELECT 1 … LIMIT 1` helper per report: + +- **Sales** (`/api/v1/reports/sales`) → does the port have **any** `interests` row? +- **Operational** (`/api/v1/reports/operational`) → does the port have **any** `berths` row? +- **Financial** (`/api/v1/reports/financial`) → does the port have **any** `payments` row **or** **any** `expenses` row? + +Window-independence is the design crux: it distinguishes a _brand-new port_ (show the onboarding hero) from _a port with history but nothing in the selected 30 days_ (show the normal report, whose per-chart empty states already degrade gracefully). Client-side inference from the payload can't tell those two apart — hence a server flag. + +### Component + +New `src/components/reports/shared/report-empty-state.tsx`: + +```tsx +interface ReportEmptyStateProps { + icon: LucideIcon; + title: string; + body: string; + actionLabel: string; + actionHref: Route; +} +``` + +A centered hero: named Lucide icon, title, one-line body, primary `Button` → `Link`. Visual language extends the existing inline `EmptyState` in `sales-report-client.tsx` (muted, centered) but elevated to full-report scale (more vertical padding, larger icon). Lives in `reports/shared/` so all three clients import it. No decorative emoji — named icon components only. + +### Client wiring (3 report clients) + +After the query resolves: if `data && data.hasData === false`, render `` in place of the report body. **Keep the `PageHeader`** so the page retains its title; disable the export/template buttons (no data to export). Keep skeletons while `query.isLoading`. + +### Copy + targets + +Plain text, no emoji. + +| Report | Icon | Title | Body | Action → href | +| ----------- | ------------ | --------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------- | +| Sales | `TrendingUp` | "No sales activity yet" | "Once you add clients and log interests, this report fills with win rates, pipeline value, and deal heat." | "Add an interest" → `/[portSlug]/interests` | +| Operational | `Anchor` | "No berths yet" | "Add berths to see utilisation, occupancy, and signing turnaround." | "Add berths" → `/[portSlug]/berths` | +| Financial | `Wallet` | "No financial activity yet" | "Record a payment on a deal or log an expense to see revenue, deposits, and cash flow." | "Go to expenses" → `/[portSlug]/expenses` | + +--- + +## Piece 2 — Operational Area filter (Approach A: berth scope) + +### Parsing + +New pure, unit-tested module `src/lib/services/reports/operational-filters.ts`, mirroring `sales-filters.ts`: + +- `OperationalFilters = { areas?: string[] }` — extensible shape (Status can be added later without a rename). +- `parseOperationalFilters(params: URLSearchParams): OperationalFilters | undefined` — reads the `area` CSV param as a free list (port-defined strings; Drizzle parameterizes the downstream `inArray`, so unvalidated values are injection-safe). Empty/whitespace entries dropped. Returns `undefined` when no areas → no filter. + +### Area options + +New `getOperationalAreaOptions(portId: string): Promise` — `SELECT DISTINCT area FROM berths WHERE port_id = ? AND area IS NOT NULL ORDER BY area`. Returned in the payload as `areaOptions` (mirrors Sales' `repOptions`). The shared `FilterBar` auto-hides a multi-select with no options, so the Area control simply doesn't render for a port with no areas defined. + +### Where Area applies + +Area is a **scope** over the berth-derived surfaces. It threads into these service fns as an optional `filters?: OperationalFilters` arg, adding `inArray(berths.area, filters.areas)` when present (index-backed by `idx_berths_area`): + +- `getOperationalKpis` (berth counts: total / sold % / under-offer %) +- `getOccupancyByArea` +- `getUtilisationHeatmap` +- `getVacantBerths` +- `getHighestValueVacant` + +**Left port-wide (unfiltered):** status-mix-over-time trend, tenancy churn / tenure / ending-soon, signing box plot, documents-in-pipeline, stuck-signing. A small caption ("Scoped to {areas}") appears on the filtered cards; the port-wide panels are visually unchanged. + +### UI + +The Operational report adds the shared `FilterBar` with a **single Area multi-select**, placed at the **top of the report next to the `DateRangePicker`** — because Area scopes the whole report (unlike Sales, where the FilterBar sits above the detail tables because it only scopes those tables). The Operational report currently has no FilterBar; this introduces it. + +### Template config + +The Operational template config (`{ kind: 'operational', range, statusMixMode }`) gains `filters: { areas?: string[] }` so a saved template round-trips its area scope. Changing the area clears the active-template badge (same pattern as `handleRangeChange` / Sales `handleFilterChange`); applying a template restores it via the raw setter. + +--- + +## Files + +**New (4):** + +- `src/lib/services/reports/operational-filters.ts` — `parseOperationalFilters` + `getOperationalAreaOptions` +- `src/components/reports/shared/report-empty-state.tsx` — shared hero +- `tests/unit/reports/operational-filters.test.ts` +- `tests/unit/reports/report-has-data.test.ts` — the three existence helpers + +**Modified (~10):** + +- `src/app/api/v1/reports/sales/route.ts` — `+ hasData` +- `src/app/api/v1/reports/operational/route.ts` — `+ hasData`, `+ areaOptions`, parse + thread area filter +- `src/app/api/v1/reports/financial/route.ts` — `+ hasData` +- `src/components/reports/sales/sales-report-client.tsx` — empty-state wiring +- `src/components/reports/operational/operational-report-client.tsx` — empty-state + FilterBar/area scope + template config +- `src/components/reports/financial/financial-report-client.tsx` — empty-state wiring +- `src/lib/services/reports/operational.service.ts` — optional `filters` on 5 fns + area-options query + `operationalHasData(portId)` helper +- `src/lib/services/reports/sales.service.ts` — `salesHasData(portId)` helper +- `src/lib/services/reports/financial.service.ts` — `financialHasData(portId)` helper + +Each `hasData` helper lives in its report's service file alongside that report's other queries (consistent with the existing one-service-per-report layout), and is the single existence check the route awaits in its `Promise.all`. + +- `docs/launch-readiness.md` — mark the empty-state + Operational-filter items shipped + +## Testing (TDD) + +Write tests first: + +1. `parseOperationalFilters` — single area, CSV multi, whitespace trimming, empty → `undefined`, no `area` param → `undefined`. +2. The three `hasData` helpers — return `false` for a port with no rows, `true` once a row exists, correct port isolation. + +Then implement to green, then browser-verify on `port-nimara`: + +- Area multi-select renders, narrows occupancy-by-area + vacant lists + berth-count KPIs; port-wide panels unchanged; "Scoped to {area}" caption shows. +- Empty-state heroes render for an empty port (force `hasData=false` if `port-nimara` has data) with correct copy + working action links. +- `pnpm exec tsc --noEmit` clean; affected unit tests green. + +## Edge cases + +- Berths with `area = NULL` — excluded from `areaOptions`; an active area filter hides them (correct: they're not in any selected area). +- Area filter matching nothing → filtered surfaces fall back to their existing per-chart empty states (NOT the report-level hero, because the port _does_ have data). +- `hasData` ignores the date window entirely — a port with old-but-real data never shows the onboarding hero. +- Export/template buttons disabled in the empty-state view (nothing to export).