From 2fac85e79dd768ff23809d61964244d7db06c0cb Mon Sep 17 00:00:00 2001 From: DEls Date: Fri, 28 Aug 2026 12:33:39 +0200 Subject: [PATCH] docs(spec): site overhead monthly vs one-off design --- ...site-overhead-monthly-and-oneoff-design.md | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-28-site-overhead-monthly-and-oneoff-design.md diff --git a/docs/superpowers/specs/2026-08-28-site-overhead-monthly-and-oneoff-design.md b/docs/superpowers/specs/2026-08-28-site-overhead-monthly-and-oneoff-design.md new file mode 100644 index 0000000..3b8ad96 --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-site-overhead-monthly-and-oneoff-design.md @@ -0,0 +1,236 @@ +# Site overhead — monthly recurring vs one-off + +**Date:** 2026-08-28 +**Status:** Approved (brainstorming) +**Scope:** Dashboard site detail page. Single-card split for monthly vs one-off overhead entries. Manual add surface for both types. + +--- + +## Problem + +The site detail page today has a single "Site P&G overheads" card that lists every `fixed_costs` row and treats it as a **monthly** cost — daily equivalent is always `cost / 21` working days. + +In practice, additional overhead often shows up **after** a project has started: things like a one-off compliance certificate, an emergency repair, a penalty, a survey fee. These need to be: + +- Logged against the same site (so they show up in the site's "Total spent") +- Distinguished from monthly overheads so the P&G category in the burn bar isn't inflated +- Addable from the dashboard (today, only n8n OCR writes to `fixed_costs`) + +There is no field on `fixed_costs`, no UI toggle, and no API route that distinguishes recurring from one-off. + +--- + +## Goals + +- Users can add a new overhead entry directly from the site detail page. +- Each entry is classified as either **monthly recurring** or **one-off** at entry time. +- Both types count toward the site's "Total spent" KPI and the sites-list roll-up. +- Existing rows are unchanged (backward-compatible). +- n8n OCR pipelines keep writing to the same collection with no changes. + +--- + +## Non-goals + +- Editing or deleting entries from the dashboard (manual entries today; can be added later). +- A new "audit" field tracking `logged_by: ocr | manual` (deferred). +- Renaming the card or moving it elsewhere on the page. +- Bulk import of one-off costs. +- Per-site reporting history or roll-back. + +--- + +## Design + +### 1. Architecture & data layer + +**Schema** — `scripts/pb-bootstrap-schema.py` (`fixed_costs` collection, around lines 147–159): + +```python +{"name": "is_recurring", "type": "bool", "required": False, "options": {"default": True}} +``` + +- Type `bool`, not required, default `true`. +- Existing rows behave identically without backfill (default true → still monthly). +- PocketBase collection default means n8n OCR writes (which don't set the field) get `true` automatically. + +**Type** — `src/lib/pb-types.ts` (`FixedCostRecord`, around lines 53–62): + +```ts +is_recurring?: boolean // default true. false = one-off. +``` + +The `PnGItem` interface in `src/app/dashboard/sites/[id]/site-detail-view.tsx` (lines 31–38) gains one field: + +```ts +is_recurring?: boolean +``` + +**New API route** — `src/app/api/sites/[id]/fixed-costs/route.ts`: + +- `POST` only (matches existing site route style). +- Path-scoped to site id; server derives `site` and `project_code` from the path. +- Body schema: + ```ts + { + description: string // required, 1–200 chars + cost: number // required, > 0 + date_logged: string // required, ISO YYYY-MM-DD + notes?: string // optional, ≤500 chars + is_recurring: boolean // required + } + ``` +- `201` + new record on success. `400` with `{ error: "validation", field?, message? }` on bad input. `404` if site id doesn't resolve. `500` if PB write throws. + +### 2. UI components + +**Modified card** — `src/app/dashboard/sites/[id]/site-detail-view.tsx` (currently lines 393–469): + +``` +┌─ Site P&G overheads ─────────────────────── [+ Add]┐ +│ Monthly preliminaries & general running costs. │ +├─────────────────────────────────────────────────────┤ +│ ▾ Monthly overheads N items │ +│ Description │ Notes │ Date │ Monthly │ Daily │ % │ +│ … existing rows … │ +│ │ +│ ▾ One-off costs M items │ +│ Description │ Notes │ Date │ Cost │ Daily │ % │ +│ … new rows … │ +└─────────────────────────────────────────────────────┘ +``` + +- Two stacked `` blocks inside the same `Card`. Each section is collapsible (default open) with a count chip. +- One-off section columns: Description / Notes / Date / **Cost** / Daily (= cost ÷ 21, shown for parity only — never used in math) / % of P&G total. +- Monthly section keeps the exact columns it has today. +- Single `+ Add` button at top-right. Opens a dialog. + +**Dialog** — new file `src/components/fixed-cost-dialog.tsx`: + +``` +┌─ Add overhead cost ────────────────────────� +│ Type: ( ) Monthly recurring │ +│ (•) One-off │ +│ │ +│ Description [____________________] │ +│ Date [YYYY-MM-DD] │ +│ Cost [R ______] │ +│ Notes [____________________] │ +│ │ +│ [Cancel] [Save] │ +└───────────────────────────────────────────┘ +``` + +- Type radio first — drives the entry's `is_recurring` value on submit. +- `Cost` input is `R-prefixed` (matches existing dashboard currency formatting). +- On submit: `POST /api/sites//fixed-costs`. Optimistic close + `router.refresh()`. + +### 3. Data flow + +**Read path** — `src/app/dashboard/sites/[id]/page.tsx` (server component, lines 49–97): + +``` +fetch fixed_costs WHERE site = + │ + ▼ +partition into: + monthlyItems = rows.filter(r => r.is_recurring !== false) + oneOffItems = rows.filter(r => r.is_recurring === false) + │ + ▼ +fixedSum = Σ monthlyItems.cost (unchanged) +oneOffSum = Σ oneOffItems.cost (NEW) + │ + ▼ +totalSpent = fixedSum + oneOffSum + payrollBurn + materialSum + fuelBurn +``` + +- `dailyCost` remains `cost / 21` for display, applied identically to both sections so the UI is consistent. +- `% of P&G` in both tables uses `cost / (fixedSum + oneOffSum)` so it sums to 100% across the whole card. + +**Sites list roll-up** — `src/app/dashboard/sites/page.tsx` (lines 31–82): same change — include `oneOffSum` in `spent` so site-card "Spent" totals stay consistent with the detail page. + +**Write path** (manual, new): + +``` +[+ Add] → Dialog → POST /api/sites/[id]/fixed-costs + │ + ▼ + pbAdmin().collection("fixed_costs").create({...}) + │ + ▼ + revalidatePath(`/dashboard/sites/${id}`) + router.refresh() on client +``` + +**Write path** (n8n OCR, unchanged): n8n keeps writing to `fixed_costs` directly via `pbAdmin()`. New rows without `is_recurring` default to `true`. + +**Empty states:** +- Both sections empty: today's single empty state ("No fixed costs logged for this site yet.") — unchanged. +- Only monthly empty: "No monthly overheads yet." +- Only one-off empty: "No one-off costs logged yet." + +### 4. Error handling + +| Condition | Response | +|---|---| +| Site id from path doesn't resolve | `404 { error: "site_not_found" }` | +| Body fails validation | `400 { error: "validation", field, message }` | +| `cost <= 0` | `400 { error: "validation", field: "cost", message: "Cost must be greater than zero." }` | +| `description` empty / > 200 chars | `400 { error: "validation", field: "description", message }` | +| `date_logged` not ISO YYYY-MM-DD | `400 { error: "validation", field: "date_logged", message }` | +| PocketBase write throws | `500 { error: "write_failed" }` — log full error server-side | + +- Inline field-level error from `400` responses (display under the offending field in the dialog). +- Generic "Couldn't save overhead" banner for `500`. +- Disable Save button while in-flight; show spinner on the Save button only. +- Closing the dialog mid-submit doesn't cancel the request; on success the dialog closes; on failure it stays open with the error. +- Server is source of truth for `site` + `project_code` (derived from path). +- `cost` stored as `number` (PocketBase `number`). No floating-point rounding in the API. + +### 5. Testing + +**Unit tests** (Vitest/Jest — match repo conventions): + +- `src/app/api/sites/[id]/fixed-costs/route.test.ts`: + - ✅ `201` with valid body — record returned + - ✅ `404` when site id missing + - ✅ `400` for each validation failure (description empty, cost 0, cost negative, date wrong format, is_recurring missing) + - ✅ `500` when PocketBase write throws (mock failure) + +- `src/lib/__tests__/partition-fixed-costs.test.ts` (pure function extracted from the page server component): + - ✅ Empty list → both arrays empty + - ✅ All `is_recurring: true` (or undefined) → monthly section only + - ✅ All `is_recurring: false` → one-off section only + - ✅ Mixed → correctly partitioned + - ✅ Totals match expected sums + +**Manual smoke test:** + +1. Open a site detail page. +2. Click `+ Add` → choose "One-off" → fill → save → row appears in "One-off costs" section. +3. Click `+ Add` → choose "Monthly recurring" → fill → save → row appears in "Monthly overheads" section. +4. Reload page — both rows persist. +5. Confirm "Total spent" KPI increased by the sum of both costs. +6. Confirm sites-list card for this site shows the same increased "Spent" total. +7. Confirm `Burn by category` stacked bar still uses `fixedSum` for the P&G segment (unchanged) but the bottom-line "Total spent" is correct (includes one-offs). + +--- + +## Files to create / modify + +- **Modify** `scripts/pb-bootstrap-schema.py` — add `is_recurring` field to `fixed_costs`. +- **Modify** `src/lib/pb-types.ts` — add `is_recurring?: boolean` to `FixedCostRecord`. +- **Create** `src/app/api/sites/[id]/fixed-costs/route.ts` — POST endpoint. +- **Create** `src/components/fixed-cost-dialog.tsx` — controlled dialog component. +- **Modify** `src/app/dashboard/sites/[id]/page.tsx` — partition fixed_costs into monthly/one-off, add `oneOffSum`, include in `totalSpent`. +- **Modify** `src/app/dashboard/sites/[id]/site-detail-view.tsx` — split card into two stacked sections, wire `+ Add` button + dialog, update `PnGItem` interface. +- **Modify** `src/app/dashboard/sites/page.tsx` — include `oneOffSum` in sites-list roll-up. +- **Create** `src/app/api/sites/[id]/fixed-costs/route.test.ts` — API route tests. +- **Create** `src/lib/__tests__/partition-fixed-costs.test.ts` — partition helper tests. + +--- + +## Open questions + +None — all design decisions resolved during brainstorming.