mega-plant/HANDOFF-PROMPT-3.md

279 lines
12 KiB
Markdown
Raw Normal View History

# Mega Plant — Round 3
Round 2 landed well: 44 clean assets, `cost_lines` created, 40/40 machines with
income, `running_cost` de-duplicated, register flattened. This round is charts and
the expenses/overheads pages.
## Ground rules (unchanged)
**DO NOT restyle anything.** Palette, roundness, gradients, spacing, dark mode stay
as they are. The only colour change permitted is the chart-mark steps in A.3, which
are deeper steps of the *same hues*.
**Do not invent data to make a step pass.** Unmatched or missing rows get reported,
never synthesised.
**Verify and report.** `npm run typecheck` after each part; paste what it output.
---
# Part 0 — Source of truth (blocking)
`data/source/original.xlsx` is a snapshot of an Apple Numbers file. The live file
`Opdateerde Grafieke Augustus.numbers` was **edited 2026-09-12 at 10:51**, after that
xlsx was taken. Numbers stores its data as Snappy-compressed protobuf (`Index/*.iwa`)
and cannot be parsed by `xlsx` or any tool in this project.
- [ ] Ask the client to re-export the workbook as `.xlsx` from Numbers
(File → Export To → Excel) and replace `data/source/original.xlsx`.
- [ ] Re-run `scripts/extract.mjs` and diff the output against the committed JSON.
**Report every value that changed.**
- [ ] Everything below is verified against the 2026-09-11 snapshot. If the re-export
differs, re-check the figures in Part B before acting on them.
---
# Part A — Machine charts
## A.1 The Fixed series is hardcoded to nothing
`app/charts/page.tsx` line 20:
```ts
const fixed = Array(12).fill(null).map((_, i) => null); // not surfaced on chart
```
`ChartsGrid` meanwhile renders a legend with an amber "Fixed" swatch. The legend
advertises a line the code guarantees can never draw. This is the bug the client
reported as "I only see 2 lines".
- [ ] Pass the real `fixed_expenses` per month through to the chart.
- [ ] `buildIncomeRows` already has it — thread it into the `bucket` shape.
## A.2 Zero is being thrown away as null
```ts
const income = r.months.map((v) => (v > 0 ? v : null));
```
A machine that genuinely earned **R0** in a month becomes a *gap* in the line, not a
zero. With `connectNulls={false}` the line fragments into disconnected stubs.
This matters more than it looks: **16 of 44 machines earned nothing all year**, so
their income line currently doesn't exist at all — leaving a lone red running-cost
line floating in space.
- [ ] Distinguish "no data for this month" (null — Sep–Dec, forecast) from "earned
zero" (0 — a real, plotted value).
- [ ] Only months beyond the last actual month are null.
## A.3 Charts span Jan–Dec but data ends in Aug
40% of every card is empty whitespace, and the series flatline to zero at September,
which reads as though the business collapsed.
- [ ] Clip the x-axis to the last actual month (Aug). Do not plot empty months as 0.
- [ ] Same fix applies to `IncomeAreaChart` on the dashboard — it has the identical
September cliff.
## A.4 Chart marks need deeper steps of the same hues
I ran the current palette through a contrast/CVD validator. Two hard failures on a
white card surface:
```
[FAIL] Lightness band #EDAE49 at L 0.792 — outside band
[FAIL] Chroma floor #00798C at 0.093 — reads grey
[WARN] Contrast #EDAE49 at 1.95:1 vs white — under the 3:1 floor
```
So even once A.1 is fixed, the amber Fixed line would be nearly invisible. **The
brand palette does not change** — chart marks get deeper steps of the same hues.
These values pass every check in both modes:
| Series | Light | Dark |
| --- | --- | --- |
| Income | `#0090A6` | `#00A6BE` |
| Fixed | `#BE851F` | `#BE851F` |
| Running | `#C13B4D` | `#D94F60` |
- [ ] Define these as CSS variables in `app/globals.css` (e.g. `--chart-income`,
`--chart-fixed`, `--chart-running`) with dark-mode overrides, and read them in
the chart components. Do not hardcode hex in three files.
- [ ] Leave `#EDAE49` / `#00798C` / `#D1495B` untouched everywhere else — they stay
the UI palette.
- [ ] In dark mode the amber/red pair sits at CVD ΔE 7.5, which is only acceptable
with a secondary cue. Keep the **2px surface-coloured gap** between stacked bar
segments. Do not remove it.
## A.5 Add a chart-type toggle
The client wants the existing line chart kept, because it's what the spreadsheet
shows and the team is used to reading it. Add a toggle offering three views, applied
to **all cards at once**.
**View 1 — "Lines" (default).** The current multi-line chart, fixed per A.1–A.4.
Income, Fixed, and Running as three lines. This mirrors the workbook.
**View 2 — "Cost stack".** A composed chart:
- Stacked bars: `fixed` + `running` = total monthly cost
- Line overlaid: `income`
- Profit is the visible gap between the bar top and the line — do **not** plot it as
a fourth series
- This is the only view that shows *why* a month was bad (instalment vs repair spike)
**View 3 — "Profit".** Single diverging bar series, `income − fixed`. Teal above the
zero line, red below. Fastest to scan across 44 cards.
- [ ] Segmented control in the `ChartsGrid` toolbar, beside the existing
All / Profitable / Loss-making filter. Match that control's styling exactly.
- [ ] Persist the choice in `localStorage` so it survives navigation.
- [ ] The legend must change with the view — "Profit / Loss" for view 3, not
"Income / Fixed / Running".
- [ ] Every view clips to the last actual month (A.3).
## A.6 Profit must come from `computeProfit()`
View 3 shows profit. `lib/profit.ts` is the single source of truth and currently
defaults to `income − fixed` (the workbook rule, Open Question 1 still unresolved).
- [ ] The chart must call `computeProfit()`, never compute inline. If Q1 is later
answered "include running cost", the chart must follow automatically.
---
# Part B — Expenses and Overheads roll-up
These pages are wrong in three separate ways.
## B.1 Four sidebar pages render completely empty
Round 2 moved machine instalments into `assets` and insurance/tracking into
`cost_lines` — correctly. But the expense pages still query the old sections, which
no longer have any rows.
Live sections in `overheads`: `Vehicle Costs`, `Bastion Finance`, `Personnel`,
`Subscriptions`, `Rent & Utilities`.
| Route | Queries section | Rows | Result |
| --- | --- | --- | --- |
| `/expenses/plant-overheads` | `Plant Overheads` | **0** | renders R 0, "0 entries" |
| `/expenses/insurance` | `Insurance` | **0** | renders R 0, "0 entries" |
| `/expenses/tracking` | `Tracking` | **0** | renders R 0, "0 entries" |
| `/expenses/petty-cash` | `Cash Floats` | **0** | section never existed |
And the reverse problem: **`Vehicle Costs` (4 rows, R14,996.31) is live but has no
page and no sidebar entry.** It appears in the roll-up and the donut, with nowhere to
drill into.
- [ ] Delete the `plant-overheads`, `insurance`, and `tracking` routes and their
sidebar entries. That data now lives on the asset, not in overheads —
surface it on the machine detail view instead.
- [ ] Add a `Vehicle Costs` page and sidebar entry.
- [ ] `petty-cash` stays, but needs its data — see B.3.
- [ ] **Add an empty state to `ExpenseSectionPage`.** A section with no rows must say
so plainly, not render a table of zeros. This class of bug should be visible
the moment it happens.
## B.2 The roll-up total is R697,201.50 short — and only part of that is correct
The workbook's own grand total (Cost sheet, row 98) is **R1,110,486.87**. The app's
Overheads Roll-up shows **R413,285.37**.
The gap decomposes exactly:
| Amount | What | Verdict |
| ---: | --- | --- |
| R415,523.94 | `Plant Overheads` — machine instalments | ✅ correctly excluded in round 2 |
| R177,000.00 | Personnel not captured | ❌ missing |
| R100,000.00 | Maintenance | ❌ missing entirely |
| R10,000.00 | Petty Cash | ❌ missing entirely |
| −R5,322.44 | Santam versekering | ✅ app is right, workbook is wrong (see B.4) |
| **R697,201.50** | | |
- [ ] **Personnel.** The workbook has two blocks: `Attie / Sandrea / Jantjie` with a
block subtotal of R157,200 (row 74), and `Opp + Drywers` at R235,000 (row 75).
Only the R235,000 landed. The three named staff exist as rows in PocketBase but
with `amount_incl = 0`. Capture the R157,200. **Note the workbook's own
Personnel total (row 77) says R412,000, but 157,200 + 235,000 = 392,200 — a
R19,800 discrepancy in the source. Report it, do not paper over it.**
- [ ] **Maintenance R100,000** (row 97) and **Petty Cash R10,000** (row 96) are
floating cells with no section header. Give them real sections
(`Maintenance`, `Cash Floats`) and seed them. `petty-cash` already queries
`Cash Floats`.
- [ ] Once fixed, the roll-up should reconcile to R1,110,486.87 **minus** the
R415,523.94 of plant instalments **minus** the R5,322.44 workbook error
= **R689,640.49**. Print this reconciliation in
`scripts/verify-cost-reconciliation.mjs` and fail if it drifts.
- [ ] The roll-up page currently calls itself "Full monthly overhead summary", which
invites comparison against the workbook's R1.11M. Add a visible note that plant
instalments are excluded because they live on the asset.
## B.3 Two Subscriptions rows have broken VAT in the source
```
Charl - CCS excl 1,970.00 vat 2,265.50 incl 2,265.50
Buildsmart Yearly update excl 14,827.91 vat 2,224.19 incl 1,421.01
```
- `Charl - CCS` — VAT of R2,265.50 on R1,970.00 is 115%. The *incl* value has been
pasted into the VAT cell. Correct: excl 1,970.00, vat 295.50, incl 2,265.50.
- `Buildsmart` — **incl is less than excl**, which is impossible. 14,827.91 × 1.15 =
**17,052.10**, not 1,421.01. The formula is broken in the workbook.
The Subscriptions section total of R6,159.01 is therefore understated by roughly
R15,631. Corrected it is about **R21,790.10**.
- [ ] Do **not** silently correct these. Flag them to the client and get the right
figures confirmed.
- [ ] Add a validation pass to the seed: reject any row where `incl < excl`, or where
`vat / excl` is outside 0–20%. Report and exit non-zero. Both these rows should
trip it.
## B.4 The workbook's Vehicle Costs subtotal skips a row
Cost sheet row 50 totals `8,875.51 excl / 9,673.87 incl`. The four rows above it sum
to `13,399.57 excl / 14,996.31 incl`. The difference is exactly `Santam versekering`
(R4,524.07 excl / R5,322.44 incl) — the SUM range starts one row too low.
**The app's R14,996.31 is correct; the spreadsheet is wrong.**
- [ ] No code change. Tell the client — this error also flows into their grand total.
- [ ] Note it in the reconciliation script so the R5,322.44 delta is expected, not a
surprise.
---
# Part C — Open questions still outstanding
Neither has been answered; both still block a correct cost model.
**Q1 — does profit include running cost?** Workbook says no (`=SUM(H4-I4)`).
`lib/profit.ts` defaults to no, matching the workbook. Needs a client decision.
**Q2 — what is term 3 of the fixed-expense formula?** Terms 1 and 2 are confirmed
as instalment and insurance (`TLB06: 29036.28 + 2401.81 + 5620.2` matches the
`assets` rows exactly). Term 3 varies per machine *and* per month — `5620.20`,
`6437.20`, `7315.95`, `10653.73`. Most likely operator wages. **Still needs the
client.** Until it's answered, `fixed_expenses` is a copied number, not a derived one.
---
# Acceptance criteria
- [ ] `npm run typecheck` exits clean
- [ ] `npm run build` succeeds
- [ ] Machine cards show **three** series in Lines view; the legend matches what draws
- [ ] A machine with R0 income in a month shows a plotted zero, not a gap
- [ ] No chart plots past the last actual month
- [ ] Chart toggle offers Lines / Cost stack / Profit, persists across navigation,
and the legend changes with the view
- [ ] Profit anywhere on the charts page comes from `computeProfit()`
- [ ] Chart marks use the validated hues; the 2px stacked-segment gap is present
- [ ] No sidebar route renders an empty table; `Vehicle Costs` has a page
- [ ] Overheads Roll-up reconciles to R689,640.49, asserted by the verify script
- [ ] Seed rejects rows where `incl < excl` or VAT is outside 0–20%
- [ ] The Personnel R19,800 discrepancy, the two Subscriptions VAT errors, and the
Santam subtotal error are all reported to the client, not silently fixed
- [ ] **Palette, roundness, gradients, and dark mode visually unchanged**