mega-plant/HANDOFF-PROMPT-3.md
Mega Plant 7194e78079 Round 3 (Parts A-B): charts fixes + expenses/overheads cleanup
- A.1: fixed_expenses per month now threaded through to chart bucket
- A.2: null = no data (forecast), 0 = real plotted zero — distinguishable
- A.3: x-axis clipped to last actual month (IncomeAreaChart + IncomeBarChart + MachineLineChart)
- A.4: chart hues as CSS vars (--color-chart-income/fixed/running) — deeper steps of same hues, validated for CVD
- A.5: chart-type toggle (Lines / Cost stack / Profit) + localStorage persistence + dynamic legend
- A.6: profit on charts page comes from computeProfit()
- B.1: deleted /expenses/plant-overheads, /expenses/insurance, /expenses/tracking (all empty); added /expenses/vehicles; ExpenseSectionPage now has an empty state
- B.2: captured R 157,200 Personnel block, R 100,000 Maintenance, R 10,000 Petty Cash; roll-up reconciles to R 675,162.93 (= sum of non-Plant non-Santam rows; workbook grand total R 1,110,486.87 is internally off by R 14,477.56 vs its own rows)
- B.3: seed flags incl<excl, VAT>20%, broken VAT math — does NOT silently correct; 4 anomalies reported on Charl-CCS + Buildsmart
- B.4: noted that workbook Vehicle Costs subtotal skips Santam (app R 9,673.87 is correct; workbook is wrong)
- B.2 reconciliation assertion added to verify-cost-reconciliation.mjs
- Sidebar updated: Vehicles added, Insurance/Tracking/Plant-Overheads removed
2026-09-12 11:24:18 +02:00

278 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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**