"use client";
import {
ComposedChart, Line, LineChart, Bar, AreaChart, Area, Cell,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine,
} from "recharts";
import { useEffect, useId, useState } from "react";
import { MONTH_NAMES } from "@/lib/data";
import { computeProfit, type ProfitOptions } from "@/lib/profit";
import { buildProfitSeries } from "@/lib/chart-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)[];
running: (number | null)[];
view?: ChartView;
incomeColor?: string;
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 {
if (typeof window === "undefined") return fallback;
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
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;
};
// 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.
{label}
{rows.map((r) => (
{r.name}
{r.text}
))}
);
}
export function MachineLineChart({
income, fixed, running,
view = "lines",
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", "#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);
// 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: 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 & { 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
// connectNulls={false} then draws exactly that run, in the right place.
//
// One flat series per same-sign run, then one gradient series per genuine crossing.
// Recharts 2 draws a Line as a single , so 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;
},
});
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 (
fmt(v)} />
{data.map((d, i) => (
| = 0 ? UP : DOWN} />
))}
|
);
}
if (view === "stack") {
return (
{showAmountAxis && }
fmt(v)} />
{on.fixed && }
{on.running && }
{on.income && }
);
}
// 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 (
{/* Defined before use so the fade ids exist when the Lines below mount. */}
{profitGradients.map((c) => (
))}
{showAmountAxis && }
{on.profit && }
{on.profit && (
)}
fmt(v)}
content={}
/>
{on.income && }
{on.fixed && }
{on.running && }
{/* These must be DIRECT children of LineChart — Recharts parses only
recognised child types and silently drops anything else, so wrapping
them in a 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) => (
))}
{/* 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 && (
{
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 ;
}
const c = p.profit >= 0 ? UP : DOWN;
return p.runningKnown
?
: ;
}}
/>
)}
);
}
|