mega-plant/components/MachineLineChart.tsx

358 lines
15 KiB
TypeScript
Raw Normal View History

"use client";
import {
2026-09-12 10:05:43 +00:00
ComposedChart, Line, LineChart, Bar, AreaChart, Area, Cell,
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine,
} from "recharts";
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
import { useEffect, useId, useState } from "react";
import { MONTH_NAMES } from "@/lib/data";
feat(running): drive the charts and the profit from the running-cost register The machine charts plotted the workbook's monthly_income.fixed_expenses, which is NOT the app's running cost. So the charts and the /running Overview disagreed about the same machine in the same month — CT01 by R 40 640, DT01 by R 121 115. The workbook's figure also carries depreciation/ownership and an operator allocation, which are capital recovery and overhead rather than running cost. Every cost value now comes from the running-cost register: fixed = insurance + financing, running = purchases. Profit is income - fixed - running, and it is ONE formula on every surface, so /charts, /income and the dashboard quote the same figure for the same machine. R 8 456 243 fleet income, Jan-Oct - R 4 705 292 fixed (insurance + financing, from the register) - R 306 387 running (purchases, from the register) = R 3 444 564 profit (was R 2 510 908 on the workbook's fixed) The legacy repairs sheet comes out completely. It was the old workbook's "Plant Monthly Running Cost" — read by no page, maintained by nobody — and a repair is bought on a purchase order, so its money is already in Purchases. The collection is still seeded, so the history is not lost, but it is no longer in any total, any chart or any profit. Also: - the profit badge wears the CHART's own green, and profit is green and loss red on every surface. It was hard-coded to the teal brand colour, so the label and the line it labelled were two different colours. - September and October now state a profit instead of a dash: the dash existed because the workbook had not posted those months' fixed costs, and the register has the premium and the instalment for every month. - the containers and the water trailers show profit equal to their income, because the running-cost model carries no cost for them. Overheads roll-up — an audit found three things out of order, and the roll-up total itself reconciles exactly (R 580 485,37, unexplained R 0,00): - the dashboard's Overhead mix legend named only five of the six sections, so the figures it listed added up to R 565 489 against a headline of R 580 485, and the donut drew a slice nobody could name. - the /overheads note claimed machine instalments were excluded, while the Bastion Finance block on the same page carried R 115 575,94 of them and R 11 204,76 of insurance for the same machines. The note now says so, with the figures read off the rows at render time. - two Subscriptions rows had VAT that did not balance — the sheet had the TOTAL in the VAT cell (115%), and annual excl/VAT against a monthly total. Both are corrected on the client's instruction; the roll-up total does not move, only the VAT breakdown does, and the seed's VAT gate now passes instead of exiting non-zero. Verified: 161 tests pass (4 new ones pin the register join), the production build is clean, the reconciliation script passes, and the charts, the profit label and the overhead legend were checked on screen.
2026-11-05 19:24:30 +00:00
import { computeProfit } from "@/lib/profit";
import { buildProfitSeries } from "@/lib/chart-profit";
export type ChartView = "lines" | "stack" | "profit" | "profit-line";
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
// Which lines the card draws. A hidden series disappears from the plot AND from
// its axis, so the reader never sees an axis for a line that is not there.
export type SeriesVisible = {
income?: boolean;
fixed?: boolean;
running?: boolean;
profit?: boolean;
};
type Props = {
income: (number | null)[];
fixed: (number | null)[];
running: (number | null)[];
view?: ChartView;
incomeColor?: string;
fixedColor?: string;
runningColor?: string;
profitColor?: string;
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
lossColor?: string;
showProfitLine?: boolean;
series?: SeriesVisible;
};
function readVar(name: string, fallback: string): string {
if (typeof window === "undefined") return fallback;
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || fallback;
}
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
// Round 4 mobile rule: no chart text under 12px on a phone, so ticks step up to 12
// below sm and stay at 10 on desktop where there is room for them.
function useIsNarrow() {
const [narrow, setNarrow] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(max-width: 639px)");
const sync = () => setNarrow(mq.matches);
sync();
mq.addEventListener("change", sync);
return () => mq.removeEventListener("change", sync);
}, []);
return narrow;
}
type Point = {
month: string;
profit: number | null;
};
// The profit line is drawn as several series (one per same-sign run, one per
// crossing, plus a transparent one that carries the dots) so it can be coloured
// per segment. Those internal keys are an implementation detail and must never
// reach the user, and "profit" is a computed figure rather than a raw column, so
// its own Tooltip formatter cannot label it. A custom tooltip is the only place
// that knows both: it drops every profit-backed series and adds one clean row.
type TooltipItem = {
dataKey?: string | number;
name?: string;
value?: number | string | null;
color?: string;
stroke?: string;
feat(running): drive the charts and the profit from the running-cost register The machine charts plotted the workbook's monthly_income.fixed_expenses, which is NOT the app's running cost. So the charts and the /running Overview disagreed about the same machine in the same month — CT01 by R 40 640, DT01 by R 121 115. The workbook's figure also carries depreciation/ownership and an operator allocation, which are capital recovery and overhead rather than running cost. Every cost value now comes from the running-cost register: fixed = insurance + financing, running = purchases. Profit is income - fixed - running, and it is ONE formula on every surface, so /charts, /income and the dashboard quote the same figure for the same machine. R 8 456 243 fleet income, Jan-Oct - R 4 705 292 fixed (insurance + financing, from the register) - R 306 387 running (purchases, from the register) = R 3 444 564 profit (was R 2 510 908 on the workbook's fixed) The legacy repairs sheet comes out completely. It was the old workbook's "Plant Monthly Running Cost" — read by no page, maintained by nobody — and a repair is bought on a purchase order, so its money is already in Purchases. The collection is still seeded, so the history is not lost, but it is no longer in any total, any chart or any profit. Also: - the profit badge wears the CHART's own green, and profit is green and loss red on every surface. It was hard-coded to the teal brand colour, so the label and the line it labelled were two different colours. - September and October now state a profit instead of a dash: the dash existed because the workbook had not posted those months' fixed costs, and the register has the premium and the instalment for every month. - the containers and the water trailers show profit equal to their income, because the running-cost model carries no cost for them. Overheads roll-up — an audit found three things out of order, and the roll-up total itself reconciles exactly (R 580 485,37, unexplained R 0,00): - the dashboard's Overhead mix legend named only five of the six sections, so the figures it listed added up to R 565 489 against a headline of R 580 485, and the donut drew a slice nobody could name. - the /overheads note claimed machine instalments were excluded, while the Bastion Finance block on the same page carried R 115 575,94 of them and R 11 204,76 of insurance for the same machines. The note now says so, with the figures read off the rows at render time. - two Subscriptions rows had VAT that did not balance — the sheet had the TOTAL in the VAT cell (115%), and annual excl/VAT against a monthly total. Both are corrected on the client's instruction; the roll-up total does not move, only the VAT breakdown does, and the seed's VAT gate now passes instead of exiting non-zero. Verified: 161 tests pass (4 new ones pin the register join), the production build is clean, the reconciliation script passes, and the charts, the profit label and the overhead legend were checked on screen.
2026-11-05 19:24:30 +00:00
payload?: { profit?: number | null };
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
};
type TooltipProps = {
active?: boolean;
label?: string | number;
payload?: TooltipItem[];
showProfit: boolean;
fmt: (v: number) => string;
up: string;
down: string;
};
const INTERNAL_SERIES = /^(profitRun|profitCross)/;
function ChartTooltip({ active, label, payload, showProfit, fmt, up, down }: TooltipProps) {
if (!active || !payload?.length) return null;
const rows: { name: string; text: string; color: string }[] = [];
for (const p of payload) {
const key = String(p.dataKey ?? "");
if (INTERNAL_SERIES.test(key)) continue;
// The dot-carrying "profit" series is replaced by the single computed row below.
if (key === "profit") continue;
const name = p.name ?? key;
const value = p.value;
if (value == null) continue;
rows.push({ name, text: fmt(Number(value)), color: p.color || p.stroke || "#9ca3af" });
}
if (showProfit) {
const row = payload[0]?.payload;
if (row && row.profit != null) {
rows.push({
name: "Profit",
text: fmt(row.profit),
color: row.profit >= 0 ? up : down,
});
}
}
if (!rows.length) return null;
return (
// Reuse the recharts class names so globals.css keeps styling this tooltip.
<div className="recharts-default-tooltip">
<div className="recharts-tooltip-label">{label}</div>
{rows.map((r) => (
<div
key={r.name}
className="recharts-tooltip-item"
style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 2 }}
>
<span
style={{ width: 8, height: 8, borderRadius: 2, background: r.color, flexShrink: 0 }}
/>
<span style={{ opacity: 0.65 }}>{r.name}</span>
<span style={{ marginLeft: "auto", fontWeight: 600 }}>{r.text}</span>
</div>
))}
</div>
);
}
export function MachineLineChart({
income, fixed, running,
view = "lines",
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
incomeColor, fixedColor, runningColor, profitColor, lossColor,
showProfitLine = true,
series,
}: Props) {
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
const on = {
income: view !== "profit-line" && (series?.income ?? true),
fixed: view !== "profit-line" && (series?.fixed ?? true),
running: view !== "profit-line" && (series?.running ?? true),
profit: view === "profit-line" || ((series?.profit ?? true) && showProfitLine),
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
};
// The amount axis exists only if some non-profit line is actually drawn.
const showAmountAxis = on.income || on.fixed || on.running;
const INC = incomeColor ?? readVar("--color-chart-income", "#0090A6");
const FIX = fixedColor ?? readVar("--color-chart-fixed", "#BE851F");
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
const RUN = runningColor ?? readVar("--color-chart-running", "#8B5CF6");
const UP = profitColor ?? readVar("--color-chart-profit", "#7A9E06");
// Loss has its own token — it must not follow the Running line's colour.
const DOWN = lossColor ?? readVar("--color-chart-loss", "#C13B4D");
const narrow = useIsNarrow();
const tickSize = narrow ? 12 : 10;
// Unique per card: /charts renders one of these per machine, and a shared
// gradient id would make every card paint the first card's fade.
const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
const lastActual = Math.max(
income.findLastIndex((v) => v != null),
fixed.findLastIndex((v) => v != null),
running.findLastIndex((v) => v != null),
);
const clipped = lastActual >= 0 ? lastActual + 1 : MONTH_NAMES.length;
const months = MONTH_NAMES.slice(0, clipped);
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
// Profit stays null in a month with no income figure. Treating it as 0 would
// draw a green "monthly profit" dot for a month that was never billed.
feat(running): drive the charts and the profit from the running-cost register The machine charts plotted the workbook's monthly_income.fixed_expenses, which is NOT the app's running cost. So the charts and the /running Overview disagreed about the same machine in the same month — CT01 by R 40 640, DT01 by R 121 115. The workbook's figure also carries depreciation/ownership and an operator allocation, which are capital recovery and overhead rather than running cost. Every cost value now comes from the running-cost register: fixed = insurance + financing, running = purchases. Profit is income - fixed - running, and it is ONE formula on every surface, so /charts, /income and the dashboard quote the same figure for the same machine. R 8 456 243 fleet income, Jan-Oct - R 4 705 292 fixed (insurance + financing, from the register) - R 306 387 running (purchases, from the register) = R 3 444 564 profit (was R 2 510 908 on the workbook's fixed) The legacy repairs sheet comes out completely. It was the old workbook's "Plant Monthly Running Cost" — read by no page, maintained by nobody — and a repair is bought on a purchase order, so its money is already in Purchases. The collection is still seeded, so the history is not lost, but it is no longer in any total, any chart or any profit. Also: - the profit badge wears the CHART's own green, and profit is green and loss red on every surface. It was hard-coded to the teal brand colour, so the label and the line it labelled were two different colours. - September and October now state a profit instead of a dash: the dash existed because the workbook had not posted those months' fixed costs, and the register has the premium and the instalment for every month. - the containers and the water trailers show profit equal to their income, because the running-cost model carries no cost for them. Overheads roll-up — an audit found three things out of order, and the roll-up total itself reconciles exactly (R 580 485,37, unexplained R 0,00): - the dashboard's Overhead mix legend named only five of the six sections, so the figures it listed added up to R 565 489 against a headline of R 580 485, and the donut drew a slice nobody could name. - the /overheads note claimed machine instalments were excluded, while the Bastion Finance block on the same page carried R 115 575,94 of them and R 11 204,76 of insurance for the same machines. The note now says so, with the figures read off the rows at render time. - two Subscriptions rows had VAT that did not balance — the sheet had the TOTAL in the VAT cell (115%), and annual excl/VAT against a monthly total. Both are corrected on the client's instruction; the roll-up total does not move, only the VAT breakdown does, and the seed's VAT gate now passes instead of exiting non-zero. Verified: 161 tests pass (4 new ones pin the register join), the production build is clean, the reconciliation script passes, and the charts, the profit label and the overhead legend were checked on screen.
2026-11-05 19:24:30 +00:00
//
// The formula is lib/profit.ts's: income - fixed - running, where both cost terms
// come from the running-cost register.
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
const points: Point[] = months.map((m, i) => ({
month: m,
profit: income[i] == null
? null
feat(running): drive the charts and the profit from the running-cost register The machine charts plotted the workbook's monthly_income.fixed_expenses, which is NOT the app's running cost. So the charts and the /running Overview disagreed about the same machine in the same month — CT01 by R 40 640, DT01 by R 121 115. The workbook's figure also carries depreciation/ownership and an operator allocation, which are capital recovery and overhead rather than running cost. Every cost value now comes from the running-cost register: fixed = insurance + financing, running = purchases. Profit is income - fixed - running, and it is ONE formula on every surface, so /charts, /income and the dashboard quote the same figure for the same machine. R 8 456 243 fleet income, Jan-Oct - R 4 705 292 fixed (insurance + financing, from the register) - R 306 387 running (purchases, from the register) = R 3 444 564 profit (was R 2 510 908 on the workbook's fixed) The legacy repairs sheet comes out completely. It was the old workbook's "Plant Monthly Running Cost" — read by no page, maintained by nobody — and a repair is bought on a purchase order, so its money is already in Purchases. The collection is still seeded, so the history is not lost, but it is no longer in any total, any chart or any profit. Also: - the profit badge wears the CHART's own green, and profit is green and loss red on every surface. It was hard-coded to the teal brand colour, so the label and the line it labelled were two different colours. - September and October now state a profit instead of a dash: the dash existed because the workbook had not posted those months' fixed costs, and the register has the premium and the instalment for every month. - the containers and the water trailers show profit equal to their income, because the running-cost model carries no cost for them. Overheads roll-up — an audit found three things out of order, and the roll-up total itself reconciles exactly (R 580 485,37, unexplained R 0,00): - the dashboard's Overhead mix legend named only five of the six sections, so the figures it listed added up to R 565 489 against a headline of R 580 485, and the donut drew a slice nobody could name. - the /overheads note claimed machine instalments were excluded, while the Bastion Finance block on the same page carried R 115 575,94 of them and R 11 204,76 of insurance for the same machines. The note now says so, with the figures read off the rows at render time. - two Subscriptions rows had VAT that did not balance — the sheet had the TOTAL in the VAT cell (115%), and annual excl/VAT against a monthly total. Both are corrected on the client's instruction; the roll-up total does not move, only the VAT breakdown does, and the seed's VAT gate now passes instead of exiting non-zero. Verified: 161 tests pass (4 new ones pin the register join), the production build is clean, the reconciliation script passes, and the charts, the profit label and the overhead legend were checked on screen.
2026-11-05 19:24:30 +00:00
: computeProfit({
income: income[i] as number,
fixed: fixed[i] ?? 0,
running: running[i] ?? 0,
}),
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
}));
const data = months.map((m, i) => ({
month: m,
income: income[i] ?? null,
fixed: fixed[i] ?? null,
running: running[i] ?? null,
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
profit: points[i].profit,
feat(running): drive the charts and the profit from the running-cost register The machine charts plotted the workbook's monthly_income.fixed_expenses, which is NOT the app's running cost. So the charts and the /running Overview disagreed about the same machine in the same month — CT01 by R 40 640, DT01 by R 121 115. The workbook's figure also carries depreciation/ownership and an operator allocation, which are capital recovery and overhead rather than running cost. Every cost value now comes from the running-cost register: fixed = insurance + financing, running = purchases. Profit is income - fixed - running, and it is ONE formula on every surface, so /charts, /income and the dashboard quote the same figure for the same machine. R 8 456 243 fleet income, Jan-Oct - R 4 705 292 fixed (insurance + financing, from the register) - R 306 387 running (purchases, from the register) = R 3 444 564 profit (was R 2 510 908 on the workbook's fixed) The legacy repairs sheet comes out completely. It was the old workbook's "Plant Monthly Running Cost" — read by no page, maintained by nobody — and a repair is bought on a purchase order, so its money is already in Purchases. The collection is still seeded, so the history is not lost, but it is no longer in any total, any chart or any profit. Also: - the profit badge wears the CHART's own green, and profit is green and loss red on every surface. It was hard-coded to the teal brand colour, so the label and the line it labelled were two different colours. - September and October now state a profit instead of a dash: the dash existed because the workbook had not posted those months' fixed costs, and the register has the premium and the instalment for every month. - the containers and the water trailers show profit equal to their income, because the running-cost model carries no cost for them. Overheads roll-up — an audit found three things out of order, and the roll-up total itself reconciles exactly (R 580 485,37, unexplained R 0,00): - the dashboard's Overhead mix legend named only five of the six sections, so the figures it listed added up to R 565 489 against a headline of R 580 485, and the donut drew a slice nobody could name. - the /overheads note claimed machine instalments were excluded, while the Bastion Finance block on the same page carried R 115 575,94 of them and R 11 204,76 of insurance for the same machines. The note now says so, with the figures read off the rows at render time. - two Subscriptions rows had VAT that did not balance — the sheet had the TOTAL in the VAT cell (115%), and annual excl/VAT against a monthly total. Both are corrected on the client's instruction; the roll-up total does not move, only the VAT breakdown does, and the seed's VAT gate now passes instead of exiting non-zero. Verified: 161 tests pass (4 new ones pin the register join), the production build is clean, the reconciliation script passes, and the charts, the profit label and the overhead legend were checked on screen.
2026-11-05 19:24:30 +00:00
})) as (Record<string, unknown> & { month: string })[];
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
// Recharts builds a categorical axis by concatenating the data of EVERY series, so a
// Line carrying its own `data` re-adds the month labels and the axis renders
// "Jan … Aug, Jan … Aug" while that series plots at the wrong months. The shared
// helper therefore writes each profit variant as a key on the ONE dataset and no
// Line is given a `data` prop: profit is nulled outside its own run, and
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
// connectNulls={false} then draws exactly that run, in the right place.
//
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
// One flat series per same-sign run, then one gradient series per genuine crossing.
// Recharts 2 draws a Line as a single <Curve>, so <Cell> cannot recolour a segment —
// separate series are the only way to get per-segment colour. The dashboard's
// MonthlyProfitChart uses this same helper, so the two cannot drift apart.
const { series: seriesDefs, gradients: profitGradients } = buildProfitSeries({
profit: points.map((p) => p.profit),
upColor: UP,
downColor: DOWN,
idPrefix: uid,
mutate: (key, keep) => {
const on = new Set(keep);
for (let i = 0; i < data.length; i++) data[i][key] = on.has(i) ? points[i].profit : null;
},
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
});
const fmt = (v: number) =>
new Intl.NumberFormat("en-ZA", { style: "currency", currency: "ZAR", maximumFractionDigits: 0, notation: "compact" }).format(v);
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
// Keep zero inside the profit axis so "above/below zero" is always readable,
// whichever way every month happens to fall.
const profitAxis = {
yAxisId: "profit",
orientation: showAmountAxis ? "right" as const : "left" as const,
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
stroke: "#9ca3af",
tickLine: false,
axisLine: false,
tickFormatter: fmt,
// A standalone profit line owns the left axis, including labels on mobile.
// Only the secondary axis drops its ticks on a phone to protect plot width.
tick: narrow && showAmountAxis ? false : { fontSize: tickSize },
width: showAmountAxis ? (narrow ? 1 : 52) : 60,
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
// Function form so zero is always inside the range.
domain: ([min, max]: [number, number]): [number, number] => [Math.min(0, min), Math.max(0, max)],
};
if (view === "profit") {
return (
2026-09-12 10:05:43 +00:00
<div className="h-44 sm:h-52">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid stroke="#f3f4f6" strokeDasharray="3 3" vertical={false} />
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
<XAxis dataKey="month" stroke="#9ca3af" tick={{ fontSize: tickSize }} tickLine={false} axisLine={false} interval="preserveStartEnd" />
<YAxis yAxisId="profit" stroke="#9ca3af" tick={{ fontSize: tickSize }} tickLine={false} axisLine={false} tickFormatter={fmt} width={60} />
<Tooltip formatter={(v: number) => fmt(v)} />
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
<ReferenceLine yAxisId="profit" y={0} stroke="#9ca3af" strokeDasharray="4 4" />
<Bar dataKey="profit" yAxisId="profit" radius={[4, 4, 0, 0]} fill={UP}>
{data.map((d, i) => (
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
<Cell key={i} fill={((d.profit as number | null) ?? 0) >= 0 ? UP : DOWN} />
))}
</Bar>
</ComposedChart>
</ResponsiveContainer>
</div>
);
}
if (view === "stack") {
return (
2026-09-12 10:05:43 +00:00
<div className="h-44 sm:h-52">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 0 }} stackOffset="sign">
<CartesianGrid stroke="#f3f4f6" strokeDasharray="3 3" vertical={false} />
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
<XAxis dataKey="month" stroke="#9ca3af" tick={{ fontSize: tickSize }} tickLine={false} axisLine={false} interval="preserveStartEnd" />
{showAmountAxis && <YAxis yAxisId="amount" stroke="#9ca3af" tick={{ fontSize: tickSize }} tickLine={false} axisLine={false} tickFormatter={fmt} width={60} />}
<Tooltip formatter={(v: number) => fmt(v)} />
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
{on.fixed && <Bar dataKey="fixed" yAxisId="amount" stackId="a" fill={FIX} radius={[0, 0, 0, 0]} />}
{on.running && <Bar dataKey="running" yAxisId="amount" stackId="a" fill={RUN} radius={[4, 4, 0, 0]} barSize={20} />}
{on.income && <Line type="monotone" name="Income" dataKey="income" yAxisId="amount" stroke={INC} strokeWidth={2.5} dot={{ r: 3, fill: INC }} connectNulls={false} />}
</ComposedChart>
</ResponsiveContainer>
</div>
);
}
2026-09-12 10:05:43 +00:00
// Round 4 Part A.5: default "Lines" view draws ACTUAL LINES (stroked, no fill).
// The chart's whole purpose is to match the workbook — the workbook draws lines.
return (
2026-09-12 10:05:43 +00:00
<div className="h-44 sm:h-52">
<ResponsiveContainer width="100%" height="100%">
2026-09-12 10:05:43 +00:00
<LineChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
{/* Defined before use so the fade ids exist when the Lines below mount. */}
<defs>
{profitGradients.map((c) => (
<linearGradient key={c.id} id={c.id} x1="0%" y1="0%" x2="100%" y2="0%">
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
<stop offset="0%" stopColor={c.from} />
<stop offset="100%" stopColor={c.to} />
</linearGradient>
))}
</defs>
<CartesianGrid stroke="#f3f4f6" strokeDasharray="3 3" vertical={false} />
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
<XAxis dataKey="month" stroke="#9ca3af" tick={{ fontSize: tickSize }} tickLine={false} axisLine={false} interval="preserveStartEnd" />
{showAmountAxis && <YAxis yAxisId="amount" stroke="#9ca3af" tick={{ fontSize: tickSize }} tickLine={false} axisLine={false} tickFormatter={fmt} width={60} />}
{on.profit && <YAxis {...profitAxis} />}
{on.profit && (
<ReferenceLine yAxisId="profit" y={0} stroke="#9ca3af" strokeDasharray="4 4" />
)}
<Tooltip
formatter={(v: number) => fmt(v)}
content={<ChartTooltip showProfit={on.profit} fmt={fmt} up={UP} down={DOWN} />}
/>
{on.income && <Line type="monotone" name="Income" dataKey="income" yAxisId="amount" stroke={INC} strokeWidth={2.5} dot={{ r: 3, fill: INC }} connectNulls={false} />}
{on.fixed && <Line type="monotone" name="Fixed" dataKey="fixed" yAxisId="amount" stroke={FIX} strokeWidth={2.5} dot={{ r: 3, fill: FIX }} connectNulls={false} />}
{on.running && <Line type="monotone" name="Running" dataKey="running" yAxisId="amount" stroke={RUN} strokeWidth={2.5} dot={{ r: 3, fill: RUN }} connectNulls={false} />}
{/* These must be DIRECT children of LineChart — Recharts parses only
recognised child types and silently drops anything else, so wrapping
them in a <g> makes the whole profit line disappear. Each reads one
masked key off the shared data and carries no `data` of its own. */}
{on.profit &&
seriesDefs.map((s) => (
<Line
key={s.key}
type="linear"
dataKey={s.key}
yAxisId="profit"
stroke={s.stroke}
strokeWidth={4}
dot={false}
activeDot={false}
isAnimationActive={false}
connectNulls={false}
/>
))}
{/* Dots ride their own transparent Line so each month can be coloured by its
feat(running): drive the charts and the profit from the running-cost register The machine charts plotted the workbook's monthly_income.fixed_expenses, which is NOT the app's running cost. So the charts and the /running Overview disagreed about the same machine in the same month — CT01 by R 40 640, DT01 by R 121 115. The workbook's figure also carries depreciation/ownership and an operator allocation, which are capital recovery and overhead rather than running cost. Every cost value now comes from the running-cost register: fixed = insurance + financing, running = purchases. Profit is income - fixed - running, and it is ONE formula on every surface, so /charts, /income and the dashboard quote the same figure for the same machine. R 8 456 243 fleet income, Jan-Oct - R 4 705 292 fixed (insurance + financing, from the register) - R 306 387 running (purchases, from the register) = R 3 444 564 profit (was R 2 510 908 on the workbook's fixed) The legacy repairs sheet comes out completely. It was the old workbook's "Plant Monthly Running Cost" — read by no page, maintained by nobody — and a repair is bought on a purchase order, so its money is already in Purchases. The collection is still seeded, so the history is not lost, but it is no longer in any total, any chart or any profit. Also: - the profit badge wears the CHART's own green, and profit is green and loss red on every surface. It was hard-coded to the teal brand colour, so the label and the line it labelled were two different colours. - September and October now state a profit instead of a dash: the dash existed because the workbook had not posted those months' fixed costs, and the register has the premium and the instalment for every month. - the containers and the water trailers show profit equal to their income, because the running-cost model carries no cost for them. Overheads roll-up — an audit found three things out of order, and the roll-up total itself reconciles exactly (R 580 485,37, unexplained R 0,00): - the dashboard's Overhead mix legend named only five of the six sections, so the figures it listed added up to R 565 489 against a headline of R 580 485, and the donut drew a slice nobody could name. - the /overheads note claimed machine instalments were excluded, while the Bastion Finance block on the same page carried R 115 575,94 of them and R 11 204,76 of insurance for the same machines. The note now says so, with the figures read off the rows at render time. - two Subscriptions rows had VAT that did not balance — the sheet had the TOTAL in the VAT cell (115%), and annual excl/VAT against a monthly total. Both are corrected on the client's instruction; the roll-up total does not move, only the VAT breakdown does, and the seed's VAT gate now passes instead of exiting non-zero. Verified: 161 tests pass (4 new ones pin the register join), the production build is clean, the reconciliation script passes, and the charts, the profit label and the overhead legend were checked on screen.
2026-11-05 19:24:30 +00:00
own sign. */}
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
{on.profit && (
<Line
type="linear"
dataKey="profit"
yAxisId="profit"
stroke="none"
strokeWidth={0}
connectNulls={false}
activeDot={false}
isAnimationActive={false}
dot={(props: { cx?: number; cy?: number; index?: number; payload?: unknown }) => {
const { cx, cy, index } = props;
feat(running): drive the charts and the profit from the running-cost register The machine charts plotted the workbook's monthly_income.fixed_expenses, which is NOT the app's running cost. So the charts and the /running Overview disagreed about the same machine in the same month — CT01 by R 40 640, DT01 by R 121 115. The workbook's figure also carries depreciation/ownership and an operator allocation, which are capital recovery and overhead rather than running cost. Every cost value now comes from the running-cost register: fixed = insurance + financing, running = purchases. Profit is income - fixed - running, and it is ONE formula on every surface, so /charts, /income and the dashboard quote the same figure for the same machine. R 8 456 243 fleet income, Jan-Oct - R 4 705 292 fixed (insurance + financing, from the register) - R 306 387 running (purchases, from the register) = R 3 444 564 profit (was R 2 510 908 on the workbook's fixed) The legacy repairs sheet comes out completely. It was the old workbook's "Plant Monthly Running Cost" — read by no page, maintained by nobody — and a repair is bought on a purchase order, so its money is already in Purchases. The collection is still seeded, so the history is not lost, but it is no longer in any total, any chart or any profit. Also: - the profit badge wears the CHART's own green, and profit is green and loss red on every surface. It was hard-coded to the teal brand colour, so the label and the line it labelled were two different colours. - September and October now state a profit instead of a dash: the dash existed because the workbook had not posted those months' fixed costs, and the register has the premium and the instalment for every month. - the containers and the water trailers show profit equal to their income, because the running-cost model carries no cost for them. Overheads roll-up — an audit found three things out of order, and the roll-up total itself reconciles exactly (R 580 485,37, unexplained R 0,00): - the dashboard's Overhead mix legend named only five of the six sections, so the figures it listed added up to R 565 489 against a headline of R 580 485, and the donut drew a slice nobody could name. - the /overheads note claimed machine instalments were excluded, while the Bastion Finance block on the same page carried R 115 575,94 of them and R 11 204,76 of insurance for the same machines. The note now says so, with the figures read off the rows at render time. - two Subscriptions rows had VAT that did not balance — the sheet had the TOTAL in the VAT cell (115%), and annual excl/VAT against a monthly total. Both are corrected on the client's instruction; the roll-up total does not move, only the VAT breakdown does, and the seed's VAT gate now passes instead of exiting non-zero. Verified: 161 tests pass (4 new ones pin the register join), the production build is clean, the reconciliation script passes, and the charts, the profit label and the overhead legend were checked on screen.
2026-11-05 19:24:30 +00:00
const p = props.payload as { profit?: number | null } | undefined;
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
// LineDot's type forbids returning null, so an unplottable month
// gets an empty group instead of a circle at NaN.
if (p?.profit == null || cx == null || cy == null || Number.isNaN(cy)) {
return <g key={`d-${index}`} />;
}
const c = p.profit >= 0 ? UP : DOWN;
feat(running): drive the charts and the profit from the running-cost register The machine charts plotted the workbook's monthly_income.fixed_expenses, which is NOT the app's running cost. So the charts and the /running Overview disagreed about the same machine in the same month — CT01 by R 40 640, DT01 by R 121 115. The workbook's figure also carries depreciation/ownership and an operator allocation, which are capital recovery and overhead rather than running cost. Every cost value now comes from the running-cost register: fixed = insurance + financing, running = purchases. Profit is income - fixed - running, and it is ONE formula on every surface, so /charts, /income and the dashboard quote the same figure for the same machine. R 8 456 243 fleet income, Jan-Oct - R 4 705 292 fixed (insurance + financing, from the register) - R 306 387 running (purchases, from the register) = R 3 444 564 profit (was R 2 510 908 on the workbook's fixed) The legacy repairs sheet comes out completely. It was the old workbook's "Plant Monthly Running Cost" — read by no page, maintained by nobody — and a repair is bought on a purchase order, so its money is already in Purchases. The collection is still seeded, so the history is not lost, but it is no longer in any total, any chart or any profit. Also: - the profit badge wears the CHART's own green, and profit is green and loss red on every surface. It was hard-coded to the teal brand colour, so the label and the line it labelled were two different colours. - September and October now state a profit instead of a dash: the dash existed because the workbook had not posted those months' fixed costs, and the register has the premium and the instalment for every month. - the containers and the water trailers show profit equal to their income, because the running-cost model carries no cost for them. Overheads roll-up — an audit found three things out of order, and the roll-up total itself reconciles exactly (R 580 485,37, unexplained R 0,00): - the dashboard's Overhead mix legend named only five of the six sections, so the figures it listed added up to R 565 489 against a headline of R 580 485, and the donut drew a slice nobody could name. - the /overheads note claimed machine instalments were excluded, while the Bastion Finance block on the same page carried R 115 575,94 of them and R 11 204,76 of insurance for the same machines. The note now says so, with the figures read off the rows at render time. - two Subscriptions rows had VAT that did not balance — the sheet had the TOTAL in the VAT cell (115%), and annual excl/VAT against a monthly total. Both are corrected on the client's instruction; the roll-up total does not move, only the VAT breakdown does, and the seed's VAT gate now passes instead of exiting non-zero. Verified: 161 tests pass (4 new ones pin the register join), the production build is clean, the reconciliation script passes, and the charts, the profit label and the overhead legend were checked on screen.
2026-11-05 19:24:30 +00:00
return <circle key={`d-${index}`} cx={cx} cy={cy} r={3.5} fill={c} stroke="#fff" strokeWidth={1} />;
Charts: a profit line that changes colour, and a Series toggle Profit was a single always-teal line, so a month that lost money looked like a month that made a little. It is now drawn per sign: one <Line> per same-sign run, plus a gradient <Line> across each crossing. Recharts draws a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate series are the only way. Every variant is a key on the ONE shared dataset rather than a sliced `data` prop, because Recharts builds a categorical axis by concatenating every series' data and a short slice re-adds the month labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content` component to fold those per-run keys back into a single profit row. Two honesty rules: profit stays null in a month with no income figure (treating it as 0 drew a green dot for a month that was never billed), and a month with no running-cost row gets a hollow dot, because its profit is optimistic. The toolbar's Series button toggles which lines a card draws, remembered in localStorage. A hidden series takes its legend entry and its Y axis with it, so there is never an axis for a line that is not drawn, and the last visible series cannot be switched off. Palette: Running moves off red to the project's existing brand purple, because red is now reserved for a loss and a red Running line next to a red loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit` are separate tokens on purpose — loss used to read the Running token, so recolouring Running silently dragged the red with it. /charts alone now reads profit including running cost, so the card badge, the profit line and the Profit bars view cannot disagree. Scoped to the page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 07:20:16 +00:00
}}
/>
)}
2026-09-12 10:05:43 +00:00
</LineChart>
</ResponsiveContainer>
</div>
);
}