"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" | "profit-line"; // 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: view !== "profit-line" && (series?.income ?? true), fixed: view !== "profit-line" && (series?.fixed ?? true), running: view !== "profit-line" && (series?.running ?? true), profit: view === "profit-line" || ((series?.profit ?? true) && showProfitLine), }; // 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 // MonthlyProfitChart uses this same helper, so the two cannot drift apart. const { series: seriesDefs, gradients: profitGradients } = buildProfitSeries({ profit: points.map((p) => p.profit), upColor: UP, downColor: DOWN, idPrefix: uid, mutate: (key, keep) => { const on = new Set(keep); for (let i = 0; i < data.length; i++) data[i][key] = on.has(i) ? points[i].profit : null; }, }); 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: showAmountAxis ? "right" as const : "left" as const, stroke: "#9ca3af", tickLine: false, axisLine: false, tickFormatter: fmt, // A standalone profit line owns the left axis, including labels on mobile. // Only the secondary axis drops its ticks on a phone to protect plot width. tick: narrow && showAmountAxis ? false : { fontSize: tickSize }, width: showAmountAxis ? (narrow ? 1 : 52) : 60, // 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 ? : ; }} /> )}
); }