Dashboard: a Monthly profit chart, one dot per month

The profit line already drawn on Income vs Fixed vs Running, isolated into its
own full-width card above the monthly trend, with the marker the eye actually
wants: a green dot on a month that made money, a red dot on one that did not.
A line rather than bars, so it is the same series the card above draws and the
two cannot drift.

The dots are NOT on the run lines. A run of a single month has no segment, so a
lone profitable month would have had no dot at all and vanished from the chart.
A second, transparent Line carries `profit` and draws one marker per month
coloured from that month's own sign.

September is a HOLLOW dot, which is the honest part: monthly_running_costs has
rows for Jan-Aug only, so September's profit has had no running cost subtracted
and is optimistic. /charts already marks such a month the same way. The chart
also says so in words, and the KPI is unaffected.

The tooltip shows the hovered MONTH's profit, not a running total, and names it
Profit or Loss by sign rather than surfacing the profitRun0/profitCross0 keys.
This commit is contained in:
David 2026-10-03 19:13:58 +02:00
parent ea94754f02
commit 8d957a1f16
2 changed files with 208 additions and 2 deletions

View file

@ -3,7 +3,7 @@ import { buildSiteSummaries, latestInvoiceYear } from "@/lib/sites";
import { computeProfit } from "@/lib/profit";
import { ZAR } from "@/lib/utils";
import { Truck, Wallet, TrendingUp, TrendingDown } from "lucide-react";
import { IncomeAreaChart, IncomeBarChart, IncomePerSiteChart, OverheadDonut, PIE_COLORS, type SiteIncomeRow } from "@/components/DashboardCharts";
import { IncomeAreaChart, IncomeBarChart, IncomePerSiteChart, MonthlyProfitChart, OverheadDonut, PIE_COLORS, type SiteIncomeRow } from "@/components/DashboardCharts";
// Reads PocketBase on every request. Without this Next tries to prerender the
// page at build time, which makes `docker build` depend on the database being
@ -52,6 +52,10 @@ export default async function DashboardPage() {
income: ms.reduce((s: number, r: MonthlyIncome) => s + r.income, 0),
fixed: ms.reduce((s: number, r: MonthlyIncome) => s + r.fixed_expenses, 0),
running: rm.reduce((s: number, r: MonthlyRunningCost) => s + r.amount, 0),
// Whether running costs were booked AT ALL for that month, which a running total of
// 0 cannot say. The profit card needs it: a month with no cost row has no cost
// subtracted, so its profit is optimistic and gets a hollow dot.
hasRunning: rm.length > 0,
isForecast,
};
});
@ -156,8 +160,11 @@ export default async function DashboardPage() {
</section>
</div>
{/* Bottom row */}
{/* Bottom row. Monthly profit is its own full-width row above the trend: the same
line the Income vs Fixed vs Running card already draws, isolated. */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<MonthlyProfitChart data={monthsIncome} />
<section className="lg:col-span-2 bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-6 shadow-sm">
<div className="mb-5">
<h2 className="text-base font-semibold text-[#003D5B] dark:text-white">Monthly Income trend</h2>

View file

@ -234,6 +234,205 @@ export function IncomeBarChart({ data }: { data: { month: string; income: number
);
}
// The profit line from IncomeAreaChart, on its own, with a DOT per month.
//
// Same series, same arithmetic, same tokens — it is the very line drawn on the card
// above, isolated so the months the business made money and the months it did not can be
// read at a glance. Dots are drawn by a second, invisible Line carrying `profit`, NOT by
// the run lines: a run of a single month has no segment and so no dot of its own, and a
// lone profitable month would simply disappear. One marker per month, coloured from that
// month's own sign, cannot miss one.
export function MonthlyProfitChart({
data,
}: {
data: { month: string; income: number; fixed: number; running: number; hasRunning?: boolean }[];
}) {
// Clip to the last month with an income figure, exactly as the other two charts do.
const lastIdx = data.findLastIndex((m) => m.income > 0);
const clipped = lastIdx >= 0 ? data.slice(0, lastIdx + 1) : data;
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, "");
// A month with no income figure is null, never 0 — 0 would plot a confident green
// dot 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; hasRunning?: boolean }
)[];
const { series, gradients } = buildProfitSeries({
profit,
upColor: UP,
downColor: DOWN,
idPrefix: `${uid}-mp`,
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;
},
});
// A month with no monthly_running_costs row has no running cost subtracted, so its
// profit is optimistic. /charts already marks those with a hollow dot; this chart is the
// same series, so it marks them the same way. The fill is --color-surface, which is the
// card's own background in both themes, so the line does not show through the middle.
const renderDot = (props: { key?: string; cx?: number; cy?: number; payload?: { profit: number | null; hasRunning?: boolean } }) => {
const d = props.payload;
if (!d || d.profit == null) return <g key={props.key} />;
const color = d.profit >= 0 ? UP : DOWN;
const hollow = d.hasRunning === false;
return (
<circle
key={props.key}
cx={props.cx}
cy={props.cy}
r={4.5}
fill={hollow ? "var(--color-surface)" : color}
stroke={color}
strokeWidth={2}
/>
);
};
const upMonths = profit.filter((v) => v != null && v >= 0).length;
const lossMonths = profit.filter((v) => v != null && v < 0).length;
const optimistic = chartData.some((d) => d.hasRunning === false);
return (
<section className="lg:col-span-3 bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-6 shadow-sm">
{/* flex-wrap, so on mobile the legend drops under the heading instead of squeezing
the title to one word per line. */}
<div className="flex flex-wrap items-end justify-between gap-3 mb-2">
<div>
<h2 className="text-base font-semibold text-[#003D5B] dark:text-white">Monthly profit</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Income less fixed and running costs, per month · 2026
</p>
</div>
<div className="flex flex-wrap items-center gap-4 text-xs">
<LegendDot color={UP} label="Made profit" />
<LegendDot color={DOWN} label="Made a loss" />
{optimistic && <LegendDot color="transparent" label="No running cost yet" hollow />}
</div>
</div>
<div className="h-56">
<ResponsiveContainer width="100%" height="100%">
{/* ComposedChart, not LineChart: the dots ride a Line with a transparent stroke,
and LineChart's own parsing of children is the part of Recharts that quietly
drops a series. */}
<ComposedChart data={chartData} margin={{ top: 12, right: 16, left: 0, bottom: 0 }}>
<defs>
{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 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={{ stroke: dark ? "rgba(255,255,255,0.18)" : "rgba(0,0,0,0.12)" }}
content={<ProfitTooltip up={UP} down={DOWN} />}
/>
{/* The marker line. `transparent`, not `none`, so Recharts still builds the
path and hands each point to renderDot. */}
<Line
type="monotone"
dataKey="profit"
stroke="transparent"
strokeWidth={0}
dot={renderDot}
activeDot={false}
connectNulls={false}
isAnimationActive={false}
legendType="none"
/>
{/* One line per same-sign run, direct children of the chart — Recharts parses
only known child types, so a <g> wrapper makes the whole thing vanish. */}
{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>
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-gray-500 dark:text-gray-400 mt-2">
<span>
{upMonths} of {upMonths + lossMonths} months made a profit
</span>
{optimistic && <span>A hollow dot is a month with no running cost booked yet.</span>}
</div>
</section>
);
}
// The hovered MONTH, not the run keys. Recharts feeds a tooltip one entry per series, so
// the per-run keys would otherwise surface as "profitRun0" and "profitCross0" and "profit"
// would appear three or four times over.
function ProfitTooltip({
active,
payload,
up,
down,
}: {
active?: boolean;
payload?: { payload?: { month: string; profit: number | null; hasRunning?: boolean } }[];
up: string;
down: string;
}) {
const d = payload?.[0]?.payload;
if (!active || !d || d.profit == null) return null;
const isUp = d.profit >= 0;
return (
<div className="recharts-default-tooltip">
<div className="recharts-tooltip-label">{d.month}</div>
<ul className="recharts-tooltip-items" style={{ listStyle: "none", margin: 0, padding: 0 }}>
<li style={{ display: "flex", alignItems: "center", gap: 8, padding: "1px 0" }}>
<span style={{ width: 8, height: 8, borderRadius: 4, flexShrink: 0, background: isUp ? up : down }} />
<span className="recharts-tooltip-item-name" style={{ color: "inherit", opacity: 0.85 }}>
{isUp ? "Profit" : "Loss"}
</span>
<span
className="recharts-tooltip-item-value"
style={{ marginLeft: "auto", fontWeight: 600, color: "inherit" }}
>
{ZAR(d.profit)}
</span>
</li>
{d.hasRunning === false && (
<li style={{ fontSize: 11, opacity: 0.8, paddingTop: 2 }}>no running cost booked yet</li>
)}
</ul>
</div>
);
}
function LegendDot({ color, label, hollow }: { color: string; label: string; hollow?: boolean }) {
return (
<div className="flex items-center gap-1.5">
<div
className="w-2.5 h-2.5 rounded-full shrink-0"
style={hollow ? { border: "2px solid var(--color-text-soft)" } : { background: color }}
/>
<span className="text-gray-500 dark:text-gray-400">{label}</span>
</div>
);
}
// One bar per site, for one month of the site's billing series.
//
// HORIZONTAL, not vertical: the x axis is the site NAME, so a site with a long name