mega-plant/components/MachineLineChart.tsx

370 lines
16 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";
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 { computeProfit, type ProfitOptions } from "@/lib/profit";
import { buildProfitSeries } from "@/lib/chart-profit";
export type ChartView = "lines" | "stack" | "profit";
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;
// Which profit basis this chart shows. lib/profit.ts stays the single source of
// truth for the formula; this only decides which basis these cards read on.
includeRunningCost?: boolean;
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;
// false = we have no running-cost row for this month. `rows.ts` leaves
// runningMonths null when there is no row, so this is exactly "no data".
runningKnown: boolean;
};
// 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;
payload?: { profit?: number | null; runningKnown?: boolean };
};
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,
includeRunningCost = false,
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: series?.income ?? true,
fixed: series?.fixed ?? true,
running: series?.running ?? true,
profit: (series?.profit ?? true) && showProfitLine,
};
// 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.
const profitOpts: ProfitOptions = { includeRunningCost };
const points: Point[] = months.map((m, i) => ({
month: m,
profit: income[i] == null
? null
: computeProfit(
{ income: income[i] as number, fixed: fixed[i] ?? 0, running: running[i] ?? 0 },
profitOpts,
),
runningKnown: running[i] != null,
}));
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,
// Carried onto the chart data because the profit dots read `payload` from here,
// not from the per-run `points` array.
runningKnown: points[i].runningKnown,
})) as (Record<string, unknown> & { month: string; runningKnown: boolean })[];
// 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
// IncomeAreaChart 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: "right" as const,
stroke: "#9ca3af",
tickLine: false,
axisLine: false,
tickFormatter: fmt,
// Ticks are dropped on a phone to protect the plot width; the scale is unchanged.
tick: narrow ? false : { fontSize: tickSize },
width: narrow ? 1 : 52,
// 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
own sign — filled = running cost known, hollow = no running-cost row for
that month, so that month's figure is optimistic. */}
{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;
const p = props.payload as
| { profit?: number | null; runningKnown?: boolean }
| undefined;
// 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;
return p.runningKnown
? <circle key={`d-${index}`} cx={cx} cy={cy} r={3.5} fill={c} stroke="#fff" strokeWidth={1} />
: <circle key={`d-${index}`} cx={cx} cy={cy} r={3.5} fill="#fff" stroke={c} strokeWidth={2} />;
}}
/>
)}
2026-09-12 10:05:43 +00:00
</LineChart>
</ResponsiveContainer>
</div>
);
}