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 ( +
+
+ {label} +
+ ); +} + // 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