feat(running): the year to date AND the live month on the running-cost pages

The client asked for two things on the running-cost pages: drop the two prose
notes, and show the year-to-date figure next to the total for the current month.

- /running loses the "Shown to Jan, Feb..." and "A re-cut of costs..."
  paragraphs. The tiles carry the year and the month instead.
- /running shows "Nov so far" beside "YTD running cost", and the "made of"
  line is printed for the month as well as for the year, so the month's total
  is explained on the page and not only in the grid.
- The cards follow the income cards: YTD spend | Nov so far side by side from
  md, the month on its own line below md, and the sparkline yields its width
  below xl so the vehicle NAME is never truncated to pay for a decorative
  trend. Below sm the name wraps instead of being cut short.
- /running/insurance shows the premium in force this month, and its
  year-to-date figure now stops at the months that have happened. December is
  a rate on file, not money spent, so including it made this page a full year
  while the Overview showed eleven months - the same cost kind reading as two
  different numbers on two screens.
- /running/financing shows "Committed for Nov" beside a new year-to-date
  instalment figure.

One rule, three screens. elapsedMonths() in lib/income-month.ts counts the
months that have happened and is the same clock the income pages use, and
financeByAsset() / fleetFinanceByMonth() in lib/running-costs.ts are the only
finance rule the Overview and the financing register both read. The Overview's
insurance and finance year-to-date figures now equal the insurance and
financing pages' figures exactly.

lib/running-month.test.ts asserts it, including the insurance cross-check
between the register and the Overview.

A settled agreement still contributes nothing to any month - the instalments
TLB06 was paying in January-October are not in the Overview's finance figure.
The client confirmed the financing is correct as it stands, so that is left
alone and recorded in AGENTS.md rather than quietly changed.
This commit is contained in:
Dave 2026-11-05 01:42:23 +02:00
parent 5f1f30f907
commit 5634c067c4
10 changed files with 450 additions and 81 deletions

View file

@ -38,6 +38,11 @@ Every page must pass the mobile checklist before it is considered complete:
2. **No text under 12px on mobile.** Bump `text-[10px]` labels up.
3. **No tap target under 44×44px.** Keep the visual size if you like — add padding to the hit area.
4. **Wide tables scroll inside their own container** with a sticky first column and `-webkit-overflow-scrolling: touch`. Add a visible scroll affordance (e.g. right-edge fade).
Modern Chrome does not SUPPORT that property any more (`CSS.supports` returns false), so it
is inert either way. React's client renderer drops the literal string when a grid is
rendered client-side (a Cards/Grid toggle), while a server-rendered grid keeps it - so the
audit script can report `touchScroll: false` on a grid that is functionally correct. Judge
it on the container, the sticky column and the affordance, not on that string.
5. **Filter controls collapse** below `lg` — pills become a `<select>` dropdown. Don't stack 13 pills into 13 rows.
6. **Verify at 375×812 and 768×1024.** Run `document.documentElement.scrollWidth > document.documentElement.clientWidth` and confirm it returns `false`. Count tap targets and text sizes. Screenshot every route.
@ -628,8 +633,38 @@ The four kinds ARE the whole of a vehicle's running cost — there is no fifth s
the parts always add up to the vehicle's spend, and the vehicles add up to the Overview.
That is now shown, not asserted: each card shows its four parts, the expanded panel shows
them against the vehicle's total, the grid's footer shows the fleet's four parts above the
fleet total, and the page header states the parts and the total side by side. Any change
that touches a cost kind must keep that additivity true.
fleet total, and the page header states the parts and the total side by side - for the
YEAR and for the live month. Any change that touches a cost kind must keep that
additivity true.
### The month on the running-cost pages - YTD and the live month
Every running-cost page shows TWO figures: the year to date and the live month. The client
asked for both on 2026-11-05, and the three pages under Running Costs now read the same
way: `/running`, `/running/insurance` and `/running/financing`.
- **The live month is the live CALENDAR month, decided on the SERVER.** `elapsedMonths(year)`
in `lib/income-month.ts` counts the months that have happened (11 in November) and is the
same clock the income pages use. The month index is passed down as a plain number; the
browser never works it out for itself.
- **A year-to-date figure counts only the months that have HAPPENED.** A premium for
December is a rate on file, not money the client has spent. `buildInsuranceRows` takes a
`throughMonth` and stops there, so the insurance page's YTD is the same figure as the
Overview's insurance column instead of being a month apart.
- **The Overview and the registers quote the SAME rands for the same cost kind.** The
Overview's insurance YTD = the insurance page's YTD. The Overview's finance YTD = the
financing page's YTD. Both read ONE rule - `financeByAsset` / `fleetFinanceByMonth` in
`lib/running-costs.ts` - so the three screens cannot drift apart.
`lib/running-month.test.ts` asserts the insurance cross-check; keep it passing.
- **A live agreement is charged every month; a settled one is charged nothing.** TLB06 is
marked settled, so it contributes nothing to ANY month - the instalments it was actually
paying in January-October 2026 are therefore NOT in the Overview's finance figure. That
is the rule the client confirmed ("I think financing is correct"); do not "fix" it by
charging a settled agreement for the months before it was settled.
- **On a card the month figure sits BESIDE the year figure** (`md` and up) and gets its own
line below `md` - the same shape the income cards use. The card's sparkline yields its
width to the month figure below `xl`, because the vehicle NAME must not be truncated to
pay for a decorative trend.
## Oil Stock — a cost code can name a STORE, not a machine

