diff --git a/app/charts/page.tsx b/app/charts/page.tsx index e47dda2..1e72f7c 100644 --- a/app/charts/page.tsx +++ b/app/charts/page.tsx @@ -14,7 +14,10 @@ export default async function ChartsPage() { getRunningCosts(), ]); - const rows = buildIncomeRows(assets, incomeActual, running); + // /charts shows profit including running cost (ChartsGrid passes the same flag to + // MachineLineChart), so the card badge and the profit line cannot disagree. + // Scoped to this page — lib/profit.ts keeps its workbook default for every other surface. + const rows = buildIncomeRows(assets, incomeActual, running, { includeRunningCost: true }); const items = rows.map((r) => { // r.months / r.fixedMonths / r.runningMonths already distinguish null (forecast) from 0 (Part A.2). diff --git a/app/globals.css b/app/globals.css index 944c915..fd1b02b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -23,7 +23,14 @@ Brand palette (#EDAE49/#00798C/#D1495B) does NOT change; these are read only by chart marks. */ --color-chart-income: #0090A6; --color-chart-fixed: #BE851F; - --color-chart-running: #C13B4D; + --color-chart-running: #8B5CF6; + /* A month that did not make profit. Its own token so moving the Running line + cannot drag the profit-loss red along with it. */ + --color-chart-loss: #C13B4D; + /* Profit-positive. The client's chosen bright green, 2026-10-02 — deliberately + outside the Coolors brand set so the profit line reads as its own series and + not as another teal. */ + --color-chart-profit: #7A9E06; --color-brand-blue: #30638E; --color-brand-orange: #EDAE49; @@ -77,7 +84,9 @@ /* Part A.4: dark-mode chart hues */ --color-chart-income: #00A6BE; --color-chart-fixed: #BE851F; - --color-chart-running: #D94F60; + --color-chart-running: #8B5CF6; + --color-chart-loss: #D94F60; + --color-chart-profit: #7A9E06; } html, body { @@ -92,6 +101,11 @@ html, body { body, .dark body { transition: background-color 0.2s ease, color 0.2s ease; } .recharts-cartesian-axis-tick text { fill: var(--color-text-soft); font-size: 11px; } +/* Round 4: no text under 12px on mobile. This CSS rule beats Recharts' tick + fontSize prop, so the bump has to happen here or nowhere. */ +@media (max-width: 639px) { + .recharts-cartesian-axis-tick text { font-size: 12px; } +} .recharts-cartesian-grid line { stroke: var(--color-border-soft); } .recharts-default-tooltip { background: rgba(255,255,255,0.98) !important; diff --git a/components/ChartsGrid.tsx b/components/ChartsGrid.tsx index 18c72a6..cec6123 100644 --- a/components/ChartsGrid.tsx +++ b/components/ChartsGrid.tsx @@ -1,10 +1,13 @@ "use client"; import { useState, useMemo, useEffect } from "react"; -import { Search, TrendingUp, TrendingDown, LineChart as LineChartIcon, BarChart3, Scale } from "lucide-react"; +import { Search, TrendingUp, TrendingDown, LineChart as LineChartIcon, BarChart3, Scale, SlidersHorizontal } from "lucide-react"; import { ZAR2 } from "@/lib/utils"; -import { MachineLineChart, type ChartView } from "@/components/MachineLineChart"; -import { computeProfit } from "@/lib/profit"; +import { MachineLineChart, type ChartView, type SeriesVisible } from "@/components/MachineLineChart"; + +// /charts reads profit INCLUDING running cost, so the line, the card badge and the +// Profit view all sit on one basis. lib/profit.ts keeps its workbook default. +const CHART_PROFIT_INCLUDE_RUNNING = true; type Item = { id: string; @@ -16,6 +19,17 @@ type Item = { }; const VIEW_KEY = "mp:chartsView"; +const SERIES_KEY = "mp:chartsSeries"; + +// Which lines a card draws. The Profit toggle also controls the loss colour, +// because red is a sign of the profit line, not a series of its own. +const ALL_SERIES: SeriesVisible = { income: true, fixed: true, running: true, profit: true }; +const SERIES_LIST: { key: keyof SeriesVisible; label: string; color: string }[] = [ + { key: "income", label: "Income", color: "var(--color-chart-income)" }, + { key: "fixed", label: "Fixed", color: "var(--color-chart-fixed)" }, + { key: "running", label: "Running", color: "var(--color-chart-running)" }, + { key: "profit", label: "Profit", color: "var(--color-chart-profit)" }, +]; export function ChartsGrid({ items }: { items: Item[] }) { const [query, setQuery] = useState(""); @@ -34,6 +48,51 @@ export function ChartsGrid({ items }: { items: Item[] }) { try { localStorage.setItem(VIEW_KEY, v); } catch {} }; + const [series, setSeries] = useState(ALL_SERIES); + const [seriesOpen, setSeriesOpen] = useState(false); + + useEffect(() => { + try { + const stored = localStorage.getItem(SERIES_KEY); + if (stored) { + const parsed = JSON.parse(stored) as Partial; + if (parsed && typeof parsed === "object") setSeries({ ...ALL_SERIES, ...parsed }); + } + } catch {} + }, []); + + const toggleSeries = (k: keyof SeriesVisible) => { + setSeries((prev) => { + const next = { ...prev, [k]: !prev[k] }; + // An empty chart reads as broken, so the last visible line stays on. + if (Object.values(next).every((v) => !v)) return prev; + try { localStorage.setItem(SERIES_KEY, JSON.stringify(next)); } catch {} + return next; + }); + }; + + const resetSeries = () => { + setSeries(ALL_SERIES); + try { localStorage.setItem(SERIES_KEY, JSON.stringify(ALL_SERIES)); } catch {} + }; + + // Close the popover on Escape or an outside click. + useEffect(() => { + if (!seriesOpen) return; + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setSeriesOpen(false); }; + const onDown = (e: MouseEvent) => { + if (!(e.target as HTMLElement)?.closest?.("[data-series-popover]")) setSeriesOpen(false); + }; + document.addEventListener("keydown", onKey); + document.addEventListener("mousedown", onDown); + return () => { + document.removeEventListener("keydown", onKey); + document.removeEventListener("mousedown", onDown); + }; + }, [seriesOpen]); + + const seriesOn = SERIES_LIST.filter((s) => series[s.key]).length; + const filtered = useMemo(() => { let list = items; if (query.trim()) { @@ -52,14 +111,30 @@ export function ChartsGrid({ items }: { items: Item[] }) { return { profitable, total: items.length, totalIncome }; }, [items]); - // Legend swatches per view (Part A.5) - const legend = view === "profit" - ? [{ color: "var(--color-chart-running)", label: "Profit / Loss" }] - : [ - { color: "var(--color-chart-income)", label: "Income" }, - { color: "var(--color-chart-fixed)", label: "Fixed" }, - { color: "var(--color-chart-running)", label: "Running" }, - ]; + // Legend swatches per view (Part A.5). Profit/Loss only appear where they are + // actually drawn — the Lines view is the only one carrying the profit line, so + // the Cost-stack view must not advertise it. A hidden series leaves the legend + // too, so the key always matches the card. + const legend: { color: string; label: string }[] = ( + view === "profit" + ? [ + { color: "var(--color-chart-profit)", label: "Profit" }, + { color: "var(--color-chart-loss)", label: "Loss" }, + ] + : view === "lines" + ? [ + series.income ? { color: "var(--color-chart-income)", label: "Income" } : null, + series.fixed ? { color: "var(--color-chart-fixed)", label: "Fixed" } : null, + series.running ? { color: "var(--color-chart-running)", label: "Running" } : null, + series.profit ? { color: "var(--color-chart-profit)", label: "Profit" } : null, + series.profit ? { color: "var(--color-chart-loss)", label: "Loss" } : null, + ] + : [ + series.income ? { color: "var(--color-chart-income)", label: "Income" } : null, + series.fixed ? { color: "var(--color-chart-fixed)", label: "Fixed" } : null, + series.running ? { color: "var(--color-chart-running)", label: "Running" } : null, + ] + ).filter((x): x is { color: string; label: string } => x !== null); return ( <> @@ -85,11 +160,17 @@ export function ChartsGrid({ items }: { items: Item[] }) { {l.label} ))} + {view === "lines" && series.profit && ( +
+
+ hollow = no running-cost data +
+ )}
{/* Toolbar — wraps to multiple rows on mobile. Round 4 Part B.1/B.3/B.5. */} -
+
setFilter(f.id as typeof filter)} - className={`min-h-[44px] px-3 text-xs font-medium rounded-lg transition ${ + className={`min-h-[44px] min-w-[44px] px-3 text-xs font-medium rounded-lg transition ${ filter === f.id ? "bg-[#30638E] text-white" : "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5" @@ -139,6 +220,66 @@ export function ChartsGrid({ items }: { items: Item[] }) { ))}
+ + {/* Series filter — a small block of checkboxes so a card can be read with + the profit line off, or with only income and profit on, etc. */} +
+ + + {seriesOpen && ( +
+
+ Show on chart +
+ {SERIES_LIST.map((s) => { + // Only the LAST remaining series is locked — the guard exists to stop + // an empty chart, not to trap the reader on one series. Disabling every + // box here meant the only way to add a line back was "Show all". + const locked = seriesOn === 1 && series[s.key]; + return ( + + ); + })} + +
+ )} +
{filtered.length === 0 ? ( @@ -152,15 +293,17 @@ export function ChartsGrid({ items }: { items: Item[] }) { ) : (
{filtered.map((item) => { - const positive = computeProfit({ income: item.totalIncome, fixed: 0, running: 0 }) >= 0 || item.totalIncome > 0; - // Profit uses computeProfit() — Part A.6 - const profit = computeProfit({ income: item.totalIncome, fixed: 0, running: 0 }); + // Was computeProfit({ income: totalIncome, fixed: 0, running: 0 }) — which is + // just totalIncome, so every card showed a green "profit" equal to its income. + // totalProfit is the real YTD figure from buildIncomeRows, on the same basis + // the profit line uses below. + const profit = item.totalProfit; const positiveProfit = profit >= 0; return (
- + {item.code}

{item.name}

@@ -181,6 +324,8 @@ export function ChartsGrid({ items }: { items: Item[] }) { fixed={item.bucket.fixed} running={item.bucket.running} view={view} + includeRunningCost={CHART_PROFIT_INCLUDE_RUNNING} + series={series} />
); diff --git a/components/DashboardCharts.tsx b/components/DashboardCharts.tsx index 478398b..b12aa37 100644 --- a/components/DashboardCharts.tsx +++ b/components/DashboardCharts.tsx @@ -21,7 +21,7 @@ export function IncomeAreaChart({ data }: { data: { month: string; income: numbe const clipped = lastActualIdx >= 0 ? data.slice(0, lastActualIdx + 1) : data; const INC = readVar("--color-chart-income", "#0090A6"); const FIX = readVar("--color-chart-fixed", "#BE851F"); - const RUN = readVar("--color-chart-running", "#C13B4D"); + const RUN = readVar("--color-chart-running", "#8B5CF6"); return (
diff --git a/components/MachineLineChart.tsx b/components/MachineLineChart.tsx index 56eb9ca..46e4594 100644 --- a/components/MachineLineChart.tsx +++ b/components/MachineLineChart.tsx @@ -2,13 +2,23 @@ import { ComposedChart, Line, LineChart, Bar, AreaChart, Area, Cell, - XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, + XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, } from "recharts"; +import { useEffect, useId, useState } from "react"; import { MONTH_NAMES } from "@/lib/data"; -import { computeProfit } from "@/lib/profit"; +import { computeProfit, type ProfitOptions } from "@/lib/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)[]; @@ -18,6 +28,12 @@ type Props = { 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 { @@ -26,15 +42,154 @@ function readVar(name: string, fallback: string): string { 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; +}; + +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 +// 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} +
+ ))} +
+ ); +} + +// 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", - incomeColor, fixedColor, runningColor, profitColor, + 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", "#C13B4D"); - const PROF = profitColor ?? readVar("--color-chart-running", "#D1495B"); + 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), @@ -43,29 +198,106 @@ export function MachineLineChart({ ); 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: computeProfit({ income: income[i] ?? 0, fixed: fixed[i] ?? 0, running: running[i] ?? 0 }), - })); + 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 })[]; + + 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 + // 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, + }]; + }); 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 ? INC : RUN} /> + = 0 ? UP : DOWN} /> ))} @@ -80,12 +312,12 @@ export function MachineLineChart({ - - + + {showAmountAxis && } fmt(v)} /> - - - + {on.fixed && } + {on.running && } + {on.income && }
@@ -98,17 +330,82 @@ export function MachineLineChart({
+ {/* Defined before use so the fade ids exist when the Lines below mount. */} + + {crossings.map((c) => ( + + + + + ))} + - - - fmt(v)} /> - - - + + {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 + ? + : ; + }} + /> + )}
); } - -