From b0c70e9210184219b286823493df23bc059825e6 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 9 Oct 2026 12:35:52 +0200 Subject: [PATCH] Stop the Running Costs grid at the current month MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Insurance and finance genuinely recur, so left to fill all twelve months they made November and December look known — ~R470k a month of phantom figures for months that have not started, which is the "it will confuse the people" case exactly. Both halves of the fix are needed: - machineRunningCosts() caps every source at the horizon, so no phantom figure is even computed. YTD is therefore genuinely year-to-date and moves as the year goes on. - RunningView renders only that many columns. Future months are NOT rendered empty: an empty column still reads as "we have a figure of zero", which is the same confusion in a quieter form. October is shown and is expected to grow as repairs and purchases land against a vehicle. A PO dated beyond the horizon is skipped silently, which is not the same as an undated PO — only a genuinely unreadable date is reported. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 22 ++++++++++++++++++++++ app/(dashboard)/running/page.tsx | 13 ++++++++++++- components/RunningView.tsx | 25 +++++++++++++++++-------- lib/running-costs.ts | 24 +++++++++++++++++------- 4 files changed, 68 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ff8169e..a9b0b75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -529,6 +529,28 @@ the money lives in one place and the Overview adds it up. - A purchase with **no readable PO date** is excluded and named in a banner, never guessed into January. +### Never show a month ahead + +**The grid stops at the month we are IN (client ruling, 2026-10-09).** Insurance and +finance genuinely recur, so left alone they would quietly fill November and December +with ~R470k a month and make the sheet look like the year is known when those months +have not started — "it will confuse the people". + +Two things enforce it, and both are needed: + +1. `machineRunningCosts(..., lastMonth)` caps every source at the horizon, so no + phantom figure is even computed. The **YTD is therefore year-to-date, not + twelve months** — it moves as the year goes on. +2. `RunningView` takes `visibleMonths` and renders only that many columns. The future + months are NOT rendered empty — an empty column still reads as "we have a figure of + zero", which is the same confusion in a quieter form. + +The current month IS shown and is expected to **grow** as repairs and purchases are +entered against a vehicle; that is the point of showing it live. + +⚠️ A PO dated beyond the horizon is skipped silently, which is NOT the same as an +undated PO. Only a genuinely unreadable date is reported as undated. + ### The incl / excl split `lib/purchase-booking.ts` holds `RUNNING_VAT_PCT = 15`. Purchase lines state their own diff --git a/app/(dashboard)/running/page.tsx b/app/(dashboard)/running/page.tsx index be9b830..275f341 100644 --- a/app/(dashboard)/running/page.tsx +++ b/app/(dashboard)/running/page.tsx @@ -5,6 +5,7 @@ import { ZAR } from "@/lib/utils"; 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 } from "@/lib/running-costs"; import type { FinancingTerm, InsuranceRate, MonthlyRunningCost, Purchase, PurchaseLine, @@ -40,9 +41,13 @@ export default async function RunningPage() { pb.collection("financing_terms").getFullList(), ]); + // 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; + const { byAsset, undatedPurchases } = machineRunningCosts({ assets, repairs, purchases, purchaseLines: lines, insurance, finance, - }); + }, lastMonth); const rows = buildRunningRows(assets, byAsset); const monthTotals = Array(12).fill(0); @@ -71,6 +76,11 @@ export default async function RunningPage() {

Everything a vehicle costs to run · repairs, purchases, insurance, finance · 2026

+

