diff --git a/app/income/page.tsx b/app/income/page.tsx
index 453c7a9..8728c83 100644
--- a/app/income/page.tsx
+++ b/app/income/page.tsx
@@ -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 (
{/* 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() {
Project income per machine per month · 2026
- {/* 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. */}
-
+ {/* The YTD income/profit tiles live inside IncomeView now, so they follow the
+ active/inactive filter instead of reporting the whole fleet. */}
+
-
-
);
}
diff --git a/app/page.tsx b/app/page.tsx
index a3efe08..dc2b78c 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -106,10 +106,14 @@ export default async function DashboardPage() {
Income vs Fixed vs Running
2026 monthly trend · R
+ {/* 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. */}
diff --git a/components/DashboardCharts.tsx b/components/DashboardCharts.tsx
index b12aa37..beb04f6 100644
--- a/components/DashboardCharts.tsx
+++ b/components/DashboardCharts.tsx
@@ -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 , 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 & {
+ /** 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 (
+
+ {label != null &&
{label}
}
+
+ {rows.map((r, i) => (
+
+
+
+ {r.name}
+
+
+ {r.text}
+
+
+ ))}
+
+
+ );
+}
+
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 & { 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((s, v) => s + (v ?? 0), 0);
+
return (
-
+ {/* ComposedChart, not AreaChart: Recharts only parses the child types each
+ wrapper declares, and AreaChart does not accept . A profit Line
+ dropped into an AreaChart vanishes without an error. */}
+
@@ -39,15 +158,44 @@ export function IncomeAreaChart({ data }: { data: { month: string; income: numbe
+ {gradients.map((g) => (
+
+
+
+
+ ))}
-
-
- `${(v / 1000).toFixed(0)}K`} />
- ZAR(v)} />
-
-
-
-
+
+
+ `${(v / 1000).toFixed(0)}K`} />
+
+ }
+ />
+
+
+
+ {/* Profit must be DIRECT children of the chart — Recharts parses only known
+ child types, so wrapping these in a makes the whole line disappear. */}
+ {series.map((s) => (
+
+ ))}
+
@@ -68,11 +217,17 @@ export function IncomeBarChart({ data }: { data: { month: string; income: number
-
-
- `${(v / 1000).toFixed(0)}K`} />
- ZAR(v)} cursor={{ fill: "#f9fafb" }} />
-
+
+
+ `${(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. */}
+ }
+ />
+
@@ -89,7 +244,7 @@ export function OverheadDonut({ data }: { data: { name: string; value: number }[
))}
- ZAR(v)} />
+ } />
diff --git a/components/IncomeView.tsx b/components/IncomeView.tsx
index 9f9a9a7..d5d2008 100644
--- a/components/IncomeView.tsx
+++ b/components/IncomeView.tsx
@@ -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(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
- {rows.length - totals.profitable} loss-making
+ {visible.length - totals.profitable} loss-making
{totals.invoiceVat > 0 && (
@@ -91,6 +111,31 @@ export function IncomeView({ rows, palette }: { rows: IncomeRow[]; palette: { in
))}
+ {/* 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. */}
+
@@ -107,6 +152,23 @@ export function IncomeView({ rows, palette }: { rows: IncomeRow[]; palette: { in
+ {/* 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. */}
+
+
{view === "cards" ? (
= 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 & { 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 , so 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({
{/* Defined before use so the fade ids exist when the Lines below mount. */}
- {crossings.map((c) => (
-
+ {profitGradients.map((c) => (
+
diff --git a/lib/chart-profit.ts b/lib/chart-profit.ts
new file mode 100644
index 0000000..f531f66
--- /dev/null
+++ b/lib/chart-profit.ts
@@ -0,0 +1,115 @@
+// Per-segment colouring for a profit line.
+//
+// Recharts draws a as a single path, so cannot recolour one
+// segment of it — Cells only work on . Getting a line that is green where the
+// month made money and red where it did not therefore means one per same-sign
+// run, plus one extra 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 };
+}
diff --git a/lib/rows.ts b/lib/rows.ts
index 034468c..6af22b9 100644
--- a/lib/rows.ts
+++ b/lib/rows.ts
@@ -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),