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.
This commit is contained in:
David 2026-10-03 09:20:16 +02:00
parent 876619c3ac
commit 681225e51d
5 changed files with 504 additions and 45 deletions

View file

@ -14,7 +14,10 @@ export default async function ChartsPage() {
getRunningCosts(),
]);
const rows = buildIncomeRows(assets, incomeActual, running);
// /charts shows profit including running cost (ChartsGrid passes the same flag to
// MachineLineChart), so the card badge and the profit line cannot disagree.
// Scoped to this page — lib/profit.ts keeps its workbook default for every other surface.
const rows = buildIncomeRows(assets, incomeActual, running, { includeRunningCost: true });
const items = rows.map((r) => {
// r.months / r.fixedMonths / r.runningMonths already distinguish null (forecast) from 0 (Part A.2).

View file

@ -23,7 +23,14 @@
Brand palette (#EDAE49/#00798C/#D1495B) does NOT change; these are read only by chart marks. */
--color-chart-income: #0090A6;
--color-chart-fixed: #BE851F;
--color-chart-running: #C13B4D;
--color-chart-running: #8B5CF6;
/* A month that did not make profit. Its own token so moving the Running line
cannot drag the profit-loss red along with it. */
--color-chart-loss: #C13B4D;
/* Profit-positive. The client's chosen bright green, 2026-10-02 — deliberately
outside the Coolors brand set so the profit line reads as its own series and
not as another teal. */
--color-chart-profit: #7A9E06;
--color-brand-blue: #30638E;
--color-brand-orange: #EDAE49;
@ -77,7 +84,9 @@
/* Part A.4: dark-mode chart hues */
--color-chart-income: #00A6BE;
--color-chart-fixed: #BE851F;
--color-chart-running: #D94F60;
--color-chart-running: #8B5CF6;
--color-chart-loss: #D94F60;
--color-chart-profit: #7A9E06;
}
html, body {
@ -92,6 +101,11 @@ html, body {
body, .dark body { transition: background-color 0.2s ease, color 0.2s ease; }
.recharts-cartesian-axis-tick text { fill: var(--color-text-soft); font-size: 11px; }
/* Round 4: no text under 12px on mobile. This CSS rule beats Recharts' tick
fontSize prop, so the bump has to happen here or nowhere. */
@media (max-width: 639px) {
.recharts-cartesian-axis-tick text { font-size: 12px; }
}
.recharts-cartesian-grid line { stroke: var(--color-border-soft); }
.recharts-default-tooltip {
background: rgba(255,255,255,0.98) !important;

View file

@ -1,10 +1,13 @@
"use client";
import { useState, useMemo, useEffect } from "react";
import { Search, TrendingUp, TrendingDown, LineChart as LineChartIcon, BarChart3, Scale } from "lucide-react";
import { Search, TrendingUp, TrendingDown, LineChart as LineChartIcon, BarChart3, Scale, SlidersHorizontal } from "lucide-react";
import { ZAR2 } from "@/lib/utils";
import { MachineLineChart, type ChartView } from "@/components/MachineLineChart";
import { computeProfit } from "@/lib/profit";
import { MachineLineChart, type ChartView, type SeriesVisible } from "@/components/MachineLineChart";
// /charts reads profit INCLUDING running cost, so the line, the card badge and the
// Profit view all sit on one basis. lib/profit.ts keeps its workbook default.
const CHART_PROFIT_INCLUDE_RUNNING = true;
type Item = {
id: string;
@ -16,6 +19,17 @@ type Item = {
};
const VIEW_KEY = "mp:chartsView";
const SERIES_KEY = "mp:chartsSeries";
// Which lines a card draws. The Profit toggle also controls the loss colour,
// because red is a sign of the profit line, not a series of its own.
const ALL_SERIES: SeriesVisible = { income: true, fixed: true, running: true, profit: true };
const SERIES_LIST: { key: keyof SeriesVisible; label: string; color: string }[] = [
{ key: "income", label: "Income", color: "var(--color-chart-income)" },
{ key: "fixed", label: "Fixed", color: "var(--color-chart-fixed)" },
{ key: "running", label: "Running", color: "var(--color-chart-running)" },
{ key: "profit", label: "Profit", color: "var(--color-chart-profit)" },
];
export function ChartsGrid({ items }: { items: Item[] }) {
const [query, setQuery] = useState("");
@ -34,6 +48,51 @@ export function ChartsGrid({ items }: { items: Item[] }) {
try { localStorage.setItem(VIEW_KEY, v); } catch {}
};
const [series, setSeries] = useState<SeriesVisible>(ALL_SERIES);
const [seriesOpen, setSeriesOpen] = useState(false);
useEffect(() => {
try {
const stored = localStorage.getItem(SERIES_KEY);
if (stored) {
const parsed = JSON.parse(stored) as Partial<SeriesVisible>;
if (parsed && typeof parsed === "object") setSeries({ ...ALL_SERIES, ...parsed });
}
} catch {}
}, []);
const toggleSeries = (k: keyof SeriesVisible) => {
setSeries((prev) => {
const next = { ...prev, [k]: !prev[k] };
// An empty chart reads as broken, so the last visible line stays on.
if (Object.values(next).every((v) => !v)) return prev;
try { localStorage.setItem(SERIES_KEY, JSON.stringify(next)); } catch {}
return next;
});
};
const resetSeries = () => {
setSeries(ALL_SERIES);
try { localStorage.setItem(SERIES_KEY, JSON.stringify(ALL_SERIES)); } catch {}
};
// Close the popover on Escape or an outside click.
useEffect(() => {
if (!seriesOpen) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setSeriesOpen(false); };
const onDown = (e: MouseEvent) => {
if (!(e.target as HTMLElement)?.closest?.("[data-series-popover]")) setSeriesOpen(false);
};
document.addEventListener("keydown", onKey);
document.addEventListener("mousedown", onDown);
return () => {
document.removeEventListener("keydown", onKey);
document.removeEventListener("mousedown", onDown);
};
}, [seriesOpen]);
const seriesOn = SERIES_LIST.filter((s) => series[s.key]).length;
const filtered = useMemo(() => {
let list = items;
if (query.trim()) {
@ -52,14 +111,30 @@ export function ChartsGrid({ items }: { items: Item[] }) {
return { profitable, total: items.length, totalIncome };
}, [items]);
// Legend swatches per view (Part A.5)
const legend = view === "profit"
? [{ color: "var(--color-chart-running)", label: "Profit / Loss" }]
: [
{ color: "var(--color-chart-income)", label: "Income" },
{ color: "var(--color-chart-fixed)", label: "Fixed" },
{ color: "var(--color-chart-running)", label: "Running" },
];
// Legend swatches per view (Part A.5). Profit/Loss only appear where they are
// actually drawn — the Lines view is the only one carrying the profit line, so
// the Cost-stack view must not advertise it. A hidden series leaves the legend
// too, so the key always matches the card.
const legend: { color: string; label: string }[] = (
view === "profit"
? [
{ color: "var(--color-chart-profit)", label: "Profit" },
{ color: "var(--color-chart-loss)", label: "Loss" },
]
: view === "lines"
? [
series.income ? { color: "var(--color-chart-income)", label: "Income" } : null,
series.fixed ? { color: "var(--color-chart-fixed)", label: "Fixed" } : null,
series.running ? { color: "var(--color-chart-running)", label: "Running" } : null,
series.profit ? { color: "var(--color-chart-profit)", label: "Profit" } : null,
series.profit ? { color: "var(--color-chart-loss)", label: "Loss" } : null,
]
: [
series.income ? { color: "var(--color-chart-income)", label: "Income" } : null,
series.fixed ? { color: "var(--color-chart-fixed)", label: "Fixed" } : null,
series.running ? { color: "var(--color-chart-running)", label: "Running" } : null,
]
).filter((x): x is { color: string; label: string } => x !== null);
return (
<>
@ -85,11 +160,17 @@ export function ChartsGrid({ items }: { items: Item[] }) {
<span className="text-gray-500 dark:text-gray-400">{l.label}</span>
</div>
))}
{view === "lines" && series.profit && (
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full bg-white border-2 border-[#7A9E06]" />
<span className="text-gray-500 dark:text-gray-400">hollow = no running-cost data</span>
</div>
)}
</div>
</div>
{/* Toolbar — wraps to multiple rows on mobile. Round 4 Part B.1/B.3/B.5. */}
<div className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-4 shadow-sm mb-5 flex flex-wrap items-center gap-3">
<div className="relative bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-4 shadow-sm mb-5 flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[160px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
@ -108,7 +189,7 @@ export function ChartsGrid({ items }: { items: Item[] }) {
<button
key={f.id}
onClick={() => setFilter(f.id as typeof filter)}
className={`min-h-[44px] px-3 text-xs font-medium rounded-lg transition ${
className={`min-h-[44px] min-w-[44px] px-3 text-xs font-medium rounded-lg transition ${
filter === f.id
? "bg-[#30638E] text-white"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
@ -139,6 +220,66 @@ export function ChartsGrid({ items }: { items: Item[] }) {
</button>
))}
</div>
{/* Series filter — a small block of checkboxes so a card can be read with
the profit line off, or with only income and profit on, etc. */}
<div className="relative" data-series-popover>
<button
onClick={() => setSeriesOpen((o) => !o)}
aria-expanded={seriesOpen}
aria-haspopup="true"
className={`min-h-[44px] min-w-[44px] px-3 text-xs font-medium rounded-lg transition flex items-center gap-1.5 ${
seriesOn === SERIES_LIST.length
? "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
: "bg-[#30638E]/10 text-[#30638E] dark:text-[#7FB3D5]"
}`}
>
<SlidersHorizontal className="w-3.5 h-3.5" />
<span>Series</span>
<span className="tabular-nums">
{seriesOn}/{SERIES_LIST.length}
</span>
</button>
{seriesOpen && (
<div className="absolute left-0 sm:left-auto sm:right-0 mt-2 z-30 w-60 max-w-[calc(100vw-2rem)] rounded-2xl border border-gray-100 dark:border-border bg-white dark:bg-surface shadow-lg p-2">
<div className="px-2 pt-1 pb-2 text-xs uppercase tracking-wider text-gray-400 dark:text-text-soft">
Show on chart
</div>
{SERIES_LIST.map((s) => {
// Only the LAST remaining series is locked — the guard exists to stop
// an empty chart, not to trap the reader on one series. Disabling every
// box here meant the only way to add a line back was "Show all".
const locked = seriesOn === 1 && series[s.key];
return (
<label
key={s.key}
className={`flex items-center gap-3 min-h-[44px] px-2 rounded-lg text-sm ${
locked ? "opacity-50" : "cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5"
}`}
>
<input
type="checkbox"
checked={series[s.key]}
disabled={locked}
onChange={() => toggleSeries(s.key)}
className="w-4 h-4 accent-[#30638E] cursor-pointer disabled:cursor-not-allowed"
/>
<span className="w-2.5 h-2.5 rounded-full" style={{ background: s.color }} />
<span className="text-gray-700 dark:text-gray-200">{s.label}</span>
</label>
);
})}
<button
onClick={resetSeries}
disabled={seriesOn === SERIES_LIST.length}
className="w-full min-h-[44px] mt-1 rounded-lg text-xs text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/5 disabled:opacity-40 disabled:hover:bg-transparent"
>
Show all
</button>
</div>
)}
</div>
</div>
{filtered.length === 0 ? (
@ -152,15 +293,17 @@ export function ChartsGrid({ items }: { items: Item[] }) {
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{filtered.map((item) => {
const positive = computeProfit({ income: item.totalIncome, fixed: 0, running: 0 }) >= 0 || item.totalIncome > 0;
// Profit uses computeProfit() — Part A.6
const profit = computeProfit({ income: item.totalIncome, fixed: 0, running: 0 });
// Was computeProfit({ income: totalIncome, fixed: 0, running: 0 }) — which is
// just totalIncome, so every card showed a green "profit" equal to its income.
// totalProfit is the real YTD figure from buildIncomeRows, on the same basis
// the profit line uses below.
const profit = item.totalProfit;
const positiveProfit = profit >= 0;
return (
<div key={item.id} className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-5 shadow-sm hover:shadow-md transition">
<div className="flex items-start justify-between mb-3">
<div>
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-[#00798C]/10 text-[#00798C] font-semibold">
<span className="font-mono text-xs px-1.5 py-0.5 rounded bg-[#00798C]/10 text-[#00798C] font-semibold">
{item.code}
</span>
<h3 className="text-sm font-semibold text-[#003D5B] dark:text-white mt-1.5">{item.name}</h3>
@ -181,6 +324,8 @@ export function ChartsGrid({ items }: { items: Item[] }) {
fixed={item.bucket.fixed}
running={item.bucket.running}
view={view}
includeRunningCost={CHART_PROFIT_INCLUDE_RUNNING}
series={series}
/>
</div>
);

View file

@ -21,7 +21,7 @@ export function IncomeAreaChart({ data }: { data: { month: string; income: numbe
const clipped = lastActualIdx >= 0 ? data.slice(0, lastActualIdx + 1) : data;
const INC = readVar("--color-chart-income", "#0090A6");
const FIX = readVar("--color-chart-fixed", "#BE851F");
const RUN = readVar("--color-chart-running", "#C13B4D");
const RUN = readVar("--color-chart-running", "#8B5CF6");
return (
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">

View file

@ -2,13 +2,23 @@
import {
ComposedChart, Line, LineChart, Bar, AreaChart, Area, Cell,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine,
} from "recharts";
import { useEffect, useId, useState } from "react";
import { MONTH_NAMES } from "@/lib/data";
import { computeProfit } from "@/lib/profit";
import { computeProfit, type ProfitOptions } from "@/lib/profit";
export type ChartView = "lines" | "stack" | "profit";
// 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)[];
@ -18,6 +28,12 @@ type Props = {
fixedColor?: string;
runningColor?: string;
profitColor?: string;
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 {
@ -26,15 +42,154 @@ function readVar(name: string, fallback: string): string {
return v || fallback;
}
// 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;
};
type Run = { start: number; end: number; up: 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>
);
}
// Split the series into maximal contiguous runs of the same sign. A null month ends
// a run, so the line never bridges a month we have no figure for.
function splitRuns(points: Point[]): Run[] {
const runs: Run[] = [];
let i = 0;
while (i < points.length) {
const first = points[i].profit;
if (first == null) { i++; continue; }
const up = first >= 0;
let j = i;
while (
j + 1 < points.length &&
points[j + 1].profit != null &&
(points[j + 1].profit as number) >= 0 === up
) j++;
runs.push({ start: i, end: j, up });
i = j + 1;
}
return runs;
}
export function MachineLineChart({
income, fixed, running,
view = "lines",
incomeColor, fixedColor, runningColor, profitColor,
incomeColor, fixedColor, runningColor, profitColor, lossColor,
includeRunningCost = false,
showProfitLine = true,
series,
}: Props) {
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");
const RUN = runningColor ?? readVar("--color-chart-running", "#C13B4D");
const PROF = profitColor ?? readVar("--color-chart-running", "#D1495B");
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),
@ -43,29 +198,106 @@ export function MachineLineChart({
);
const clipped = lastActual >= 0 ? lastActual + 1 : MONTH_NAMES.length;
const months = MONTH_NAMES.slice(0, clipped);
// 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,
profit: computeProfit({ income: income[i] ?? 0, fixed: fixed[i] ?? 0, running: running[i] ?? 0 }),
}));
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 })[];
const runs = splitRuns(points);
// 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 ends up
// reading "Jan … Aug, Jan … Aug" while the short series plots at the wrong months.
// Therefore every profit variant lives as its own key on the ONE shared dataset and
// no Line is given a `data` prop: profit is nulled outside its own run, and
// connectNulls={false} then draws exactly that run, in the right place.
const seriesDefs: { key: string; stroke: string }[] = [];
const assignRun = (key: string, keep: number[]) => {
const on = new Set(keep);
for (let i = 0; i < data.length; i++) data[i][key] = on.has(i) ? points[i].profit : null;
};
// 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.
runs.forEach((r, idx) => {
if (r.end <= r.start) return;
const key = `profitRun${idx}`;
const keep: number[] = [];
for (let i = r.start; i <= r.end; i++) keep.push(i);
assignRun(key, keep);
seriesDefs.push({ key, stroke: r.up ? UP : DOWN });
});
const crossings = runs.flatMap((a, idx) => {
const b = runs[idx + 1];
// Not adjacent means a null month sits between them — do not bridge the gap.
if (!b || a.end + 1 !== b.start) return [];
const key = `profitCross${idx}`;
assignRun(key, [a.end, b.start]);
const def = { key, stroke: `url(#${uid}-g${idx})` };
seriesDefs.push(def);
return [{
key: `grad-${a.end}-${b.start}`,
id: `${uid}-g${idx}`,
from: a.up ? UP : DOWN,
to: b.up ? UP : DOWN,
}];
});
const fmt = (v: number) =>
new Intl.NumberFormat("en-ZA", { style: "currency", currency: "ZAR", maximumFractionDigits: 0, notation: "compact" }).format(v);
// 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 (
<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} />
<XAxis dataKey="month" stroke="#9ca3af" tick={{ fontSize: 10 }} tickLine={false} axisLine={false} />
<YAxis stroke="#9ca3af" tick={{ fontSize: 10 }} tickLine={false} axisLine={false} tickFormatter={fmt} width={60} />
<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)} />
<Bar dataKey="profit" radius={[4, 4, 0, 0]} fill={INC}>
<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) => (
<Cell key={i} fill={d.profit >= 0 ? INC : RUN} />
<Cell key={i} fill={((d.profit as number | null) ?? 0) >= 0 ? UP : DOWN} />
))}
</Bar>
</ComposedChart>
@ -80,12 +312,12 @@ export function MachineLineChart({
<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} />
<XAxis dataKey="month" stroke="#9ca3af" tick={{ fontSize: 10 }} tickLine={false} axisLine={false} />
<YAxis stroke="#9ca3af" tick={{ fontSize: 10 }} tickLine={false} axisLine={false} tickFormatter={fmt} width={60} />
<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)} />
<Bar dataKey="fixed" stackId="a" fill={FIX} radius={[0, 0, 0, 0]} />
<Bar dataKey="running" stackId="a" fill={RUN} radius={[4, 4, 0, 0]} barSize={20} />
<Line type="monotone" dataKey="income" stroke={INC} strokeWidth={2.5} dot={{ r: 3, fill: INC }} connectNulls={false} />
{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>
@ -98,17 +330,82 @@ export function MachineLineChart({
<div className="h-44 sm:h-52">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
{/* Defined before use so the fade ids exist when the Lines below mount. */}
<defs>
{crossings.map((c) => (
<linearGradient key={c.key} id={c.id} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor={c.from} />
<stop offset="100%" stopColor={c.to} />
</linearGradient>
))}
</defs>
<CartesianGrid stroke="#f3f4f6" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="month" stroke="#9ca3af" tick={{ fontSize: 10 }} tickLine={false} axisLine={false} />
<YAxis stroke="#9ca3af" tick={{ fontSize: 10 }} tickLine={false} axisLine={false} tickFormatter={fmt} width={60} />
<Tooltip formatter={(v: number) => fmt(v)} />
<Line type="monotone" dataKey="income" stroke={INC} strokeWidth={2.5} dot={{ r: 3, fill: INC }} connectNulls={false} />
<Line type="monotone" dataKey="fixed" stroke={FIX} strokeWidth={2.5} dot={{ r: 3, fill: FIX }} connectNulls={false} />
<Line type="monotone" dataKey="running" stroke={RUN} strokeWidth={2.5} dot={{ r: 3, fill: RUN }} connectNulls={false} />
<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} />;
}}
/>
)}
</LineChart>
</ResponsiveContainer>
</div>
);
}