mega-plant/components/MachineLineChart.tsx
David def147bc73 Income: hide stood-down machines, with an active/inactive filter
The field is `assets.is_active`. All 7 inactive machines have no income at all
— the three generators, two porta camps, AEB Plant Versekering and the Diesel
Truck — so on the income list they were a flat row of zeros and nothing else,
reading as "idle" when they are actually out of the fleet.

They are now hidden by default. The rows are not dropped from the data: the
filter lives in the view, so Active / All / Inactive is a client-side toggle
with no refetch, and each shows its count so a filter that silently hides rows
is obvious. A machine hidden by the filter also collapses if it was expanded.

The YTD income and profit tiles moved into the view so they sum only what is
visible — otherwise the headline would report the whole fleet while the list
below it showed a subset.

/charts is unchanged: it passes no filter and still sees everything.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-03 18:40:32 +02:00

369 lines
16 KiB
TypeScript

"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.
<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",
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<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
// 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 <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;
},
});
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: 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)} />
<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 as number | null) ?? 0) >= 0 ? UP : DOWN} />
))}
</Bar>
</ComposedChart>
</ResponsiveContainer>
</div>
);
}
if (view === "stack") {
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 }} stackOffset="sign">
<CartesianGrid stroke="#f3f4f6" strokeDasharray="3 3" vertical={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} />}
<Tooltip formatter={(v: number) => fmt(v)} />
{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>
);
}
// 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 (
<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>
{profitGradients.map((c) => (
<linearGradient key={c.id} 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: 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>
);
}