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>
This commit is contained in:
David 2026-10-03 18:40:32 +02:00
parent 299c20ae57
commit def147bc73
7 changed files with 397 additions and 112 deletions

View file

@ -1,5 +1,4 @@
import { getAssets, getActualMonthlyIncome, getRunningCosts, getInvoiceLines } from "@/lib/data";
import { ZAR } from "@/lib/utils";
import { TrendingUp } from "lucide-react";
import { IncomeView } from "@/components/IncomeView";
import { buildIncomeRows } from "@/lib/rows";
@ -21,9 +20,6 @@ export default async function IncomePage() {
// show how much of it is invoice income and how much of that is VAT.
const rows = buildIncomeRows(assets, incomeActual, running, {}, invoiceLines);
const totalIncome = rows.reduce((s, m) => s + m.income, 0);
const totalProfit = rows.reduce((s, m) => s + m.ytdProfit, 0);
return (
<div className="p-4 sm:p-8 max-w-[1400px]">
{/* Stacks until lg, not sm. As a side-by-side row the title block took 371px
@ -41,23 +37,13 @@ export default async function IncomePage() {
</div>
<p className="text-gray-500 dark:text-gray-400 text-sm">Project income per machine per month · 2026</p>
</div>
{/* grid, not flex-wrap: a wrapping flex row sizes tiles by CONTENT, which
is the ragged-left-edge trap AGENTS.md records for /income/invoices.
Full-width stacked on mobile, two-up from sm — same shape the dashboard
cards already use there. */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="bg-gradient-to-br from-[#00798C] to-[#003D5B] rounded-2xl px-5 py-3 text-white shadow-lg shadow-teal-500/20">
<div className="text-xs text-white/80">YTD Income</div>
<div className="text-2xl font-semibold tabular-nums">{ZAR(totalIncome)}</div>
</div>
<div className={`bg-gradient-to-br ${totalProfit >= 0 ? "from-[#EDAE49] to-[#D1495B]" : "from-[#D1495B] to-[#003D5B]"} rounded-2xl px-5 py-3 text-white shadow-lg`}>
<div className="text-xs text-white/80">YTD Profit</div>
<div className="text-2xl font-semibold tabular-nums">{ZAR(totalProfit)}</div>
</div>
</div>
{/* The YTD income/profit tiles live inside IncomeView now, so they follow the
active/inactive filter instead of reporting the whole fleet. */}
<IncomeView
rows={rows}
palette={{ income: "#00798C", profit: "#00798C", loss: "#D1495B" }}
/>
</div>
<IncomeView rows={rows} palette={{ income: "#00798C", profit: "#00798C", loss: "#D1495B" }} />
</div>
);
}

View file

@ -106,10 +106,14 @@ export default async function DashboardPage() {
<h2 className="text-base font-semibold text-[#003D5B] dark:text-white">Income vs Fixed vs Running</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">2026 monthly trend · R</p>
</div>
{/* The dots read the same tokens the chart reads. They were hard-coded, so
the Running dot was Brick Red while the Running line is actually the
project's purple — the legend was describing a colour not on screen. */}
<div className="flex items-center gap-4 text-xs">
<LegendDot color="#00798C" label="Income" />
<LegendDot color="#EDAE49" label="Fixed" />
<LegendDot color="#D1495B" label="Running" />
<LegendDot color="var(--color-chart-income)" label="Income" />
<LegendDot color="var(--color-chart-fixed)" label="Fixed" />
<LegendDot color="var(--color-chart-running)" label="Running" />
<LegendDot color="var(--color-chart-profit)" label="Profit" />
</div>
</div>
<IncomeAreaChart data={monthsIncome} />
@ -171,7 +175,7 @@ function GradientCard({
style={{ width: `${Math.min(100, Math.max(0, pct))}%` }}
/>
</div>
<div className="flex items-center justify-between mt-2 text-[10px] text-white/80">
<div className="flex items-center justify-between mt-2 text-xs text-white/80">
<span>{negative ? "Deficit" : "On target"}</span>
<span>{pct}%</span>
</div>

View file

@ -1,10 +1,13 @@
"use client";
import { useEffect, useId, useState } from "react";
import {
AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
ComposedChart, Area, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, PieChart, Pie, Cell,
} from "recharts";
import type { TooltipProps } from "recharts";
import { ZAR } from "@/lib/utils";
import { buildProfitSeries } from "@/lib/chart-profit";
const pieColors = ["#30638E", "#00798C", "#EDAE49", "#003D5B", "#D1495B", "#EDAE49", "#00798C"];
@ -15,6 +18,89 @@ function readVar(name: string, fallback: string): string {
return v || fallback;
}
// Dark mode is a class on <html>, which React cannot see. Watching it keeps the
// tooltip and the bar cursor correct the instant the theme is toggled.
function useIsDark(): boolean {
const [dark, setDark] = useState(false);
useEffect(() => {
const root = document.documentElement;
const update = () => setDark(root.classList.contains("dark"));
update();
const mo = new MutationObserver(update);
mo.observe(root, { attributes: true, attributeFilter: ["class"] });
return () => mo.disconnect();
}, []);
return dark;
}
// One tooltip for every chart on the dashboard.
//
// Recharts' DEFAULT tooltip paints each row's text in the SERIES COLOUR. The pie's
// slice colours include #003D5B and #30638E, so in dark mode the tooltip was drawing
// near-black text on the near-black tooltip background that globals.css sets — legible
// on nothing. globals.css can only restyle the container; the per-row colour is
// inline, so the row has to be rendered here instead.
//
// It keeps the `recharts-default-tooltip` class, so globals.css still styles the box.
type DashTooltipProps = TooltipProps<number, string> & {
/** Keys that only exist to carry a gradient, never shown as their own row. */
internal?: RegExp;
/** Profit is computed from the payload rather than drawn as a series. */
profitRow?: { name: string; value: number; up: string; down: string } | null;
};
function DashTooltip({ active, payload, label, internal, profitRow }: DashTooltipProps) {
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?.test(key)) continue;
if (key === "profit") continue;
if (p.value == null) continue;
rows.push({ name: p.name ?? key, text: ZAR(Number(p.value)), color: p.color || p.stroke || "#9ca3af" });
}
if (profitRow) {
rows.push({
name: profitRow.name,
text: ZAR(profitRow.value),
color: profitRow.value >= 0 ? profitRow.up : profitRow.down,
});
}
if (!rows.length) return null;
return (
<div className="recharts-default-tooltip">
{label != null && <div className="recharts-tooltip-label">{label}</div>}
<ul className="recharts-tooltip-items" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{rows.map((r, i) => (
<li
key={`${r.name}-${i}`}
className="recharts-tooltip-item"
style={{ display: "flex", alignItems: "center", gap: 8, padding: "1px 0" }}
>
<span
style={{
width: 8, height: 8, borderRadius: 2, flexShrink: 0,
background: r.color,
}}
/>
<span className="recharts-tooltip-item-name" style={{ color: "inherit", opacity: 0.85 }}>
{r.name}
</span>
<span
className="recharts-tooltip-item-value"
style={{ marginLeft: "auto", fontWeight: 600, color: "inherit" }}
>
{r.text}
</span>
</li>
))}
</ul>
</div>
);
}
export function IncomeAreaChart({ data }: { data: { month: string; income: number; fixed: number; running: number; isForecast: boolean }[] }) {
// Part A.3 — clip to last actual month. Past months stay out so the chart doesn't flatline.
const lastActualIdx = data.findLastIndex((m) => !m.isForecast && (m.income || m.fixed || m.running));
@ -22,10 +108,43 @@ export function IncomeAreaChart({ data }: { data: { month: string; income: numbe
const INC = readVar("--color-chart-income", "#0090A6");
const FIX = readVar("--color-chart-fixed", "#BE851F");
const RUN = readVar("--color-chart-running", "#8B5CF6");
const UP = readVar("--color-chart-profit", "#7A9E06");
const DOWN = readVar("--color-chart-loss", "#C13B4D");
const dark = useIsDark();
const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
// Income less fixed less running, per the client's ruling for this chart.
// A month with no income at all gets null rather than 0: 0 would plot a confident
// green "no loss" point for a month that was never billed.
const profit = clipped.map((m) => (m.income ? m.income - m.fixed - m.running : null));
const chartData = clipped.map((m, i) => ({ ...m, profit: profit[i] })) as (
Record<string, unknown> & { month: string; profit: number | null }
)[];
const { series, gradients } = buildProfitSeries({
profit,
upColor: UP,
downColor: DOWN,
idPrefix: `${uid}-dash`,
mutate: (key, keep) => {
const on = new Set(keep);
for (let i = 0; i < chartData.length; i++) chartData[i][key] = on.has(i) ? profit[i] : null;
},
});
// One total row for the tooltip rather than a per-variant row per run, which would
// otherwise list "profitRun0" and "profitCross0" as if they were separate series.
const hasProfit = profit.some((v) => v != null);
const profitTotal = profit.reduce<number>((s, v) => s + (v ?? 0), 0);
return (
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={clipped} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
{/* ComposedChart, not AreaChart: Recharts only parses the child types each
wrapper declares, and AreaChart does not accept <Line>. A profit Line
dropped into an AreaChart vanishes without an error. */}
<ComposedChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="gIncome" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={INC} stopOpacity={0.4} />
@ -39,15 +158,44 @@ export function IncomeAreaChart({ data }: { data: { month: string; income: numbe
<stop offset="0%" stopColor={RUN} stopOpacity={0.3} />
<stop offset="100%" stopColor={RUN} stopOpacity={0.02} />
</linearGradient>
{gradients.map((g) => (
<linearGradient key={g.id} id={g.id} x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor={g.from} />
<stop offset="100%" stopColor={g.to} />
</linearGradient>
))}
</defs>
<CartesianGrid stroke="#f3f4f6" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="month" stroke="#9ca3af" tickLine={false} axisLine={false} />
<YAxis stroke="#9ca3af" tickLine={false} axisLine={false} tickFormatter={(v) => `${(v / 1000).toFixed(0)}K`} />
<Tooltip formatter={(v: number) => ZAR(v)} />
<Area type="monotone" dataKey="income" stroke={INC} strokeWidth={2.5} fill="url(#gIncome)" />
<Area type="monotone" dataKey="fixed" stroke={FIX} strokeWidth={2.5} fill="url(#gFixed)" />
<Area type="monotone" dataKey="running" stroke={RUN} strokeWidth={2.5} fill="url(#gRunning)" />
</AreaChart>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="month" tickLine={false} axisLine={false} />
<YAxis tickLine={false} axisLine={false} tickFormatter={(v) => `${(v / 1000).toFixed(0)}K`} />
<Tooltip
cursor={{ fill: dark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.03)" }}
content={
<DashTooltip
internal={/^(profitRun|profitCross)/}
profitRow={hasProfit ? { name: "Profit", value: profitTotal, up: UP, down: DOWN } : null}
/>
}
/>
<Area type="monotone" dataKey="income" name="Income" stroke={INC} strokeWidth={2.5} fill="url(#gIncome)" />
<Area type="monotone" dataKey="fixed" name="Fixed" stroke={FIX} strokeWidth={2.5} fill="url(#gFixed)" />
<Area type="monotone" dataKey="running" name="Running" stroke={RUN} strokeWidth={2.5} fill="url(#gRunning)" />
{/* Profit must be DIRECT children of the chart — Recharts parses only known
child types, so wrapping these in a <g> makes the whole line disappear. */}
{series.map((s) => (
<Line
key={s.key}
type="monotone"
dataKey={s.key}
stroke={s.stroke}
strokeWidth={2.5}
dot={false}
connectNulls={false}
isAnimationActive={false}
legendType="none"
/>
))}
</ComposedChart>
</ResponsiveContainer>
</div>
);
@ -58,6 +206,7 @@ export function IncomeBarChart({ data }: { data: { month: string; income: number
const lastActualIdx = data.findLastIndex((m) => m.income > 0);
const clipped = lastActualIdx >= 0 ? data.slice(0, lastActualIdx + 1) : data;
const INC = readVar("--color-chart-income", "#0090A6");
const dark = useIsDark();
return (
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
@ -68,11 +217,17 @@ export function IncomeBarChart({ data }: { data: { month: string; income: number
<stop offset="100%" stopColor={INC} stopOpacity={0.5} />
</linearGradient>
</defs>
<CartesianGrid stroke="#f3f4f6" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="month" stroke="#9ca3af" tickLine={false} axisLine={false} />
<YAxis stroke="#9ca3af" tickLine={false} axisLine={false} tickFormatter={(v) => `${(v / 1000).toFixed(0)}K`} />
<Tooltip formatter={(v: number) => ZAR(v)} cursor={{ fill: "#f9fafb" }} />
<Bar dataKey="income" fill="url(#gBar)" radius={[8, 8, 0, 0]} />
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="month" tickLine={false} axisLine={false} />
<YAxis tickLine={false} axisLine={false} tickFormatter={(v) => `${(v / 1000).toFixed(0)}K`} />
{/* The cursor is the band that follows the pointer across the bars. It was a
hard-coded #f9fafb, so scrolling the bars threw a near-white stripe across
a dark card. */}
<Tooltip
cursor={{ fill: dark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.03)" }}
content={<DashTooltip />}
/>
<Bar dataKey="income" name="Income" fill="url(#gBar)" radius={[8, 8, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
@ -89,7 +244,7 @@ export function OverheadDonut({ data }: { data: { name: string; value: number }[
<Cell key={i} fill={pieColors[i % pieColors.length]} />
))}
</Pie>
<Tooltip formatter={(v: number) => ZAR(v)} />
<Tooltip content={<DashTooltip />} />
</PieChart>
</ResponsiveContainer>
</div>

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useMemo } from "react";
import { useState, useMemo, useEffect } from "react";
import { TrendingUp, TrendingDown, LayoutGrid, Sparkles, ChevronRight } from "lucide-react";
import { Sparkline } from "@/components/Sparkline";
import { MONTH_NAMES } from "@/lib/data";
@ -12,23 +12,43 @@ export function IncomeView({ rows, palette }: { rows: IncomeRow[]; palette: { in
const [view, setView] = useState<"cards" | "grid">("cards");
const [sort, setSort] = useState<"profit" | "income">("profit");
const [expanded, setExpanded] = useState<string | null>(null);
// Machines the client has stood down show nothing but a flat row of zeros — they are
// out of the fleet, not idle. Hidden by default, reachable behind the filter.
const [status, setStatus] = useState<"active" | "all" | "inactive">("active");
const visible = useMemo(
() =>
rows.filter((r) =>
status === "all" ? true : status === "active" ? r.isActive : !r.isActive,
),
[rows, status],
);
useEffect(() => {
// A machine hidden by the filter must not stay stuck expanded.
if (expanded && !visible.some((r) => r.id === expanded)) setExpanded(null);
}, [visible, expanded]);
const activeN = useMemo(() => rows.filter((r) => r.isActive).length, [rows]);
const inactiveN = rows.length - activeN;
const sorted = useMemo(() => {
return [...rows].sort((a, b) =>
return [...visible].sort((a, b) =>
sort === "profit" ? b.ytdProfit - a.ytdProfit : b.income - a.income
);
}, [rows, sort]);
}, [visible, sort]);
const totals = useMemo(() => {
const totalIncome = rows.reduce((s, r) => s + r.income, 0);
const totalProfit = rows.reduce((s, r) => s + r.ytdProfit, 0);
const profitable = rows.filter((r) => r.ytdProfit > 0).length;
// Summed over what is VISIBLE, so the headline never disagrees with the list below it.
const totalIncome = visible.reduce((s, r) => s + r.income, 0);
const totalProfit = visible.reduce((s, r) => s + r.ytdProfit, 0);
const profitable = visible.filter((r) => r.ytdProfit > 0).length;
// VAT already inside the booked figures — booked amounts are VAT-inclusive, so this
// is part of the income total and is called out rather than hidden.
const invoiceVat = rows.reduce((s, r) => s + r.invoiceVat, 0);
const invoiceIncome = rows.reduce((s, r) => s + r.invoiceIncome, 0);
const invoiceVat = visible.reduce((s, r) => s + r.invoiceVat, 0);
const invoiceIncome = visible.reduce((s, r) => s + r.invoiceIncome, 0);
return { totalIncome, totalProfit, profitable, invoiceVat, invoiceIncome };
}, [rows]);
}, [visible]);
return (
<>
@ -39,7 +59,7 @@ export function IncomeView({ rows, palette }: { rows: IncomeRow[]; palette: { in
{totals.profitable} profitable
</span>
<span className="px-2.5 py-1 rounded-md bg-[#D1495B]/10 text-[#D1495B] font-medium">
{rows.length - totals.profitable} loss-making
{visible.length - totals.profitable} loss-making
</span>
{totals.invoiceVat > 0 && (
<span className="px-2.5 py-1 rounded-md bg-[#EDAE49]/15 text-[#8a6420] dark:text-[#EDAE49] font-medium">
@ -91,6 +111,31 @@ export function IncomeView({ rows, palette }: { rows: IncomeRow[]; palette: { in
</button>
))}
</div>
{/* Status filter. Stands down machines are hidden by default; this is the only
way to see them, so the count is stated rather than leaving a silent gap. */}
<div className="flex items-center gap-1 p-0.5 rounded-lg bg-white dark:bg-white/5 border border-gray-200 dark:border-white/5">
{([
{ id: "active", label: "Active", n: activeN },
{ id: "all", label: "All", n: rows.length },
{ id: "inactive", label: "Inactive", n: inactiveN },
] as const).map((o) => (
<button
key={o.id}
onClick={() => setStatus(o.id)}
aria-pressed={status === o.id}
className={`min-h-[44px] px-3 rounded-md text-xs font-medium transition ${
status === o.id
? o.id === "inactive"
? "bg-[#D1495B] text-white"
: "bg-[#003D5B] text-white dark:bg-[#00798C]"
: "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
}`}
>
{o.label} <span className="opacity-60">({o.n})</span>
</button>
))}
</div>
{/* Part B.3: mobile sort dropdown */}
<div className="sm:hidden">
<label className="sr-only" htmlFor="income-sort">Sort by</label>
@ -107,6 +152,23 @@ export function IncomeView({ rows, palette }: { rows: IncomeRow[]; palette: { in
</div>
</div>
{/* grid, not flex-wrap: a wrapping flex row sizes tiles by CONTENT, which is the
ragged-left-edge trap AGENTS.md records for /income/invoices. */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-5">
<div className="bg-gradient-to-br from-[#00798C] to-[#003D5B] rounded-2xl px-5 py-3 text-white shadow-lg shadow-teal-500/20">
<div className="text-xs text-white/80">YTD Income</div>
<div className="text-2xl font-semibold tabular-nums">{ZAR(totals.totalIncome)}</div>
</div>
<div
className={`bg-gradient-to-br ${
totals.totalProfit >= 0 ? "from-[#EDAE49] to-[#D1495B]" : "from-[#D1495B] to-[#003D5B]"
} rounded-2xl px-5 py-3 text-white shadow-lg`}
>
<div className="text-xs text-white/80">YTD Profit</div>
<div className="text-2xl font-semibold tabular-nums">{ZAR(totals.totalProfit)}</div>
</div>
</div>
{view === "cards" ? (
<CardsView
rows={sorted}

View file

@ -7,6 +7,7 @@ import {
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";
@ -64,8 +65,6 @@ type Point = {
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
@ -141,27 +140,6 @@ function ChartTooltip({ active, label, payload, showProfit, fmt, up, down }: Too
);
}
// 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",
@ -224,46 +202,26 @@ export function MachineLineChart({
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
// 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.
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,
}];
// 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) =>
@ -332,8 +290,8 @@ export function MachineLineChart({
<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%">
{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>

115
lib/chart-profit.ts Normal file
View file

@ -0,0 +1,115 @@
// Per-segment colouring for a profit line.
//
// Recharts draws a <Line> as a single <Curve> path, so <Cell> cannot recolour one
// segment of it — Cells only work on <Bar>. Getting a line that is green where the
// month made money and red where it did not therefore means one <Line> per same-sign
// run, plus one extra <Line> per genuine crossing to carry the gradient between them.
//
// This is the shared implementation. `/charts` (MachineLineChart) and the dashboard
// (IncomeAreaChart) both draw the same shape of series, and two private copies of this
// logic would drift the moment one of them was fixed.
//
// The colours come from the tokens already ruled for this purpose — see AGENTS.md:
// `--color-chart-profit` is a deliberate exception to the "never change the palette"
// rule, and `--color-chart-loss` has its own token precisely so a loss does not follow
// the Running line's colour when that hue is recoloured.
export type ProfitRun = {
/** Index of the first month in the run. */
start: number;
/** Index of the last month in the run, inclusive. */
end: number;
/** true = the month made money (or broke even). */
up: boolean;
};
// Split a 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 — treating a missing
// month as 0 would draw a confident line across a gap in the data.
export function splitProfitRuns(profit: readonly (number | null)[]): ProfitRun[] {
const runs: ProfitRun[] = [];
let i = 0;
while (i < profit.length) {
const first = profit[i];
if (first == null) {
i++;
continue;
}
const up = first >= 0;
let j = i;
while (
j + 1 < profit.length &&
profit[j + 1] != null &&
(profit[j + 1] as number) >= 0 === up
) {
j++;
}
runs.push({ start: i, end: j, up });
i = j + 1;
}
return runs;
}
export type ProfitSeriesDef = { key: string; stroke: string };
export type ProfitGradient = { id: string; from: string; to: string };
/**
* Write one key per profit variant onto the SHARED chart data, and describe the series
* and gradients to draw.
*
* Nothing is given a `data` prop of its own: Recharts builds a categorical axis by
* concatenating the data of every series, so a Line carrying a short `data` re-adds the
* month labels and the axis renders "Jan … Aug, Jan … Aug" while that series plots at
* the wrong months. Instead each variant is a key on the one dataset, nulled outside
* its own run, and `connectNulls={false}` then draws exactly that run in the right
* place.
*
* `mutate` is called for every variant with a `keep` index list and the key to write.
*/
export function buildProfitSeries({
profit,
upColor,
downColor,
idPrefix,
mutate,
}: {
profit: readonly (number | null)[];
upColor: string;
downColor: string;
/** Unique per chart instance, or two charts would share one gradient def. */
idPrefix: string;
mutate: (key: string, keep: number[]) => void;
}): { series: ProfitSeriesDef[]; gradients: ProfitGradient[] } {
const runs = splitProfitRuns(profit);
const series: ProfitSeriesDef[] = [];
const gradients: ProfitGradient[] = [];
const keepRange = (from: number, to: number) => {
const keep: number[] = [];
for (let i = from; i <= to; i++) keep.push(i);
return keep;
};
// A run of a single point has no segment to draw, so it is skipped. The point still
// shows as a dot on the axis below, which is where a lone month's profit belongs.
runs.forEach((r, idx) => {
if (r.end <= r.start) return;
const key = `profitRun${idx}`;
mutate(key, keepRange(r.start, r.end));
series.push({ key, stroke: r.up ? upColor : downColor });
});
// One gradient series per genuine crossing: two adjacent runs, same sign flip.
runs.forEach((a, idx) => {
const b = runs[idx + 1];
// Not adjacent means a null month sits between them — never bridge that gap.
if (!b || a.end + 1 !== b.start) return;
const key = `profitCross${idx}`;
const id = `${idPrefix}-g${idx}`;
mutate(key, [a.end, b.start]);
series.push({ key, stroke: `url(#${id})` });
gradients.push({ id, from: a.up ? upColor : downColor, to: b.up ? upColor : downColor });
});
return { series, gradients };
}

View file

@ -14,6 +14,10 @@ export type IncomeRow = {
running: number;
ytdProfit: number;
lastActualMonth: number; // Part A.3: clip x-axis
// Machines the client has stood down. They still hold their history, so they are kept
// in the data and filtered in the view rather than dropped here — /income defaults to
// active only, but the client can switch to All or Inactive.
isActive: boolean;
// Portion of `income` that came from invoices, and how much of that is VAT.
// Booked amounts are VAT-inclusive (see lib/invoice-booking-policy.json), so without
// this the per-machine figure is not explainable.
@ -42,6 +46,7 @@ export function buildIncomeRows(
if (!a.code) continue;
byAsset.set(a.id, {
id: a.id, code: a.code, name: a.name,
isActive: a.is_active !== false,
months: Array(12).fill(null),
fixedMonths: Array(12).fill(null),
runningMonths: Array(12).fill(null),