diff --git a/app/page.tsx b/app/page.tsx
index cc3f783..7efb0cd 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -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() {
- {/* 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. */}
+
+
Monthly Income trend
diff --git a/components/DashboardCharts.tsx b/components/DashboardCharts.tsx
index aef34cf..7e1ceb8 100644
--- a/components/DashboardCharts.tsx
+++ b/components/DashboardCharts.tsx
@@ -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
& { 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 ;
+ const color = d.profit >= 0 ? UP : DOWN;
+ const hollow = d.hasRunning === false;
+ return (
+
+ );
+ };
+
+ 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 (
+
+ {/* flex-wrap, so on mobile the legend drops under the heading instead of squeezing
+ the title to one word per line. */}
+
+
+
Monthly profit
+
+ Income less fixed and running costs, per month ยท 2026
+
+
+
+
+
+ {optimistic && }
+
+
+
+
+
+ {/* 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. */}
+
+
+ {gradients.map((g) => (
+
+
+
+
+ ))}
+
+
+
+ `${(v / 1000).toFixed(0)}K`} />
+ }
+ />
+ {/* The marker line. `transparent`, not `none`, so Recharts still builds the
+ path and hands each point to renderDot. */}
+
+ {/* One line per same-sign run, direct children of the chart โ Recharts parses
+ only known child types, so a wrapper makes the whole thing vanish. */}
+ {series.map((s) => (
+
+ ))}
+
+
+
+
+
+
+ {upMonths} of {upMonths + lossMonths} months made a profit
+
+ {optimistic && A hollow dot is a month with no running cost booked yet.}
+
+
+ );
+}
+
+// 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 (
+
+
{d.month}
+
+ -
+
+
+ {isUp ? "Profit" : "Loss"}
+
+
+ {ZAR(d.profit)}
+
+
+ {d.hasRunning === false && (
+ - no running cost booked yet
+ )}
+
+
+ );
+}
+
+function LegendDot({ color, label, hollow }: { color: string; label: string; hollow?: boolean }) {
+ return (
+
+ );
+}
+
// 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