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:
parent
ea94754f02
commit
8d957a1f16
2 changed files with 208 additions and 2 deletions
11
app/page.tsx
11
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() {
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue