mega-plant/lib/rows.ts

392 lines
15 KiB
TypeScript
Raw Normal View History

Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics Splits the Running Costs group into six purpose-built pages, each with its own collection and its own source data, plus a coordinate parser for purchase orders. Insurance (R503 330/yr, 20 vehicles) A premium can move every month or three, so it is stored as a RATE PERIOD in insurance_rates and each month's cost is resolved against the rate in force that month. cost_lines insurance rows are derived from those periods. An absent rate is null, never 0 — nobody has entered the date, which is not the same as being insured for free. Financing (20 agreements, R428 585/mo) Instalment from the sheet; start date, paid-off date and the settled toggle are human-entered and preserved across re-seeds. Months-left and outstanding are null until a date is entered, never R0 on a live debt. The two paid-off agreements (WB02, DT01) show as settled and are excluded from the run-rate. Purchases + Oil Stock Uploads PO PDFs the way /income/invoices does. The parser reads columns by x-position, because plain text extraction reorders them, and refuses any PO whose lines do not sum to the sub-total. A cost code either names a machine ("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never guessed. Oil POs move litres into oil_stock_movements instead of onto a machine; stock is the sum of movements so the first usage is a subtraction from history. Operators (19) and Mechanics (2) Both are people with an employee number, separately reconcilable by the seed so someone added in the app survives. Mechanics get a badge QR; a mechanic with no employee number cannot be printed, because the code is what the QR encodes. Cross-cutting - The seed no longer deletes operators, mechanics or oil items: people are added in the app, and those rows exist in no file. - Re-pointing allocations that PocketBase clears because `assets` is rebuilt with new ids each seed (operators, purchase lines). - One QR encoder for screen and PDF, so a printed code cannot drift from the one on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them. - Every figure is null-safe: unknown is never rendered as zero. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
import type { Asset, FinancingTerm, InsuranceRate, InvoiceLine, MonthlyIncome, MonthlyRunningCost, Operator } from "./data";
import { computeProfit, type ProfitOptions } from "./profit";
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics Splits the Running Costs group into six purpose-built pages, each with its own collection and its own source data, plus a coordinate parser for purchase orders. Insurance (R503 330/yr, 20 vehicles) A premium can move every month or three, so it is stored as a RATE PERIOD in insurance_rates and each month's cost is resolved against the rate in force that month. cost_lines insurance rows are derived from those periods. An absent rate is null, never 0 — nobody has entered the date, which is not the same as being insured for free. Financing (20 agreements, R428 585/mo) Instalment from the sheet; start date, paid-off date and the settled toggle are human-entered and preserved across re-seeds. Months-left and outstanding are null until a date is entered, never R0 on a live debt. The two paid-off agreements (WB02, DT01) show as settled and are excluded from the run-rate. Purchases + Oil Stock Uploads PO PDFs the way /income/invoices does. The parser reads columns by x-position, because plain text extraction reorders them, and refuses any PO whose lines do not sum to the sub-total. A cost code either names a machine ("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never guessed. Oil POs move litres into oil_stock_movements instead of onto a machine; stock is the sum of movements so the first usage is a subtraction from history. Operators (19) and Mechanics (2) Both are people with an employee number, separately reconcilable by the seed so someone added in the app survives. Mechanics get a badge QR; a mechanic with no employee number cannot be printed, because the code is what the QR encodes. Cross-cutting - The seed no longer deletes operators, mechanics or oil items: people are added in the app, and those rows exist in no file. - Re-pointing allocations that PocketBase clears because `assets` is rebuilt with new ids each seed (operators, purchase lines). - One QR encoder for screen and PDF, so a printed code cannot drift from the one on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them. - Every figure is null-safe: unknown is never rendered as zero. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
import { currentRate, monthlyCost, validatePeriods, type RatePeriod } from "./insurance";
import { financeCalc, toInput, type FinanceCalc } from "./financing";
Running cost = everything linked to a vehicle: repairs, purchases, insurance, finance A machine with no repairs and no purchase order still carries an insurance premium and, while it is financed, a monthly instalment. So a blank row was never "this vehicle costs nothing" — it was "nothing recorded", which is a gap rather than a figure. The Overview now totals all four kinds per machine per month. Everything is read from POCKETBASE, the registers the client maintains in the app. The original spreadsheet only bootstrapped the page (monthly_running_costs, Jan-Aug); it is not a source here. September having no workbook data is expected and correct — the figures there now come from the app. Two finance sources existed and OVERLAPPED. The cost_lines instalment rows are the old workbook's subset (5 machines, R115 576,14/mo); financing_terms is the client's current sheet (18 agreements, R428 585,04/mo). Using both double-counted MG02, RV04, TLB06 and TLB07, so financing_terms wins and the stale rows are ignored. TB01 has no financing_terms row, so its old instalment no longer counts — consistent with it being absent from the current sheet. Insurance has one source and no such problem. Insurance is resolved from the RATE PERIODS rather than the derived cost_lines, so a premium that moves mid-year is charged at the price that applied that month. Also: settled agreements contribute nothing (a paid-off machine is not a running cost), purchase lines are filed under the PO date, and the bottom row keeps showing incl above excl. The page carries one line saying this is a re-cut of costs already inside fixed expenses — adding it to them would count the same premium twice. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-09 10:28:29 +00:00
import { exclFromIncl } from "./purchase-booking";
import { COST_KINDS, type MachineCosts } from "./running-costs";
export type IncomeRow = {
id: string;
code: string;
name: string;
months: (number | null)[]; // Part A.2: null = no data (forecast), 0 = earned zero
fixedMonths: (number | null)[];
runningMonths: (number | null)[];
profit: number[];
income: number;
fixed: number;
running: number;
ytdProfit: number;
lastActualMonth: number; // Part A.3: clip x-axis
// Machines the client has stood down. They still hold their history, so they are kept
// in the data and filtered in the view rather than dropped here — /income defaults to
// active only, but the client can switch to All or Inactive.
isActive: boolean;
Invoices: line-item extraction, booking to plant items, VAT-inclusive income Adds an Invoices sub-menu under /income with a summary table (one row per invoice, click to expand the full detail) and the data model behind it. Data: - invoices + invoice_lines PocketBase collections, created idempotently by scripts/setup-invoice-collections.mjs - line items transcribed from PHI 166/167/171 with per-invoice arithmetic validation in the seed; a bad invoice is refused without blocking the rest - invoice-*.json in data/source are auto-imported, so adding an invoice is one file Booking: - manual book-in per line (Booked to), grouped by category - POST /api/invoice-lines/:id/book moves the income row rather than adding a second one, so a booking can be changed freely - scripts/book-invoice.mjs is idempotent: rows are stamped with monthly_income.invoice_line and replaced on re-run - booked amount follows lib/invoice-booking-policy.json (bookInclusiveVat), read by both the app and the CLI so they cannot disagree Money: - the R24,205.13 column on these invoices is PLUS VAT, not escalation. The seed now rejects VAT that disagrees with the stated rate. - booked amounts are VAT-inclusive per the client ruling of 2026-10-01; the VAT stays visible rather than hidden. Uploads: - drag-and-drop PDF attached to invoices.source_pdf - GET /api/invoices/:id/document proxies it — the collection is rule-locked, so a direct PocketBase /api/files link 404s in a browser Fixes a month-aggregation bug this introduced: buildIncomeRows assigned into months[i], so a machine with two income rows in one month (one per invoice, which is normal multi-site use) showed only the last. SS01 September read R20,700 instead of R35,305 while its own YTD included both. Months now accumulate, per-month profit is computed once after accumulation, and lib/rows.test.ts pins it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-01 13:59:30 +00:00
// Portion of `income` that came from invoices, and how much of that is VAT.
// Booked amounts are VAT-inclusive (see lib/invoice-booking-policy.json), so without
// this the per-machine figure is not explainable.
invoiceIncome: number;
invoiceVat: number;
// Every invoice that contributed to this machine, so the card can show the combined
// total and break it down by invoice and site without another query.
};
// Per Part A.2: months beyond lastActualMonth are null in `months`/`fixedMonths`/`runningMonths`.
// `running` is derived from monthly_running_costs; null means we have no running data for that month.
export function buildIncomeRows(
assets: Asset[],
income: MonthlyIncome[],
running?: MonthlyRunningCost[],
Invoices: line-item extraction, booking to plant items, VAT-inclusive income Adds an Invoices sub-menu under /income with a summary table (one row per invoice, click to expand the full detail) and the data model behind it. Data: - invoices + invoice_lines PocketBase collections, created idempotently by scripts/setup-invoice-collections.mjs - line items transcribed from PHI 166/167/171 with per-invoice arithmetic validation in the seed; a bad invoice is refused without blocking the rest - invoice-*.json in data/source are auto-imported, so adding an invoice is one file Booking: - manual book-in per line (Booked to), grouped by category - POST /api/invoice-lines/:id/book moves the income row rather than adding a second one, so a booking can be changed freely - scripts/book-invoice.mjs is idempotent: rows are stamped with monthly_income.invoice_line and replaced on re-run - booked amount follows lib/invoice-booking-policy.json (bookInclusiveVat), read by both the app and the CLI so they cannot disagree Money: - the R24,205.13 column on these invoices is PLUS VAT, not escalation. The seed now rejects VAT that disagrees with the stated rate. - booked amounts are VAT-inclusive per the client ruling of 2026-10-01; the VAT stays visible rather than hidden. Uploads: - drag-and-drop PDF attached to invoices.source_pdf - GET /api/invoices/:id/document proxies it — the collection is rule-locked, so a direct PocketBase /api/files link 404s in a browser Fixes a month-aggregation bug this introduced: buildIncomeRows assigned into months[i], so a machine with two income rows in one month (one per invoice, which is normal multi-site use) showed only the last. SS01 September read R20,700 instead of R35,305 while its own YTD included both. Months now accumulate, per-month profit is computed once after accumulation, and lib/rows.test.ts pins it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-01 13:59:30 +00:00
profitOpts: ProfitOptions = {},
// Invoice line items, used only to attribute the VAT already inside each booked
// amount back to the machine that earned it.
invoiceLines?: InvoiceLine[],
): IncomeRow[] {
// Determine the last actual month globally (Part A.2/A.3)
const lastActualMonth = income.reduce((m, r) => r.income > 0 && r.month > m ? r.month : m, 0);
const byAsset = new Map<string, IncomeRow>();
for (const a of assets) {
if (!a.code) continue;
byAsset.set(a.id, {
id: a.id, code: a.code, name: a.name,
isActive: a.is_active !== false,
months: Array(12).fill(null),
fixedMonths: Array(12).fill(null),
runningMonths: Array(12).fill(null),
profit: Array(12).fill(0),
income: 0, fixed: 0, running: 0, ytdProfit: 0,
lastActualMonth,
Invoices: line-item extraction, booking to plant items, VAT-inclusive income Adds an Invoices sub-menu under /income with a summary table (one row per invoice, click to expand the full detail) and the data model behind it. Data: - invoices + invoice_lines PocketBase collections, created idempotently by scripts/setup-invoice-collections.mjs - line items transcribed from PHI 166/167/171 with per-invoice arithmetic validation in the seed; a bad invoice is refused without blocking the rest - invoice-*.json in data/source are auto-imported, so adding an invoice is one file Booking: - manual book-in per line (Booked to), grouped by category - POST /api/invoice-lines/:id/book moves the income row rather than adding a second one, so a booking can be changed freely - scripts/book-invoice.mjs is idempotent: rows are stamped with monthly_income.invoice_line and replaced on re-run - booked amount follows lib/invoice-booking-policy.json (bookInclusiveVat), read by both the app and the CLI so they cannot disagree Money: - the R24,205.13 column on these invoices is PLUS VAT, not escalation. The seed now rejects VAT that disagrees with the stated rate. - booked amounts are VAT-inclusive per the client ruling of 2026-10-01; the VAT stays visible rather than hidden. Uploads: - drag-and-drop PDF attached to invoices.source_pdf - GET /api/invoices/:id/document proxies it — the collection is rule-locked, so a direct PocketBase /api/files link 404s in a browser Fixes a month-aggregation bug this introduced: buildIncomeRows assigned into months[i], so a machine with two income rows in one month (one per invoice, which is normal multi-site use) showed only the last. SS01 September read R20,700 instead of R35,305 while its own YTD included both. Months now accumulate, per-month profit is computed once after accumulation, and lib/rows.test.ts pins it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-01 13:59:30 +00:00
invoiceIncome: 0, invoiceVat: 0,
});
}
for (const r of income) {
const row = byAsset.get(r.asset);
if (!row) continue;
const i = r.month - 1;
// Part A.2: only null out months BEYOND the last actual month. Months up to and including
// lastActualMonth get their real value (which may be 0 — a real plotted zero).
Invoices: line-item extraction, booking to plant items, VAT-inclusive income Adds an Invoices sub-menu under /income with a summary table (one row per invoice, click to expand the full detail) and the data model behind it. Data: - invoices + invoice_lines PocketBase collections, created idempotently by scripts/setup-invoice-collections.mjs - line items transcribed from PHI 166/167/171 with per-invoice arithmetic validation in the seed; a bad invoice is refused without blocking the rest - invoice-*.json in data/source are auto-imported, so adding an invoice is one file Booking: - manual book-in per line (Booked to), grouped by category - POST /api/invoice-lines/:id/book moves the income row rather than adding a second one, so a booking can be changed freely - scripts/book-invoice.mjs is idempotent: rows are stamped with monthly_income.invoice_line and replaced on re-run - booked amount follows lib/invoice-booking-policy.json (bookInclusiveVat), read by both the app and the CLI so they cannot disagree Money: - the R24,205.13 column on these invoices is PLUS VAT, not escalation. The seed now rejects VAT that disagrees with the stated rate. - booked amounts are VAT-inclusive per the client ruling of 2026-10-01; the VAT stays visible rather than hidden. Uploads: - drag-and-drop PDF attached to invoices.source_pdf - GET /api/invoices/:id/document proxies it — the collection is rule-locked, so a direct PocketBase /api/files link 404s in a browser Fixes a month-aggregation bug this introduced: buildIncomeRows assigned into months[i], so a machine with two income rows in one month (one per invoice, which is normal multi-site use) showed only the last. SS01 September read R20,700 instead of R35,305 while its own YTD included both. Months now accumulate, per-month profit is computed once after accumulation, and lib/rows.test.ts pins it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-01 13:59:30 +00:00
//
// ACCUMULATE, do not assign: a machine can have several income rows in one month —
// a workbook row plus one per invoice line booked to it. Assigning made the cell show
// only the last row, so SS01 (two September invoices) displayed R20,700 while its own
// YTD total included both (R35,305), and the September column total came out short.
if (r.month <= lastActualMonth) {
Invoices: line-item extraction, booking to plant items, VAT-inclusive income Adds an Invoices sub-menu under /income with a summary table (one row per invoice, click to expand the full detail) and the data model behind it. Data: - invoices + invoice_lines PocketBase collections, created idempotently by scripts/setup-invoice-collections.mjs - line items transcribed from PHI 166/167/171 with per-invoice arithmetic validation in the seed; a bad invoice is refused without blocking the rest - invoice-*.json in data/source are auto-imported, so adding an invoice is one file Booking: - manual book-in per line (Booked to), grouped by category - POST /api/invoice-lines/:id/book moves the income row rather than adding a second one, so a booking can be changed freely - scripts/book-invoice.mjs is idempotent: rows are stamped with monthly_income.invoice_line and replaced on re-run - booked amount follows lib/invoice-booking-policy.json (bookInclusiveVat), read by both the app and the CLI so they cannot disagree Money: - the R24,205.13 column on these invoices is PLUS VAT, not escalation. The seed now rejects VAT that disagrees with the stated rate. - booked amounts are VAT-inclusive per the client ruling of 2026-10-01; the VAT stays visible rather than hidden. Uploads: - drag-and-drop PDF attached to invoices.source_pdf - GET /api/invoices/:id/document proxies it — the collection is rule-locked, so a direct PocketBase /api/files link 404s in a browser Fixes a month-aggregation bug this introduced: buildIncomeRows assigned into months[i], so a machine with two income rows in one month (one per invoice, which is normal multi-site use) showed only the last. SS01 September read R20,700 instead of R35,305 while its own YTD included both. Months now accumulate, per-month profit is computed once after accumulation, and lib/rows.test.ts pins it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-01 13:59:30 +00:00
row.months[i] = (row.months[i] ?? 0) + r.income;
row.fixedMonths[i] = (row.fixedMonths[i] ?? 0) + r.fixed_expenses;
}
row.income += r.income;
row.fixed += r.fixed_expenses;
}
// Per Part 1.5: running cost is derived from monthly_running_costs, not stored on income.
for (const c of running ?? []) {
const row = byAsset.get(c.asset);
if (!row) continue;
row.running += c.amount;
if (c.month <= lastActualMonth) {
row.runningMonths[c.month - 1] = c.amount;
}
}
Invoices: line-item extraction, booking to plant items, VAT-inclusive income Adds an Invoices sub-menu under /income with a summary table (one row per invoice, click to expand the full detail) and the data model behind it. Data: - invoices + invoice_lines PocketBase collections, created idempotently by scripts/setup-invoice-collections.mjs - line items transcribed from PHI 166/167/171 with per-invoice arithmetic validation in the seed; a bad invoice is refused without blocking the rest - invoice-*.json in data/source are auto-imported, so adding an invoice is one file Booking: - manual book-in per line (Booked to), grouped by category - POST /api/invoice-lines/:id/book moves the income row rather than adding a second one, so a booking can be changed freely - scripts/book-invoice.mjs is idempotent: rows are stamped with monthly_income.invoice_line and replaced on re-run - booked amount follows lib/invoice-booking-policy.json (bookInclusiveVat), read by both the app and the CLI so they cannot disagree Money: - the R24,205.13 column on these invoices is PLUS VAT, not escalation. The seed now rejects VAT that disagrees with the stated rate. - booked amounts are VAT-inclusive per the client ruling of 2026-10-01; the VAT stays visible rather than hidden. Uploads: - drag-and-drop PDF attached to invoices.source_pdf - GET /api/invoices/:id/document proxies it — the collection is rule-locked, so a direct PocketBase /api/files link 404s in a browser Fixes a month-aggregation bug this introduced: buildIncomeRows assigned into months[i], so a machine with two income rows in one month (one per invoice, which is normal multi-site use) showed only the last. SS01 September read R20,700 instead of R35,305 while its own YTD included both. Months now accumulate, per-month profit is computed once after accumulation, and lib/rows.test.ts pins it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-01 13:59:30 +00:00
// Per-month profit is computed ONCE, after income and running have both been
// accumulated, so it reflects the whole month rather than whichever row happened to be
// read last. Previously this lived inside the two loops above and inherited the same
// last-write-wins bug.
for (const row of byAsset.values()) {
for (let i = 0; i < 12; i++) {
if (i + 1 > lastActualMonth) continue;
row.profit[i] = computeProfit(
{
income: row.months[i] ?? 0,
fixed: row.fixedMonths[i] ?? 0,
running: row.runningMonths[i] ?? 0,
},
profitOpts,
);
}
}
// Attribute invoice-derived income and its embedded VAT per machine.
for (const l of invoiceLines ?? []) {
const row = byAsset.get(l.booked_asset);
if (!row) continue;
row.invoiceIncome += l.total_incl || 0;
row.invoiceVat += l.vat || 0;
}
for (const row of byAsset.values()) {
row.ytdProfit = computeProfit({ income: row.income, fixed: row.fixed, running: row.running }, profitOpts);
}
return Array.from(byAsset.values());
}
export type RunningRow = {
id: string;
code: string;
name: string;
/** Per month, VAT-INCLUSIVE: the workbook figure plus any purchased parts. */
months: number[];
ytd: number;
/** Per month, net of VAT at the confirmed rate. Shown under `months`, never instead of it. */
monthsExcl: number[];
ytdExcl: number;
/** The purchased portion of each month, so the Overview can say what came from where. */
purchasesIncl: number[];
Running cost = everything linked to a vehicle: repairs, purchases, insurance, finance A machine with no repairs and no purchase order still carries an insurance premium and, while it is financed, a monthly instalment. So a blank row was never "this vehicle costs nothing" — it was "nothing recorded", which is a gap rather than a figure. The Overview now totals all four kinds per machine per month. Everything is read from POCKETBASE, the registers the client maintains in the app. The original spreadsheet only bootstrapped the page (monthly_running_costs, Jan-Aug); it is not a source here. September having no workbook data is expected and correct — the figures there now come from the app. Two finance sources existed and OVERLAPPED. The cost_lines instalment rows are the old workbook's subset (5 machines, R115 576,14/mo); financing_terms is the client's current sheet (18 agreements, R428 585,04/mo). Using both double-counted MG02, RV04, TLB06 and TLB07, so financing_terms wins and the stale rows are ignored. TB01 has no financing_terms row, so its old instalment no longer counts — consistent with it being absent from the current sheet. Insurance has one source and no such problem. Insurance is resolved from the RATE PERIODS rather than the derived cost_lines, so a premium that moves mid-year is charged at the price that applied that month. Also: settled agreements contribute nothing (a paid-off machine is not a running cost), purchase lines are filed under the PO date, and the bottom row keeps showing incl above excl. The page carries one line saying this is a re-cut of costs already inside fixed expenses — adding it to them would count the same premium twice. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-09 10:28:29 +00:00
/** False means NO cost of any kind is recorded for that month — which is different
* from a month that genuinely costs R0. */
knownMonths: boolean[];
byKind: { repairs: number[]; purchases: number[]; insurance: number[]; finance: number[] };
feat(running): show what each vehicle's running cost is made of The four kinds under running cost - repairs & parts, purchases, insurance and finance - ARE the whole of a vehicle's running cost. That was true of the arithmetic but not of the screen: a card showed one number with no visible parts, and the only breakdown was a hover tooltip on the grid's total row. - Each card now shows its four parts, so what the vehicle's spend is made of is visible without expanding it. A vehicle with no spend shows nothing: "nothing recorded" is already on the card. - The expanded panel shows the four parts against the vehicle's total. - The grid's footer shows the fleet's four parts above the fleet total, so a month column adds up on the screen instead of on trust. - The page header states the fleet's four parts and the total side by side. The parts ARE the spend and there is no fifth source, so the parts and the total can never disagree. Any change that touches a cost kind must keep that additivity true. Also: the labels on this page were text-[10px], under the 12px floor the mobile checklist sets. They are now 12px. Two rulings recorded in AGENTS.md: - FUEL IS NOT A PLANT RUNNING COST. The sites are invoiced for the fuel they use. The only fuel that is a plant cost is the fuel a plant vehicle takes from the tank for plant work, so a fuel log is never a running-cost source however many litres it records. - Only what is under running cost as it stands. The workbook's per-vehicle fixed cost also carries depreciation/ownership and an operator allocation; that is capital recovery and overhead, not running cost, so it stays out - and the vehicles it covers (the excavator, the site and office containers, the generators, the trailers) correctly show no running cost on the Overview.
2026-11-04 23:09:45 +00:00
/** The same four kinds summed for the year, so a card can show WHAT its spend is made
* of. The parts are the four kinds and nothing else, so they always add up to `ytd`. */
byKindYtd: { repairs: number; purchases: number; insurance: number; finance: number };
};
export function buildRunningRows(
assets: Asset[],
Running cost = everything linked to a vehicle: repairs, purchases, insurance, finance A machine with no repairs and no purchase order still carries an insurance premium and, while it is financed, a monthly instalment. So a blank row was never "this vehicle costs nothing" — it was "nothing recorded", which is a gap rather than a figure. The Overview now totals all four kinds per machine per month. Everything is read from POCKETBASE, the registers the client maintains in the app. The original spreadsheet only bootstrapped the page (monthly_running_costs, Jan-Aug); it is not a source here. September having no workbook data is expected and correct — the figures there now come from the app. Two finance sources existed and OVERLAPPED. The cost_lines instalment rows are the old workbook's subset (5 machines, R115 576,14/mo); financing_terms is the client's current sheet (18 agreements, R428 585,04/mo). Using both double-counted MG02, RV04, TLB06 and TLB07, so financing_terms wins and the stale rows are ignored. TB01 has no financing_terms row, so its old instalment no longer counts — consistent with it being absent from the current sheet. Insurance has one source and no such problem. Insurance is resolved from the RATE PERIODS rather than the derived cost_lines, so a premium that moves mid-year is charged at the price that applied that month. Also: settled agreements contribute nothing (a paid-off machine is not a running cost), purchase lines are filed under the PO date, and the bottom row keeps showing incl above excl. The page carries one line saying this is a re-cut of costs already inside fixed expenses — adding it to them would count the same premium twice. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-09 10:28:29 +00:00
// All four cost kinds per machine per month — repairs, purchases, insurance and
// finance, assembled in lib/running-costs.ts. Everything a vehicle costs to run.
costs?: Map<string, MachineCosts>,
): RunningRow[] {
const byAsset = new Map<string, RunningRow>();
for (const a of assets) {
if (!a.code) continue;
byAsset.set(a.id, {
id: a.id, code: a.code, name: a.name,
months: Array(12).fill(0),
ytd: 0,
Running cost = everything linked to a vehicle: repairs, purchases, insurance, finance A machine with no repairs and no purchase order still carries an insurance premium and, while it is financed, a monthly instalment. So a blank row was never "this vehicle costs nothing" — it was "nothing recorded", which is a gap rather than a figure. The Overview now totals all four kinds per machine per month. Everything is read from POCKETBASE, the registers the client maintains in the app. The original spreadsheet only bootstrapped the page (monthly_running_costs, Jan-Aug); it is not a source here. September having no workbook data is expected and correct — the figures there now come from the app. Two finance sources existed and OVERLAPPED. The cost_lines instalment rows are the old workbook's subset (5 machines, R115 576,14/mo); financing_terms is the client's current sheet (18 agreements, R428 585,04/mo). Using both double-counted MG02, RV04, TLB06 and TLB07, so financing_terms wins and the stale rows are ignored. TB01 has no financing_terms row, so its old instalment no longer counts — consistent with it being absent from the current sheet. Insurance has one source and no such problem. Insurance is resolved from the RATE PERIODS rather than the derived cost_lines, so a premium that moves mid-year is charged at the price that applied that month. Also: settled agreements contribute nothing (a paid-off machine is not a running cost), purchase lines are filed under the PO date, and the bottom row keeps showing incl above excl. The page carries one line saying this is a re-cut of costs already inside fixed expenses — adding it to them would count the same premium twice. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-09 10:28:29 +00:00
// Net of VAT at the confirmed rate, kept alongside so the Overview can show
// incl above excl rather than making a reader back the VAT out.
monthsExcl: Array(12).fill(0),
ytdExcl: 0,
purchasesIncl: Array(12).fill(0),
Running cost = everything linked to a vehicle: repairs, purchases, insurance, finance A machine with no repairs and no purchase order still carries an insurance premium and, while it is financed, a monthly instalment. So a blank row was never "this vehicle costs nothing" — it was "nothing recorded", which is a gap rather than a figure. The Overview now totals all four kinds per machine per month. Everything is read from POCKETBASE, the registers the client maintains in the app. The original spreadsheet only bootstrapped the page (monthly_running_costs, Jan-Aug); it is not a source here. September having no workbook data is expected and correct — the figures there now come from the app. Two finance sources existed and OVERLAPPED. The cost_lines instalment rows are the old workbook's subset (5 machines, R115 576,14/mo); financing_terms is the client's current sheet (18 agreements, R428 585,04/mo). Using both double-counted MG02, RV04, TLB06 and TLB07, so financing_terms wins and the stale rows are ignored. TB01 has no financing_terms row, so its old instalment no longer counts — consistent with it being absent from the current sheet. Insurance has one source and no such problem. Insurance is resolved from the RATE PERIODS rather than the derived cost_lines, so a premium that moves mid-year is charged at the price that applied that month. Also: settled agreements contribute nothing (a paid-off machine is not a running cost), purchase lines are filed under the PO date, and the bottom row keeps showing incl above excl. The page carries one line saying this is a re-cut of costs already inside fixed expenses — adding it to them would count the same premium twice. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-09 10:28:29 +00:00
knownMonths: Array(12).fill(false),
byKind: { repairs: Array(12).fill(0), purchases: Array(12).fill(0), insurance: Array(12).fill(0), finance: Array(12).fill(0) },
feat(running): show what each vehicle's running cost is made of The four kinds under running cost - repairs & parts, purchases, insurance and finance - ARE the whole of a vehicle's running cost. That was true of the arithmetic but not of the screen: a card showed one number with no visible parts, and the only breakdown was a hover tooltip on the grid's total row. - Each card now shows its four parts, so what the vehicle's spend is made of is visible without expanding it. A vehicle with no spend shows nothing: "nothing recorded" is already on the card. - The expanded panel shows the four parts against the vehicle's total. - The grid's footer shows the fleet's four parts above the fleet total, so a month column adds up on the screen instead of on trust. - The page header states the fleet's four parts and the total side by side. The parts ARE the spend and there is no fifth source, so the parts and the total can never disagree. Any change that touches a cost kind must keep that additivity true. Also: the labels on this page were text-[10px], under the 12px floor the mobile checklist sets. They are now 12px. Two rulings recorded in AGENTS.md: - FUEL IS NOT A PLANT RUNNING COST. The sites are invoiced for the fuel they use. The only fuel that is a plant cost is the fuel a plant vehicle takes from the tank for plant work, so a fuel log is never a running-cost source however many litres it records. - Only what is under running cost as it stands. The workbook's per-vehicle fixed cost also carries depreciation/ownership and an operator allocation; that is capital recovery and overhead, not running cost, so it stays out - and the vehicles it covers (the excavator, the site and office containers, the generators, the trailers) correctly show no running cost on the Overview.
2026-11-04 23:09:45 +00:00
byKindYtd: { repairs: 0, purchases: 0, insurance: 0, finance: 0 },
});
}
Running cost = everything linked to a vehicle: repairs, purchases, insurance, finance A machine with no repairs and no purchase order still carries an insurance premium and, while it is financed, a monthly instalment. So a blank row was never "this vehicle costs nothing" — it was "nothing recorded", which is a gap rather than a figure. The Overview now totals all four kinds per machine per month. Everything is read from POCKETBASE, the registers the client maintains in the app. The original spreadsheet only bootstrapped the page (monthly_running_costs, Jan-Aug); it is not a source here. September having no workbook data is expected and correct — the figures there now come from the app. Two finance sources existed and OVERLAPPED. The cost_lines instalment rows are the old workbook's subset (5 machines, R115 576,14/mo); financing_terms is the client's current sheet (18 agreements, R428 585,04/mo). Using both double-counted MG02, RV04, TLB06 and TLB07, so financing_terms wins and the stale rows are ignored. TB01 has no financing_terms row, so its old instalment no longer counts — consistent with it being absent from the current sheet. Insurance has one source and no such problem. Insurance is resolved from the RATE PERIODS rather than the derived cost_lines, so a premium that moves mid-year is charged at the price that applied that month. Also: settled agreements contribute nothing (a paid-off machine is not a running cost), purchase lines are filed under the PO date, and the bottom row keeps showing incl above excl. The page carries one line saying this is a re-cut of costs already inside fixed expenses — adding it to them would count the same premium twice. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-09 10:28:29 +00:00
if (!costs) return Array.from(byAsset.values());
for (const [assetId, kinds] of costs) {
const row = byAsset.get(assetId);
if (!row) continue;
Running cost = everything linked to a vehicle: repairs, purchases, insurance, finance A machine with no repairs and no purchase order still carries an insurance premium and, while it is financed, a monthly instalment. So a blank row was never "this vehicle costs nothing" — it was "nothing recorded", which is a gap rather than a figure. The Overview now totals all four kinds per machine per month. Everything is read from POCKETBASE, the registers the client maintains in the app. The original spreadsheet only bootstrapped the page (monthly_running_costs, Jan-Aug); it is not a source here. September having no workbook data is expected and correct — the figures there now come from the app. Two finance sources existed and OVERLAPPED. The cost_lines instalment rows are the old workbook's subset (5 machines, R115 576,14/mo); financing_terms is the client's current sheet (18 agreements, R428 585,04/mo). Using both double-counted MG02, RV04, TLB06 and TLB07, so financing_terms wins and the stale rows are ignored. TB01 has no financing_terms row, so its old instalment no longer counts — consistent with it being absent from the current sheet. Insurance has one source and no such problem. Insurance is resolved from the RATE PERIODS rather than the derived cost_lines, so a premium that moves mid-year is charged at the price that applied that month. Also: settled agreements contribute nothing (a paid-off machine is not a running cost), purchase lines are filed under the PO date, and the bottom row keeps showing incl above excl. The page carries one line saying this is a re-cut of costs already inside fixed expenses — adding it to them would count the same premium twice. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-09 10:28:29 +00:00
for (let i = 0; i < 12; i++) {
let incl = 0;
for (const k of COST_KINDS) {
const v = kinds[k][i] ?? 0;
incl += v;
row.byKind[k][i] += v;
}
if (incl <= 0) continue;
row.months[i] += incl;
row.ytd += incl;
row.purchasesIncl[i] += kinds.purchases[i] ?? 0;
row.monthsExcl[i] += exclFromIncl(incl);
row.ytdExcl += exclFromIncl(incl);
row.knownMonths[i] = true;
}
}
feat(running): show what each vehicle's running cost is made of The four kinds under running cost - repairs & parts, purchases, insurance and finance - ARE the whole of a vehicle's running cost. That was true of the arithmetic but not of the screen: a card showed one number with no visible parts, and the only breakdown was a hover tooltip on the grid's total row. - Each card now shows its four parts, so what the vehicle's spend is made of is visible without expanding it. A vehicle with no spend shows nothing: "nothing recorded" is already on the card. - The expanded panel shows the four parts against the vehicle's total. - The grid's footer shows the fleet's four parts above the fleet total, so a month column adds up on the screen instead of on trust. - The page header states the fleet's four parts and the total side by side. The parts ARE the spend and there is no fifth source, so the parts and the total can never disagree. Any change that touches a cost kind must keep that additivity true. Also: the labels on this page were text-[10px], under the 12px floor the mobile checklist sets. They are now 12px. Two rulings recorded in AGENTS.md: - FUEL IS NOT A PLANT RUNNING COST. The sites are invoiced for the fuel they use. The only fuel that is a plant cost is the fuel a plant vehicle takes from the tank for plant work, so a fuel log is never a running-cost source however many litres it records. - Only what is under running cost as it stands. The workbook's per-vehicle fixed cost also carries depreciation/ownership and an operator allocation; that is capital recovery and overhead, not running cost, so it stays out - and the vehicles it covers (the excavator, the site and office containers, the generators, the trailers) correctly show no running cost on the Overview.
2026-11-04 23:09:45 +00:00
// The four kinds summed for the year. These parts ARE the vehicle's spend — there is
// no fifth source — so they always add up to `ytd`, which is what the card shows.
for (const row of byAsset.values()) {
for (const k of COST_KINDS) row.byKindYtd[k] = row.byKind[k].reduce((s, v) => s + v, 0);
}
return Array.from(byAsset.values());
}
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics Splits the Running Costs group into six purpose-built pages, each with its own collection and its own source data, plus a coordinate parser for purchase orders. Insurance (R503 330/yr, 20 vehicles) A premium can move every month or three, so it is stored as a RATE PERIOD in insurance_rates and each month's cost is resolved against the rate in force that month. cost_lines insurance rows are derived from those periods. An absent rate is null, never 0 — nobody has entered the date, which is not the same as being insured for free. Financing (20 agreements, R428 585/mo) Instalment from the sheet; start date, paid-off date and the settled toggle are human-entered and preserved across re-seeds. Months-left and outstanding are null until a date is entered, never R0 on a live debt. The two paid-off agreements (WB02, DT01) show as settled and are excluded from the run-rate. Purchases + Oil Stock Uploads PO PDFs the way /income/invoices does. The parser reads columns by x-position, because plain text extraction reorders them, and refuses any PO whose lines do not sum to the sub-total. A cost code either names a machine ("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never guessed. Oil POs move litres into oil_stock_movements instead of onto a machine; stock is the sum of movements so the first usage is a subtraction from history. Operators (19) and Mechanics (2) Both are people with an employee number, separately reconcilable by the seed so someone added in the app survives. Mechanics get a badge QR; a mechanic with no employee number cannot be printed, because the code is what the QR encodes. Cross-cutting - The seed no longer deletes operators, mechanics or oil items: people are added in the app, and those rows exist in no file. - Re-pointing allocations that PocketBase clears because `assets` is rebuilt with new ids each seed (operators, purchase lines). - One QR encoder for screen and PDF, so a printed code cannot drift from the one on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them. - Every figure is null-safe: unknown is never rendered as zero. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
export type FinancingRow = {
id: string;
code: string;
name: string;
plant: string;
/** Monthly instalment incl VAT, straight from the rate sheet. Not editable in the app. */
installment: number;
startYear: number;
startMonth: number;
endYear: number;
endMonth: number;
isSettled: boolean;
calc: FinanceCalc;
};
/**
* One row per finance agreement, with its settlement maths already resolved.
*
* A machine with no agreement is left OUT, as everywhere else here: it was not
* financed, which is a different statement from being financed at R0.
*/
export function buildFinancingRows(
assets: Asset[],
terms: FinancingTerm[],
now: { year: number; month: number },
): FinancingRow[] {
const byId = new Map(assets.map((a) => [a.id, a]));
const out: FinancingRow[] = [];
for (const t of terms) {
const a = byId.get(t.asset);
if (!a || !a.code) continue;
out.push({
id: t.id,
code: a.code,
name: a.name,
plant: t.plant || "",
installment: t.installment || 0,
startYear: t.start_year || 0,
startMonth: t.start_month || 0,
endYear: t.end_year || 0,
endMonth: t.end_month || 0,
isSettled: Boolean(t.is_settled),
calc: financeCalc(toInput(t, now)),
});
}
return out;
}
export type OperatorRow = {
/** null on a row that represents an operator with no machine of their own. */
machineId: string | null;
machineCode: string;
machineName: string;
operatorId: string | null;
operatorName: string;
employeeCode: string;
rate: number | null;
/** True when this row exists because of an operator, not a machine. */
isUnassignedOperator: boolean;
};
/**
* The operator register, keyed on the PLANT REGISTER but SCOPED to the sheet.
*
* Only machines the client's operator sheet lists appear — twenty vehicles. The
* register holds forty-one coded machines and the difference (containers, generators,
* truck trailers) can never have a person allocated to them, so listing them would
* present twenty-odd permanent blanks as if they were staffing gaps. The machine's
* NAME and CODE still come from the register, so the list stays linked to it.
*
* Every in-scope machine gets a row, whether or not anyone is on it: an unstaffed
* machine is exactly what a reader needs to see. A machine with no operator carries
* blank operator cells; that is NOT the same as an operator with no rate.
*
* Operators the sheet gave no machine get their own row at the end with blank machine
* cells, because they are people on the payroll and must not vanish from the register.
*
* Assets with NO code are skipped even if flagged: the sheet addresses machines by
* P/Unit, so an uncoded entry cannot be pointed at.
*/
export function buildOperatorRows(assets: Asset[], operators: Operator[]): OperatorRow[] {
const byMachine = new Map<string, Operator>();
const unassigned: Operator[] = [];
for (const o of operators) {
if (o.asset) byMachine.set(o.asset, o);
else unassigned.push(o);
}
// The sheet decides the baseline, but a machine someone has been allocated to ALWAYS
// appears — otherwise adding an operator to a machine that was not on the sheet would
// look like it silently did nothing. Adding a person must always be visible.
const inScope = assets.filter(
(a) => a.code && (a.on_operator_sheet || byMachine.has(a.id)),
);
const rows: OperatorRow[] = inScope.map((a) => {
const op = byMachine.get(a.id);
return {
machineId: a.id,
machineCode: a.code,
machineName: a.name,
operatorId: op?.id ?? null,
operatorName: op?.name ?? "",
employeeCode: op?.employee_code ?? "",
rate: op ? op.rate ?? null : null,
isUnassignedOperator: false,
};
});
for (const op of unassigned) {
rows.push({
machineId: null,
machineCode: "",
machineName: "",
operatorId: op.id,
operatorName: op.name,
employeeCode: op.employee_code,
rate: op.rate ?? null,
isUnassignedOperator: true,
});
}
return rows;
}
export type InsuranceRow = {
id: string;
code: string;
name: string;
// 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)[];
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.
2026-11-04 23:42:23 +00:00
// 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.
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics Splits the Running Costs group into six purpose-built pages, each with its own collection and its own source data, plus a coordinate parser for purchase orders. Insurance (R503 330/yr, 20 vehicles) A premium can move every month or three, so it is stored as a RATE PERIOD in insurance_rates and each month's cost is resolved against the rate in force that month. cost_lines insurance rows are derived from those periods. An absent rate is null, never 0 — nobody has entered the date, which is not the same as being insured for free. Financing (20 agreements, R428 585/mo) Instalment from the sheet; start date, paid-off date and the settled toggle are human-entered and preserved across re-seeds. Months-left and outstanding are null until a date is entered, never R0 on a live debt. The two paid-off agreements (WB02, DT01) show as settled and are excluded from the run-rate. Purchases + Oil Stock Uploads PO PDFs the way /income/invoices does. The parser reads columns by x-position, because plain text extraction reorders them, and refuses any PO whose lines do not sum to the sub-total. A cost code either names a machine ("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never guessed. Oil POs move litres into oil_stock_movements instead of onto a machine; stock is the sum of movements so the first usage is a subtraction from history. Operators (19) and Mechanics (2) Both are people with an employee number, separately reconcilable by the seed so someone added in the app survives. Mechanics get a badge QR; a mechanic with no employee number cannot be printed, because the code is what the QR encodes. Cross-cutting - The seed no longer deletes operators, mechanics or oil items: people are added in the app, and those rows exist in no file. - Re-pointing allocations that PocketBase clears because `assets` is rebuilt with new ids each seed (operators, purchase lines). - One QR encoder for screen and PDF, so a printed code cannot drift from the one on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them. - Every figure is null-safe: unknown is never rendered as zero. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
ytd: number;
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.
2026-11-04 23:42:23 +00:00
/** Months of the whole year that carry a premium — gaps included. */
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics Splits the Running Costs group into six purpose-built pages, each with its own collection and its own source data, plus a coordinate parser for purchase orders. Insurance (R503 330/yr, 20 vehicles) A premium can move every month or three, so it is stored as a RATE PERIOD in insurance_rates and each month's cost is resolved against the rate in force that month. cost_lines insurance rows are derived from those periods. An absent rate is null, never 0 — nobody has entered the date, which is not the same as being insured for free. Financing (20 agreements, R428 585/mo) Instalment from the sheet; start date, paid-off date and the settled toggle are human-entered and preserved across re-seeds. Months-left and outstanding are null until a date is entered, never R0 on a live debt. The two paid-off agreements (WB02, DT01) show as settled and are excluded from the run-rate. Purchases + Oil Stock Uploads PO PDFs the way /income/invoices does. The parser reads columns by x-position, because plain text extraction reorders them, and refuses any PO whose lines do not sum to the sub-total. A cost code either names a machine ("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never guessed. Oil POs move litres into oil_stock_movements instead of onto a machine; stock is the sum of movements so the first usage is a subtraction from history. Operators (19) and Mechanics (2) Both are people with an employee number, separately reconcilable by the seed so someone added in the app survives. Mechanics get a badge QR; a mechanic with no employee number cannot be printed, because the code is what the QR encodes. Cross-cutting - The seed no longer deletes operators, mechanics or oil items: people are added in the app, and those rows exist in no file. - Re-pointing allocations that PocketBase clears because `assets` is rebuilt with new ids each seed (operators, purchase lines). - One QR encoder for screen and PDF, so a printed code cannot drift from the one on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them. - Every figure is null-safe: unknown is never rendered as zero. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
coveredMonths: number;
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.
2026-11-04 23:42:23 +00:00
/** Months inside the year-to-date window that carry a premium. What `ytd` sums. */
chargedMonths: number;
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics Splits the Running Costs group into six purpose-built pages, each with its own collection and its own source data, plus a coordinate parser for purchase orders. Insurance (R503 330/yr, 20 vehicles) A premium can move every month or three, so it is stored as a RATE PERIOD in insurance_rates and each month's cost is resolved against the rate in force that month. cost_lines insurance rows are derived from those periods. An absent rate is null, never 0 — nobody has entered the date, which is not the same as being insured for free. Financing (20 agreements, R428 585/mo) Instalment from the sheet; start date, paid-off date and the settled toggle are human-entered and preserved across re-seeds. Months-left and outstanding are null until a date is entered, never R0 on a live debt. The two paid-off agreements (WB02, DT01) show as settled and are excluded from the run-rate. Purchases + Oil Stock Uploads PO PDFs the way /income/invoices does. The parser reads columns by x-position, because plain text extraction reorders them, and refuses any PO whose lines do not sum to the sub-total. A cost code either names a machine ("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never guessed. Oil POs move litres into oil_stock_movements instead of onto a machine; stock is the sum of movements so the first usage is a subtraction from history. Operators (19) and Mechanics (2) Both are people with an employee number, separately reconcilable by the seed so someone added in the app survives. Mechanics get a badge QR; a mechanic with no employee number cannot be printed, because the code is what the QR encodes. Cross-cutting - The seed no longer deletes operators, mechanics or oil items: people are added in the app, and those rows exist in no file. - Re-pointing allocations that PocketBase clears because `assets` is rebuilt with new ids each seed (operators, purchase lines). - One QR encoder for screen and PDF, so a printed code cannot drift from the one on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them. - Every figure is null-safe: unknown is never rendered as zero. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
currentRate: number | null;
// The machine's premium history, newest first — the point of the page.
periods: RatePeriod[];
problems: string[];
};
/**
* One row per insured machine, built from RATE PERIODS (`insurance_rates`), not from
* `cost_lines`. The rates are the source of truth; the monthly cost lines are
* derived from them by the seed, so reading the rates directly is what keeps this
* page showing a premium change rather than a flat rate re-keyed twelve times.
*
* 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".
*/
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.
2026-11-04 23:42:23 +00:00
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[] {
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics Splits the Running Costs group into six purpose-built pages, each with its own collection and its own source data, plus a coordinate parser for purchase orders. Insurance (R503 330/yr, 20 vehicles) A premium can move every month or three, so it is stored as a RATE PERIOD in insurance_rates and each month's cost is resolved against the rate in force that month. cost_lines insurance rows are derived from those periods. An absent rate is null, never 0 — nobody has entered the date, which is not the same as being insured for free. Financing (20 agreements, R428 585/mo) Instalment from the sheet; start date, paid-off date and the settled toggle are human-entered and preserved across re-seeds. Months-left and outstanding are null until a date is entered, never R0 on a live debt. The two paid-off agreements (WB02, DT01) show as settled and are excluded from the run-rate. Purchases + Oil Stock Uploads PO PDFs the way /income/invoices does. The parser reads columns by x-position, because plain text extraction reorders them, and refuses any PO whose lines do not sum to the sub-total. A cost code either names a machine ("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never guessed. Oil POs move litres into oil_stock_movements instead of onto a machine; stock is the sum of movements so the first usage is a subtraction from history. Operators (19) and Mechanics (2) Both are people with an employee number, separately reconcilable by the seed so someone added in the app survives. Mechanics get a badge QR; a mechanic with no employee number cannot be printed, because the code is what the QR encodes. Cross-cutting - The seed no longer deletes operators, mechanics or oil items: people are added in the app, and those rows exist in no file. - Re-pointing allocations that PocketBase clears because `assets` is rebuilt with new ids each seed (operators, purchase lines). - One QR encoder for screen and PDF, so a printed code cannot drift from the one on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them. - Every figure is null-safe: unknown is never rendered as zero. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
const byId = new Map(assets.map((a) => [a.id, a]));
const grouped = new Map<string, RatePeriod[]>();
for (const r of rates) {
const a = byId.get(r.asset);
if (!a || !a.code) continue;
const list = grouped.get(a.id) || [];
list.push({ from: r.month_from, to: r.month_to || 0, rate: r.rate, source: r.source || "" });
grouped.set(a.id, list);
}
const out: InsuranceRow[] = [];
for (const [assetId, periods] of grouped) {
const a = byId.get(assetId)!;
const cost = monthlyCost(periods);
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.
2026-11-04 23:42:23 +00:00
const window = cost.slice(0, Math.max(0, Math.min(12, throughMonth)));
const known = window.filter((v): v is number => v !== null);
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics Splits the Running Costs group into six purpose-built pages, each with its own collection and its own source data, plus a coordinate parser for purchase orders. Insurance (R503 330/yr, 20 vehicles) A premium can move every month or three, so it is stored as a RATE PERIOD in insurance_rates and each month's cost is resolved against the rate in force that month. cost_lines insurance rows are derived from those periods. An absent rate is null, never 0 — nobody has entered the date, which is not the same as being insured for free. Financing (20 agreements, R428 585/mo) Instalment from the sheet; start date, paid-off date and the settled toggle are human-entered and preserved across re-seeds. Months-left and outstanding are null until a date is entered, never R0 on a live debt. The two paid-off agreements (WB02, DT01) show as settled and are excluded from the run-rate. Purchases + Oil Stock Uploads PO PDFs the way /income/invoices does. The parser reads columns by x-position, because plain text extraction reorders them, and refuses any PO whose lines do not sum to the sub-total. A cost code either names a machine ("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never guessed. Oil POs move litres into oil_stock_movements instead of onto a machine; stock is the sum of movements so the first usage is a subtraction from history. Operators (19) and Mechanics (2) Both are people with an employee number, separately reconcilable by the seed so someone added in the app survives. Mechanics get a badge QR; a mechanic with no employee number cannot be printed, because the code is what the QR encodes. Cross-cutting - The seed no longer deletes operators, mechanics or oil items: people are added in the app, and those rows exist in no file. - Re-pointing allocations that PocketBase clears because `assets` is rebuilt with new ids each seed (operators, purchase lines). - One QR encoder for screen and PDF, so a printed code cannot drift from the one on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them. - Every figure is null-safe: unknown is never rendered as zero. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
out.push({
id: assetId,
code: a.code,
name: a.name,
months: cost,
ytd: known.reduce((s, v) => s + v, 0),
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.
2026-11-04 23:42:23 +00:00
coveredMonths: cost.filter((v) => v !== null).length,
chargedMonths: known.length,
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics Splits the Running Costs group into six purpose-built pages, each with its own collection and its own source data, plus a coordinate parser for purchase orders. Insurance (R503 330/yr, 20 vehicles) A premium can move every month or three, so it is stored as a RATE PERIOD in insurance_rates and each month's cost is resolved against the rate in force that month. cost_lines insurance rows are derived from those periods. An absent rate is null, never 0 — nobody has entered the date, which is not the same as being insured for free. Financing (20 agreements, R428 585/mo) Instalment from the sheet; start date, paid-off date and the settled toggle are human-entered and preserved across re-seeds. Months-left and outstanding are null until a date is entered, never R0 on a live debt. The two paid-off agreements (WB02, DT01) show as settled and are excluded from the run-rate. Purchases + Oil Stock Uploads PO PDFs the way /income/invoices does. The parser reads columns by x-position, because plain text extraction reorders them, and refuses any PO whose lines do not sum to the sub-total. A cost code either names a machine ("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never guessed. Oil POs move litres into oil_stock_movements instead of onto a machine; stock is the sum of movements so the first usage is a subtraction from history. Operators (19) and Mechanics (2) Both are people with an employee number, separately reconcilable by the seed so someone added in the app survives. Mechanics get a badge QR; a mechanic with no employee number cannot be printed, because the code is what the QR encodes. Cross-cutting - The seed no longer deletes operators, mechanics or oil items: people are added in the app, and those rows exist in no file. - Re-pointing allocations that PocketBase clears because `assets` is rebuilt with new ids each seed (operators, purchase lines). - One QR encoder for screen and PDF, so a printed code cannot drift from the one on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them. - Every figure is null-safe: unknown is never rendered as zero. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
currentRate: currentRate(periods)?.rate ?? null,
// Newest first — the period you most likely came to read.
periods: [...periods].sort((x, y) => y.from - x.from),
problems: validatePeriods(periods),
});
}
return out;
}