View file

@ -5,6 +5,9 @@ import { Landmark, ArrowLeft, TriangleAlert, CalendarClock } from "lucide-react"
import { FinancingView } from "@/components/FinancingView";
import { buildFinancingRows } from "@/lib/rows";
import { VAT_NOTE } from "@/lib/insurance-rate-policy";
import { fleetFinanceByMonth } from "@/lib/running-costs";
import { elapsedMonths } from "@/lib/income-month";
import { MONTH_NAMES } from "@/lib/data";
// 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
@ -29,8 +32,23 @@ export default async function RunningFinancingPage() {
const now = { year: new Date().getFullYear(), month: new Date().getMonth() + 1 };
const rows = buildFinancingRows(assets, terms, now);
// The SAME finance rule the Overview uses (lib/running-costs.ts), applied to the
// agreements that are actually on the register — so the register's month figure and
// the Overview's finance column are the same rands, not two that can drift apart.
const YEAR = 2026;
const through = elapsedMonths(YEAR);
const monthIndex = through - 1;
const monthName = MONTH_NAMES[monthIndex];
const windowLabel = `${MONTH_NAMES[0]} – ${MONTH_NAMES[through - 1]}`;
const byId = new Map(assets.map((a) => [a.id, a]));
const registerTerms = terms.filter((t) => {
const a = byId.get(t.asset);
return Boolean(a && a.code);
});
const fleetFinance = fleetFinanceByMonth(registerTerms, through);
// A settled agreement has stopped being paid, so it must not sit in the run-rate.
const committed = rows.reduce((s, r) => s + (r.isSettled ? 0 : r.installment), 0);
const monthInstalment = fleetFinance[monthIndex] ?? 0;
const ytdInstalment = fleetFinance.slice(0, through).reduce((s, v) => s + v, 0);
const settled = rows.filter((r) => r.isSettled).length;
const live = rows.length - settled;
const dated = rows.filter((r) => r.calc.monthsRemaining !== null).length;
@ -46,7 +64,7 @@ export default async function RunningFinancingPage() {
<ArrowLeft className="w-3 h-3" /> Back to Running Costs
</Link>
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between mb-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between mb-6">
<div className="min-w-0">
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 shrink-0 rounded-xl bg-gradient-to-br from-[#30638E]/15 to-[#003D5B]/15 grid place-items-center text-[#30638E]">
@ -63,9 +81,18 @@ export default async function RunningFinancingPage() {
Every instalment below is <span className="font-medium">{VAT_NOTE}</span>.
</p>
</div>
<div className="bg-gradient-to-br from-[#30638E] to-[#003D5B] rounded-2xl px-5 py-3 text-white shadow-lg shadow-blue-900/20 self-start sm:self-auto">
<div className="text-xs text-white/80">Committed per month</div>
<div className="text-2xl font-semibold tabular-nums">{ZAR(committed)}</div>
{/* The year and the live month, on the same clock as the Overview. */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 self-start sm:self-auto">
<div className="bg-gradient-to-br from-[#30638E] to-[#003D5B] rounded-2xl px-5 py-3 text-white shadow-lg shadow-blue-900/20">
<div className="text-xs text-white/80">YTD instalments · {VAT_NOTE}</div>
<div className="text-2xl font-semibold tabular-nums">{ZAR(ytdInstalment)}</div>
<div className="text-xs text-white/75 tabular-nums mt-0.5">{windowLabel} 2026</div>
</div>
<div className="bg-white dark:bg-surface border border-[#30638E]/30 rounded-2xl px-5 py-3 shadow-sm">
<div className="text-xs text-gray-500 dark:text-gray-400">Committed for {monthName}</div>
<div className="text-2xl font-semibold tabular-nums text-[#003D5B] dark:text-white">{ZAR(monthInstalment)}</div>
<div className="text-xs text-gray-400 dark:text-text-soft tabular-nums mt-0.5">what leaves the bank each month</div>
</div>
</div>
</div>

View file

@ -5,6 +5,8 @@ import { Shield, ArrowLeft, TriangleAlert } from "lucide-react";
import { InsuranceView } from "@/components/InsuranceView";
import { buildInsuranceRows } from "@/lib/rows";
import { VAT_NOTE } from "@/lib/insurance-rate-policy";
import { elapsedMonths } from "@/lib/income-month";
import { MONTH_NAMES } from "@/lib/data";
// 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
@ -25,9 +27,22 @@ export const dynamic = "force-dynamic";
export default async function RunningInsurancePage() {
const [assets, rates] = await Promise.all([getAssets(), getInsuranceRates()]);
const rows = buildInsuranceRows(assets, rates);
// The year-to-date window: the months that have HAPPENED. A premium for a month
// that has not happened yet is a rate on file, not a cost the client has incurred, so
// it is not part of the year to date — that is what keeps this page's YTD equal to
// the Overview's insurance column instead of a month apart.
const YEAR = 2026;
const through = elapsedMonths(YEAR);
const monthIndex = through - 1;
const monthName = MONTH_NAMES[monthIndex];
const windowLabel = `${MONTH_NAMES[0]} – ${MONTH_NAMES[through - 1]}`;
const rows = buildInsuranceRows(assets, rates, through);
const ytd = rows.reduce((s, r) => s + r.ytd, 0);
// The premium in force THIS month — what the fleet's insurance costs right now.
const monthPremium = rows.reduce((s, r) => s + (r.months[monthIndex] ?? 0), 0);
const monthCovered = rows.filter((r) => r.months[monthIndex] !== null).length;
// The run-rate going forward: the premium currently charged on each machine.
const currentMonthly = rows.reduce((s, r) => s + (r.currentRate ?? 0), 0);
const reRated = rows.filter((r) => r.periods.length > 1).length;
@ -44,7 +59,7 @@ export default async function RunningInsurancePage() {
<ArrowLeft className="w-3 h-3" /> Back to Running Costs
</Link>
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between mb-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between mb-6">
<div className="min-w-0">
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 shrink-0 rounded-xl bg-gradient-to-br from-[#EDAE49]/15 to-[#D1495B]/15 grid place-items-center text-[#EDAE49]">
@ -62,17 +77,26 @@ export default async function RunningInsurancePage() {
insurer invoices, which is what is booked to the vehicle.
</p>
</div>
<div className="bg-gradient-to-br from-[#EDAE49] to-[#D1495B] rounded-2xl px-5 py-3 text-white shadow-lg shadow-orange-500/20 self-start sm:self-auto">
<div className="text-xs text-white/80">YTD insurance · {VAT_NOTE}</div>
<div className="text-2xl font-semibold tabular-nums">{ZAR(ytd)}</div>
{/* The year and the live month, on the same clock as the Overview. */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 self-start sm:self-auto">
<div className="bg-gradient-to-br from-[#EDAE49] to-[#D1495B] rounded-2xl px-5 py-3 text-white shadow-lg shadow-orange-500/20">
<div className="text-xs text-white/80">YTD insurance · {VAT_NOTE}</div>
<div className="text-2xl font-semibold tabular-nums">{ZAR(ytd)}</div>
<div className="text-xs text-white/75 tabular-nums mt-0.5">{windowLabel} 2026</div>
</div>
<div className="bg-white dark:bg-surface border border-[#EDAE49]/30 rounded-2xl px-5 py-3 shadow-sm">
<div className="text-xs text-gray-500 dark:text-gray-400">{monthName} premium · {VAT_NOTE}</div>
<div className="text-2xl font-semibold tabular-nums text-[#003D5B] dark:text-white">{ZAR(monthPremium)}</div>
<div className="text-xs text-gray-400 dark:text-text-soft tabular-nums mt-0.5">{monthCovered} vehicles insured</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<StatCard label="Vehicles insured" value={`${rows.length} / ${assets.length}`} color="#30638E" />
{/* The run-rate is what the next month will actually cost — the YTD figure
above can differ once a premium has moved mid-year. */}
<StatCard label="Current run-rate / month" value={ZAR(currentMonthly)} color="#003D5B" />
{/* The run-rate is what NEXT month will cost — the live month above is what
this month costs, and the two differ once a premium has moved mid-year. */}
<StatCard label="Next month run-rate" value={ZAR(currentMonthly)} color="#003D5B" />
<StatCard label="Re-rated this year" value={`${reRated} of ${rows.length}`} color="#EDAE49" />
</div>
@ -114,7 +138,12 @@ export default async function RunningInsurancePage() {
</p>
)}
<InsuranceView rows={rows} totalMachines={assets.length} />
<InsuranceView
rows={rows}
totalMachines={assets.length}
monthIndex={monthIndex}
throughMonth={through}
/>
</>
)}
</div>

View file

@ -6,7 +6,8 @@ import { Wrench, TriangleAlert } from "lucide-react";
import { RunningView } from "@/components/RunningView";
import { buildRunningRows } from "@/lib/rows";
import { MONTH_NAMES } from "@/lib/data";
import { machineRunningCosts, RUNNING_VAT_PCT, COST_KINDS, COST_KIND_LABELS } from "@/lib/running-costs";
import { machineRunningCosts, COST_KINDS, COST_KIND_LABELS } from "@/lib/running-costs";
import { currentMonthIndex, elapsedMonths } from "@/lib/income-month";
import type {
FinancingTerm, InsuranceRate, MonthlyRunningCost, Purchase, PurchaseLine,
} from "@/lib/data";
@ -41,9 +42,13 @@ export default async function RunningPage() {
pb.collection("financing_terms").getFullList<FinancingTerm>(),
]);
// Never project past the month we are IN — see lib/running-costs.ts.
const now = new Date();
const lastMonth = now.getFullYear() === 2026 ? now.getMonth() + 1 : 12;
// Never project past the month we are IN — see lib/running-costs.ts. The horizon
// and the live month are ONE clock: `elapsedMonths` counts the months that have
// happened (the columns that exist), `currentMonthIndex` names the one of them that
// is still filling up. Both are decided here, on the server, and passed down as
// plain numbers — the browser never works out the month for itself.
const YEAR = 2026;
const lastMonth = elapsedMonths(YEAR);
const { byAsset, undatedPurchases } = machineRunningCosts({
assets, repairs, purchases, purchaseLines: lines, insurance, finance,
@ -61,6 +66,12 @@ export default async function RunningPage() {
}
const grand = monthTotals.reduce((s, v) => s + v, 0);
const grandExcl = monthTotalsExcl.reduce((s, v) => s + v, 0);
// The live month — what the fleet has cost so far THIS month. It grows as repairs and
// purchases are entered against a vehicle, exactly like the income page's live month.
const monthIndex = currentMonthIndex(rows, YEAR);
const monthName = MONTH_NAMES[monthIndex] ?? "";
const monthTotal = monthTotals[monthIndex] ?? 0;
const monthTotalExcl = monthTotalsExcl[monthIndex] ?? 0;
const unallocated = lines.filter((l) => !l.asset).length;
// The four kinds are the whole of it — these parts add up to the Overview total, and
// every vehicle's parts add up to its own spend.
@ -68,11 +79,12 @@ export default async function RunningPage() {
kind: k,
label: COST_KIND_LABELS[k],
total: monthByKind[k].reduce((s, v) => s + v, 0),
month: monthByKind[k][monthIndex] ?? 0,
}));
return (
<div className="p-4 sm:p-8 max-w-[1400px]">
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between mb-6">
<div>
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 shrink-0 rounded-xl bg-gradient-to-br from-[#EDAE49]/15 to-[#D1495B]/15 grid place-items-center text-[#EDAE49]">
@ -83,38 +95,39 @@ export default async function RunningPage() {
<p className="text-gray-500 dark:text-gray-400 text-sm">
Everything a vehicle costs to run · repairs, purchases, insurance, finance · 2026
</p>
<p className="text-xs text-gray-400 dark:text-text-soft mt-1">
Shown to {MONTH_NAMES.slice(0, lastMonth).join(", ")}. A month ahead is not shown —
it cannot be calculated, only guessed at, and the last month keeps growing as
things are entered.
</p>
<p className="text-xs text-gray-400 dark:text-text-soft mt-1">
A re-cut of costs already counted in fixed expenses — do not add this to them.
Figures are incl VAT, with the excl figure derived at {RUNNING_VAT_PCT}%.
</p>
</div>
<div className="bg-gradient-to-br from-[#EDAE49] to-[#D1495B] rounded-2xl px-5 py-3 text-white shadow-lg shadow-orange-500/20 self-start sm:self-auto">
<div className="text-xs text-white/80">YTD running cost · incl VAT</div>
<div className="text-2xl font-semibold tabular-nums">{ZAR(grand)}</div>
<div className="text-xs text-white/75 tabular-nums mt-0.5">excl VAT {ZAR(grandExcl)}</div>
{/* The year and the live month — the same two figures the income page shows, on
the same clock (lib/income-month.ts). The month is shown even when it is
small: it is a live figure that grows as things are entered. */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 self-start sm:self-auto">
<div className="bg-gradient-to-br from-[#EDAE49] to-[#D1495B] rounded-2xl px-5 py-3 text-white shadow-lg shadow-orange-500/20">
<div className="text-xs text-white/80">YTD running cost · incl VAT</div>
<div className="text-2xl font-semibold tabular-nums">{ZAR(grand)}</div>
<div className="text-xs text-white/75 tabular-nums mt-0.5">excl VAT {ZAR(grandExcl)}</div>
</div>
<div className="bg-white dark:bg-surface border border-[#EDAE49]/30 rounded-2xl px-5 py-3 shadow-sm">
<div className="text-xs text-gray-500 dark:text-gray-400">{monthName} so far · incl VAT</div>
<div className="text-2xl font-semibold tabular-nums text-[#003D5B] dark:text-white">{ZAR(monthTotal)}</div>
<div className="text-xs text-gray-400 dark:text-text-soft tabular-nums mt-0.5">excl VAT {ZAR(monthTotalExcl)}</div>
</div>
</div>
</div>
{/* What the Overview total is made of. The four kinds are the whole of it, so this
line and the grid's footer both add up to the figure in the tile. */}
<div className="mb-5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
<span className="text-gray-400 dark:text-text-soft">Made of</span>
{kindTotals.map((k, index) => (
<span key={k.kind} className="whitespace-nowrap">
{k.label}{" "}
<span className="font-medium tabular-nums text-gray-600 dark:text-gray-300">{ZAR(k.total)}</span>
{index < kindTotals.length - 1 && <span className="ml-3 text-gray-300 dark:text-gray-600">·</span>}
</span>
))}
<span className="whitespace-nowrap">
<span className="text-gray-400 dark:text-text-soft">= Total</span>{" "}
<span className="font-semibold tabular-nums text-[#003D5B] dark:text-white">{ZAR(grand)}</span>
</span>
line and the grid's footer both add up to the figure in the tile. The live
month gets the same treatment: the month is explained on the page, not only
in the grid. */}
<div className="mb-5 space-y-1 text-xs text-gray-500 dark:text-gray-400">
<PartsLine
label="Made of"
parts={kindTotals.map((k) => ({ label: k.label, total: k.total }))}
total={grand}
/>
<PartsLine
label={`${monthName} made of`}
parts={kindTotals.map((k) => ({ label: k.label, total: k.month }))}
total={monthTotal}
/>
</div>
{undatedPurchases.length > 0 && (
@ -151,7 +164,35 @@ export default async function RunningPage() {
monthTotalsExcl={monthTotalsExcl}
byKind={monthByKind}
visibleMonths={lastMonth}
monthIndex={monthIndex}
/>
</div>
);
}
/** "Made of Repairs & parts R 1 · Purchases R 2 · … = Total R 3" — the four kinds and
* the total they add up to, for the year and for the live month. */
function PartsLine({
label, parts, total,
}: {
label: string;
parts: { label: string; total: number }[];
total: number;
}) {
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-gray-400 dark:text-text-soft">{label}</span>
{parts.map((p, index) => (
<span key={p.label} className="whitespace-nowrap">
{p.label}{" "}
<span className="font-medium tabular-nums text-gray-600 dark:text-gray-300">{ZAR(p.total)}</span>
{index < parts.length - 1 && <span className="ml-3 text-gray-300 dark:text-gray-600">·</span>}
</span>
))}
<span className="whitespace-nowrap">
<span className="text-gray-400 dark:text-text-soft">= Total</span>{" "}
<span className="font-semibold tabular-nums text-[#003D5B] dark:text-white">{ZAR(total)}</span>
</span>
</div>
);
}

View file

@ -32,10 +32,18 @@ function periodLabel(p: RatePeriod): string {
export function InsuranceView({
rows,
totalMachines,
monthIndex,
throughMonth,
}: {
rows: InsuranceRow[];
totalMachines: number;
/** The live month, 0-based, decided on the server. */
monthIndex?: number;
/** The last month of the year-to-date window, 1-based. */
throughMonth?: number;
}) {
const monthLabel = monthIndex != null && monthIndex >= 0 ? MONTH_NAMES[monthIndex] : null;
const windowLabel = `${MONTH_NAMES[0]} – ${MONTH_NAMES[(throughMonth ?? 12) - 1]}`;
// GRID opens first (client request, 2026-10-09). With Current / Previous / Year
// total in five columns, the table reads as a register at a glance — where the card
// list buried exactly those three figures behind a click, and a card per vehicle
@ -121,20 +129,22 @@ export function InsuranceView({
</div>
{view === "cards" ? (
<CardsView rows={sorted} expanded={expanded} setExpanded={setExpanded} />
<CardsView rows={sorted} expanded={expanded} setExpanded={setExpanded} monthLabel={monthLabel} windowLabel={windowLabel} />
) : (
<GridView rows={sorted} grandTotal={grandTotal} />
<GridView rows={sorted} grandTotal={grandTotal} windowLabel={windowLabel} throughMonth={throughMonth} />
)}
</>
);
}
function CardsView({
rows, expanded, setExpanded,
rows, expanded, setExpanded, monthLabel, windowLabel,
}: {
rows: InsuranceRow[];
expanded: string | null;
setExpanded: (id: string | null) => void;
monthLabel: string | null;
windowLabel: string;
}) {
return (
<div className="space-y-2">
@ -222,7 +232,7 @@ function CardsView({
<span className="text-gray-400 uppercase tracking-wider">YTD total</span>
<span className="font-semibold tabular-nums text-[#EDAE49]">{ZAR(r.ytd)}</span>
<span className="text-gray-500 dark:text-gray-400">
across {r.coveredMonths} of 12 months
across {r.chargedMonths} months · {windowLabel} 2026
</span>
</div>
</div>
@ -235,10 +245,12 @@ function CardsView({
}
function GridView({
rows, grandTotal,
rows, grandTotal, windowLabel, throughMonth,
}: {
rows: InsuranceRow[];
grandTotal: number;
windowLabel: string;
throughMonth?: number;
}) {
const scroller = useRef<HTMLDivElement>(null);
const [overflows, setOverflows] = useState(false);
@ -278,7 +290,7 @@ function GridView({
Previous
</th>
<th className="text-right py-3 px-6 font-medium whitespace-nowrap">
Year total<span className="block normal-case tracking-normal text-[12px] text-gray-400 dark:text-text-soft">{r_coveredLabel(rows)}</span>
YTD<span className="block normal-case tracking-normal text-[12px] text-gray-400 dark:text-text-soft">{windowLabel}</span>
</th>
</tr>
</thead>
@ -317,9 +329,9 @@ function GridView({
</td>
<td className="py-2.5 px-6 text-right tabular-nums font-semibold whitespace-nowrap text-[#003D5B] dark:text-white">
{ZAR(r.ytd)}
{r.coveredMonths < 12 && (
{r.chargedMonths < (throughMonth ?? 12) && (
<span className="block text-[12px] font-normal text-gray-400 dark:text-text-soft">
{r.coveredMonths} of 12 mo
{r.chargedMonths} of {throughMonth ?? 12} mo
</span>
)}
</td>
@ -351,9 +363,3 @@ function GridView({
</div>
);
}
/** "12 mo covered" / "1 gap" caption under the Year total header. */
function r_coveredLabel(rows: InsuranceRow[]): string {
const gaps = rows.filter((r) => r.coveredMonths < 12).length;
return gaps ? `${gaps} with a gap` : "12 mo covered";
}

View file

@ -31,6 +31,7 @@ export function RunningView({
monthTotalsExcl,
byKind,
visibleMonths,
monthIndex,
}: {
rows: RunningRow[];
palette: { income: string; profit: string; loss: string };
@ -46,6 +47,12 @@ export function RunningView({
* (client ruling, 2026-10-09).
*/
visibleMonths?: number;
/**
* The LIVE month, 0-based, decided on the server (lib/income-month.ts). It is the
* month that is still filling up, so its figure grows as repairs and purchases are
* entered against a vehicle.
*/
monthIndex?: number;
}) {
const [view, setView] = useState<"cards" | "grid">("cards");
const [sort, setSort] = useState<"ytd" | "code">("ytd");
@ -126,7 +133,7 @@ export function RunningView({
</div>
{view === "cards" ? (
<CardsView rows={sorted} expanded={expanded} setExpanded={setExpanded} palette={palette} visibleMonths={visibleMonths} />
<CardsView rows={sorted} expanded={expanded} setExpanded={setExpanded} palette={palette} visibleMonths={visibleMonths} monthIndex={monthIndex} />
) : (
<GridView rows={sorted} monthTotals={monthTotals} monthTotalsExcl={monthTotalsExcl} byKind={byKind} visibleMonths={visibleMonths} grandTotal={grandTotal} grandExcl={(monthTotalsExcl || []).reduce((s, v) => s + v, 0)} />
)}
@ -135,19 +142,22 @@ export function RunningView({
}
function CardsView({
rows, expanded, setExpanded, palette, visibleMonths,
rows, expanded, setExpanded, palette, visibleMonths, monthIndex,
}: {
rows: RunningRow[];
visibleMonths?: number;
expanded: string | null;
setExpanded: (id: string | null) => void;
palette: { income: string; profit: string; loss: string };
monthIndex?: number;
}) {
const monthLabel = monthIndex != null && monthIndex >= 0 ? MONTH_NAMES[monthIndex] : null;
return (
<div className="space-y-2">
{rows.map((r) => {
const isOpen = expanded === r.id;
const hasData = r.ytd > 0;
const monthValue = monthIndex != null ? r.months[monthIndex] ?? 0 : 0;
return (
<div
key={r.id}
@ -163,17 +173,36 @@ function CardsView({
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-sm font-medium text-[#003D5B] dark:text-white">
<Wrench className="w-3.5 h-3.5 text-[#EDAE49] shrink-0" />
<span className="truncate">{r.name}</span>
{/* On a phone the name wraps onto its own line rather than being
cut short — the same rule the income cards follow. */}
<span className="sm:truncate">{r.name}</span>
</div>
</div>
<div className="hidden sm:block shrink-0">
{/* The sparkline yields its width to the month figure below xl: the
content column is only ~670px wide between md and xl (the sidebar eats
the rest), and the vehicle NAME must not be truncated to pay for a
decorative trend. The same 12 months are on the strip in the expanded
panel, one click away. */}
<div className="hidden xl:block shrink-0">
<Sparkline data={r.months} color={palette.income} />
</div>
<div className="text-right shrink-0 w-32 hidden md:block">
<div className="text-xs text-gray-400 uppercase tracking-wider">YTD spend</div>
<div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">
{hasData ? ZAR2(r.ytd) : "—"}
{/* The money: the year figure and the live month, side by side — the same
shape the income cards use. */}
<div className="hidden md:flex shrink-0 items-start gap-4">
<div className="text-right w-28">
<div className="text-xs text-gray-400 uppercase tracking-wider">YTD spend</div>
<div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">
{hasData ? ZAR2(r.ytd) : "—"}
</div>
</div>
{monthLabel && (
<div className="text-right w-28">
<div className="text-xs text-gray-400 uppercase tracking-wider whitespace-nowrap">{monthLabel} so far</div>
<div className="text-sm font-semibold tabular-nums text-[#00798C] dark:text-[#EDAE49]">
{monthValue > 0 ? ZAR2(monthValue) : "—"}
</div>
</div>
)}
</div>
<div
className={`shrink-0 px-2.5 py-1.5 rounded-lg text-xs font-semibold ${
@ -187,6 +216,25 @@ function CardsView({
/>
</button>
{/* Below md the money row above is hidden, so the year and the live month
get their own line — the same shape the income cards use on a phone. */}
<div className="flex md:hidden items-end justify-between gap-3 px-4 pb-2 pt-1 text-xs">
<div>
<div className="text-gray-400 uppercase tracking-wider">YTD spend</div>
<div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">
{hasData ? ZAR2(r.ytd) : "—"}
</div>
</div>
{monthLabel && (
<div className="text-right">
<div className="text-gray-400 uppercase tracking-wider whitespace-nowrap">{monthLabel} so far</div>
<div className="text-sm font-semibold tabular-nums text-[#00798C] dark:text-[#EDAE49]">
{monthValue > 0 ? ZAR2(monthValue) : "—"}
</div>
</div>
)}
</div>
{/* What the vehicle's spend is MADE of, on the card itself. These four parts
are the whole of it, so they add up to the YTD figure above — and the
vehicles add up to the Overview. A vehicle with no spend shows nothing

View file

@ -86,3 +86,15 @@ export function monthProfit(rows: MonthRow[], index: number): number | null {
if (!monthHasPostedCosts(rows, index)) return null;
return monthIncome(rows, index) - monthFixed(rows, index);
}
/**
* How many months of `year` have happened: 11 in November, 12 once the year is over.
*
* A run-rate — a monthly instalment, a premium — is not a month-by-month record, so it
* has no `months` array for `currentMonthIndex` to read. This is how the pages that show
* one still count the same months the month-by-month pages do, so the two cannot
* disagree about how far the year has gone.
*/
export function elapsedMonths(year: number, now: Date = new Date()): number {
return now.getFullYear() === year ? now.getMonth() + 1 : 12;
}

View file

@ -324,10 +324,14 @@ export type InsuranceRow = {
// The cost per month, resolved against the premium in force THAT month.
// `null` = no premium on record for that month — never 0 (see lib/insurance.ts).
months: (number | null)[];
// Sum of the months we actually know. Months with no premium are excluded rather
// than counted as zero, so this is never quietly smaller than a full year.
// Sum of the months we actually know, INSIDE the year-to-date window. Months with
// no premium are excluded rather than counted as zero, so this is never quietly
// smaller than the time that has actually passed.
ytd: number;
/** Months of the whole year that carry a premium — gaps included. */
coveredMonths: number;
/** Months inside the year-to-date window that carry a premium. What `ytd` sums. */
chargedMonths: number;
currentRate: number | null;
// The machine's premium history, newest first — the point of the page.
periods: RatePeriod[];
@ -343,7 +347,15 @@ export type InsuranceRow = {
* A machine with no rate at all is left OUT, as `buildRunningRows` does for running
* cost: "no premium on record" is not "R0 of insurance".
*/
export function buildInsuranceRows(assets: Asset[], rates: InsuranceRate[]): InsuranceRow[] {
export function buildInsuranceRows(
assets: Asset[],
rates: InsuranceRate[],
// The last month that has HAPPENED, 1-based. The year-to-date figure sums only the
// months up to here: a premium for December is a rate that is on file, not a cost the
// client has incurred yet, and including it made this page's YTD disagree with the
// Overview's insurance column by exactly one month.
throughMonth = 12,
): InsuranceRow[] {
const byId = new Map(assets.map((a) => [a.id, a]));
const grouped = new Map<string, RatePeriod[]>();
@ -359,14 +371,16 @@ export function buildInsuranceRows(assets: Asset[], rates: InsuranceRate[]): Ins
for (const [assetId, periods] of grouped) {
const a = byId.get(assetId)!;
const cost = monthlyCost(periods);
const known = cost.filter((v): v is number => v !== null);
const window = cost.slice(0, Math.max(0, Math.min(12, throughMonth)));
const known = window.filter((v): v is number => v !== null);
out.push({
id: assetId,
code: a.code,
name: a.name,
months: cost,
ytd: known.reduce((s, v) => s + v, 0),
coveredMonths: known.length,
coveredMonths: cost.filter((v) => v !== null).length,
chargedMonths: known.length,
currentRate: currentRate(periods)?.rate ?? null,
// Newest first — the period you most likely came to read.
periods: [...periods].sort((x, y) => y.from - x.from),

View file

@ -80,6 +80,40 @@ function poMonth(p: Purchase, year: number): number | null {
* client's current sheet (18 agreements — R428 585,04/mo). Using both double-counts
* the four shared machines, so the current sheet wins and the stale rows are ignored.
*/
/**
* The finance cost per agreement, per month, up to the horizon.
*
* A LIVE agreement is charged its instalment every month — it is a monthly commitment,
* so it recurs. A SETTLED agreement has stopped being paid, so it contributes nothing:
* a paid-off machine is not a running cost.
*
* The Overview and the financing register BOTH read this, so the fleet's finance for a
* month is the same figure on both screens and they cannot drift apart.
*/
export function financeByAsset(terms: FinancingTerm[], horizon: number): Map<string, (number | null)[]> {
const out = new Map<string, (number | null)[]>();
for (const t of terms) {
if (t.is_settled) continue;
for (let m = 1; m <= horizon; m++) add(out, t.asset, m, t.installment || 0);
}
return out;
}
/**
* The fleet's finance cost per month — every live agreement's instalment, month by
* month. The financing register reads this for its own month and year-to-date figures,
* so the register and the Overview quote the same rands.
*/
export function fleetFinanceByMonth(terms: FinancingTerm[], horizon: number): number[] {
const months = Array(12).fill(0);
for (const perAsset of financeByAsset(terms, horizon).values()) {
perAsset.forEach((v, i) => {
if (v != null) months[i] += v;
});
}
return months;
}
export function machineRunningCosts(input: RunningCostInputs, lastMonth?: number): {
byAsset: Map<string, MachineCosts>;
undatedPurchases: string[];
@ -95,7 +129,6 @@ export function machineRunningCosts(input: RunningCostInputs, lastMonth?: number
const repairs = new Map<string, (number | null)[]>();
const purchases = new Map<string, (number | null)[]>();
const insurance = new Map<string, (number | null)[]>();
const finance = new Map<string, (number | null)[]>();
const undatedPurchases: string[] = [];
// 1. Repairs & parts — the workbook bootstrap, still the only repair history there is.
@ -133,11 +166,9 @@ export function machineRunningCosts(input: RunningCostInputs, lastMonth?: number
// 4. Finance — a MONTHLY instalment, so it recurs every month, and only while the
// agreement is live. Settled agreements have stopped, so they contribute nothing:
// a paid-off machine is not a running cost.
for (const t of input.finance) {
if (t.is_settled) continue;
for (let m = 1; m <= horizon; m++) add(finance, t.asset, m, t.installment || 0);
}
// a paid-off machine is not a running cost. The rule lives in `financeByAsset`,
// which the financing register reads too, so the two screens cannot disagree.
const finance = financeByAsset(input.finance, horizon);
const byAsset = new Map<string, MachineCosts>();
const seen = new Set([...repairs.keys(), ...purchases.keys(), ...insurance.keys(), ...finance.keys()]);

126
lib/running-month.test.ts Normal file
View file

@ -0,0 +1,126 @@
// The month rules on the running-cost pages.
//
// Run: node --import tsx --test lib/running-month.test.ts
//
// Two rules that keep three screens from disagreeing:
//
// 1. A year-to-date figure counts the months that have HAPPENED. A premium or an
// instalment for a month that has not happened yet is a rate on file, not money the
// client has spent, so it is not part of the year to date.
//
// 2. The Overview and the financing register read the SAME finance rule, so the
// fleet's finance for a month is the same rand on both.
//
// A third one is checked here because it is the whole point of the exercise: the
// insurance page and the Overview must quote the SAME year to date for insurance.
import { test } from "node:test";
import assert from "node:assert/strict";
import { elapsedMonths } from "./income-month";
import { buildInsuranceRows, buildRunningRows } from "./rows";
import { financeByAsset, fleetFinanceByMonth, machineRunningCosts } from "./running-costs";
const asset = (id: string, code = "TLB01") =>
({
id,
code,
name: code,
category: "Plant",
type: "",
gl_account: "",
supplier: "",
monthly_excl: 0,
monthly_incl: 0,
purchase_value: 0,
is_provisional: false,
is_active: true,
}) as any;
const rate = (assetId: string, from: number, to: number, amount: number) =>
({
id: `r-${assetId}-${from}`,
asset: assetId,
year: 2026,
month_from: from,
month_to: to,
rate: amount,
}) as any;
const term = (assetId: string, installment: number, settled = false) =>
({
id: `t-${assetId}`,
asset: assetId,
installment,
is_settled: settled,
start_year: 2026,
start_month: 1,
end_year: 2026,
end_month: 12,
}) as any;
test("elapsed months count the year as it stands", () => {
assert.equal(elapsedMonths(2026, new Date("2026-11-05T09:00:00")), 11);
assert.equal(elapsedMonths(2026, new Date("2026-01-15T09:00:00")), 1);
// A year that is over is a whole year.
assert.equal(elapsedMonths(2026, new Date("2027-03-01T09:00:00")), 12);
});
test("the insurance YTD stops at the month that has happened", () => {
const rates = [rate("a1", 1, 0, 1300)];
const full = buildInsuranceRows([asset("a1")], rates);
const toNovember = buildInsuranceRows([asset("a1")], rates, 11);
assert.equal(full[0].ytd, 1300 * 12, "a whole year, when the whole year is the window");
assert.equal(toNovember[0].ytd, 1300 * 11, "Jan-Nov only");
assert.equal(toNovember[0].chargedMonths, 11, "the months the YTD actually sums");
assert.equal(toNovember[0].coveredMonths, 12, "the year's coverage is still reported in full");
assert.equal(toNovember[0].months[10], 1300, "November still carries its premium");
});
test("a month with no premium on record is unknown, not free", () => {
// Insured from June only: January-May are unknown, and the YTD says so.
const rows = buildInsuranceRows([asset("a1")], [rate("a1", 6, 0, 1300)], 11);
assert.equal(rows[0].months[0], null, "January has no premium on record");
assert.equal(rows[0].ytd, 1300 * 6, "June to November, and not one rand less");
assert.equal(rows[0].chargedMonths, 6);
});
test("a settled agreement costs nothing; a live one costs every month", () => {
const months = fleetFinanceByMonth(
[term("a1", 1000), term("a2", 500, true)],
11,
);
assert.equal(months[0], 1000, "January: the live agreement only");
assert.equal(months[10], 1000, "November: still the live agreement");
assert.equal(months[11], 0, "a month past the horizon is not charged");
assert.equal(
months.slice(0, 11).reduce((s, v) => s + v, 0),
11 * 1000,
"the year's instalments",
);
});
test("the register and the Overview read the same finance rule", () => {
const terms = [term("a1", 1000)];
const perAsset = financeByAsset(terms, 11);
assert.equal(perAsset.get("a1")?.[0], 1000);
assert.equal(perAsset.get("a1")?.[10], 1000);
// The fleet total is the sum of the per-asset months — never a second rule.
assert.equal(fleetFinanceByMonth(terms, 11)[10], 1000);
});
test("the insurance page and the Overview quote the same year to date", () => {
const assets = [asset("a1"), asset("a2", "TLB02")];
const rates = [rate("a1", 1, 0, 1300), rate("a2", 1, 6, 900), rate("a2", 7, 0, 1000)];
const insuranceRows = buildInsuranceRows(assets, rates, 11);
const { byAsset } = machineRunningCosts(
{ assets, repairs: [], purchases: [], purchaseLines: [], insurance: rates, finance: [] },
11,
);
const running = buildRunningRows(assets, byAsset);
const insuranceYtd = insuranceRows.reduce((s, r) => s + r.ytd, 0);
const overviewYtd = running.reduce((s, r) => s + r.byKindYtd.insurance, 0);
assert.equal(insuranceYtd, overviewYtd, "the two screens must not be a month apart");
assert.equal(insuranceYtd, 1300 * 11 + 900 * 6 + 1000 * 5);
});