- 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
12 KiB
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
.xlsxfrom Numbers (File → Export To → Excel) and replacedata/source/original.xlsx. - Re-run
scripts/extract.mjsand 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:
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_expensesper month through to the chart. buildIncomeRowsalready has it — thread it into thebucketshape.
A.2 Zero is being thrown away as null
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
IncomeAreaCharton 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/#D1495Buntouched 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
ChartsGridtoolbar, beside the existing All / Profitable / Loss-making filter. Match that control's styling exactly. - Persist the choice in
localStorageso 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, andtrackingroutes 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 Costspage and sidebar entry. petty-cashstays, 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 / Jantjiewith a block subtotal of R157,200 (row 74), andOpp + Drywersat R235,000 (row 75). Only the R235,000 landed. The three named staff exist as rows in PocketBase but withamount_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-cashalready queriesCash 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.mjsand 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 wherevat / exclis 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 typecheckexits cleannpm run buildsucceeds- 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 Costshas a page - Overheads Roll-up reconciles to R689,640.49, asserted by the verify script
- Seed rejects rows where
incl < exclor 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