+ 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. +

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}%. @@ -116,6 +126,7 @@ export default async function RunningPage() { monthTotals={monthTotals} monthTotalsExcl={monthTotalsExcl} byKind={monthByKind} + visibleMonths={lastMonth} /> ); diff --git a/components/RunningView.tsx b/components/RunningView.tsx index bb696d3..d7b67f4 100644 --- a/components/RunningView.tsx +++ b/components/RunningView.tsx @@ -24,6 +24,7 @@ export function RunningView({ monthTotals, monthTotalsExcl, byKind, + visibleMonths, }: { rows: RunningRow[]; palette: { income: string; profit: string; loss: string }; @@ -33,6 +34,12 @@ export function RunningView({ monthTotalsExcl?: number[]; /** Per-month split behind the total, offered on hover rather than as columns. */ byKind?: { repairs: number[]; purchases: number[]; insurance: number[]; finance: number[] }; + /** + * How many months of the year to RENDER. A month ahead cannot be calculated, only + * guessed at, so the columns for it are not shown at all rather than shown empty + * (client ruling, 2026-10-09). + */ + visibleMonths?: number; }) { const [view, setView] = useState<"cards" | "grid">("cards"); const [sort, setSort] = useState<"ytd" | "code">("ytd"); @@ -113,18 +120,19 @@ export function RunningView({ {view === "cards" ? ( - + ) : ( - s + v, 0)} /> + s + v, 0)} /> )} ); } function CardsView({ - rows, expanded, setExpanded, palette, + rows, expanded, setExpanded, palette, visibleMonths, }: { rows: RunningRow[]; + visibleMonths?: number; expanded: string | null; setExpanded: (id: string | null) => void; palette: { income: string; profit: string; loss: string }; @@ -177,7 +185,7 @@ function CardsView({

{/* Mobile: 3 cols (4 rows × 3 cols = 12 months). md+: 6 cols. lg+: 12 cols. */}
- {r.months.map((v, i) => ( + {r.months.slice(0, visibleMonths ?? 12).map((v, i) => (
{MONTH_NAMES[i]}
0 ? "bg-[#EDAE49]/5 text-[#EDAE49] dark:bg-[#EDAE49]/15" : "text-gray-300 dark:text-text-soft"}`}> @@ -200,12 +208,13 @@ function CardsView({ } function GridView({ - rows, monthTotals, monthTotalsExcl, byKind, grandTotal, grandExcl, + rows, monthTotals, monthTotalsExcl, byKind, visibleMonths, grandTotal, grandExcl, }: { rows: RunningRow[]; monthTotals: number[]; monthTotalsExcl?: number[]; byKind?: { repairs: number[]; purchases: number[]; insurance: number[]; finance: number[] }; + visibleMonths?: number; grandTotal: number; grandExcl: number; }) { @@ -234,7 +243,7 @@ function GridView({ Code Machine - {MONTH_NAMES.map((m) => ( + {MONTH_NAMES.slice(0, visibleMonths ?? 12).map((m) => ( {m} ))} YTD @@ -254,7 +263,7 @@ function GridView({ {r.name}
- {r.months.map((v, i) => ( + {r.months.slice(0, visibleMonths ?? 12).map((v, i) => ( 0 ? "text-gray-700 dark:text-gray-300" : "text-gray-300 dark:text-text-soft"}`}> {v > 0 ? ZAR2(v) : "—"} @@ -273,7 +282,7 @@ function GridView({ incl / excl - {monthTotals.map((v, i) => ( + {monthTotals.slice(0, visibleMonths ?? 12).map((v, i) => ( ; undatedPurchases: string[]; financeFrom: string; } { const year = input.year ?? 2026; + // Never project past the month we are IN. Insurance and finance recur, so left alone + // they would silently fill November and December with ~R470k a month and make the + // sheet look like the year is known when those months have not started (client + // ruling, 2026-10-09). A month ahead cannot be calculated, only guessed at. + const horizon = Math.min(12, Math.max(1, lastMonth ?? 12)); const repairs = new Map(); const purchases = new Map(); @@ -85,7 +90,9 @@ export function machineRunningCosts(input: RunningCostInputs): { const undatedPurchases: string[] = []; // 1. Repairs & parts — the workbook bootstrap, still the only repair history there is. - for (const r of input.repairs) add(repairs, r.asset, r.month, r.amount); + for (const r of input.repairs) { + if (r.month <= horizon) add(repairs, r.asset, r.month, r.amount); + } // 2. Purchase orders — only lines allocated to a machine. An unallocated line is // real money not yet on a plant item; it stays on /purchases rather than being @@ -97,10 +104,13 @@ export function machineRunningCosts(input: RunningCostInputs): { if (!po) continue; const month = poMonth(po, year); if (month === null) { + // Genuinely undated — reported, never guessed into January. const label = `${po.po_no} #${line.line_no}`; if (!undatedPurchases.includes(label)) undatedPurchases.push(label); continue; } + // Beyond the horizon: simply not shown yet, which is NOT the same as undated. + if (month > horizon) continue; add(purchases, line.asset, month, line.total_incl ?? line.amount_excl ?? 0); } @@ -108,16 +118,16 @@ export function machineRunningCosts(input: RunningCostInputs): { // charged at the price that applied that month rather than smeared across the year. for (const rate of input.insurance) { if (rate.year !== year) continue; - const end = rate.month_to || 12; + const end = Math.min(rate.month_to || 12, horizon); for (let m = rate.month_from; m <= end; m++) add(insurance, rate.asset, m, rate.rate); } - // 4. Finance — a MONTHLY instalment, so it recurs for every month of the year, and - // only while the agreement is live. Settled agreements have stopped, so they - // contribute nothing: a paid-off machine is not a running cost. + // 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 <= 12; m++) add(finance, t.asset, m, t.installment || 0); + for (let m = 1; m <= horizon; m++) add(finance, t.asset, m, t.installment || 0); } const byAsset = new Map();