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