From 2d36e9b1cd2f09a33e6a773b55d37be0825f6474 Mon Sep 17 00:00:00 2001 From: DEls Date: Sat, 29 Aug 2026 19:17:14 +0200 Subject: [PATCH] fix(vehicles): handle empty unit_type during updates Updated the vehicles API to allow PATCH requests with an empty unit_type. This change ensures that existing records with unit_type='' can be updated without validation errors, while maintaining the default value of 'fuel' for new inserts. This adjustment resolves issues with updating pre-existing vehicle records that lack a unit_type field. --- RAW_DOCS/Plant_Hours_Template_1.xlsx | Bin 0 -> 8073 bytes .../plans/2026-08-29-metrics-extraction.md | 1943 +++++++++++++++++ .../pb-bootstrap-schema.cpython-314.pyc | Bin 0 -> 12099 bytes 3 files changed, 1943 insertions(+) create mode 100644 RAW_DOCS/Plant_Hours_Template_1.xlsx create mode 100644 docs/superpowers/plans/2026-08-29-metrics-extraction.md create mode 100644 scripts/__pycache__/pb-bootstrap-schema.cpython-314.pyc diff --git a/RAW_DOCS/Plant_Hours_Template_1.xlsx b/RAW_DOCS/Plant_Hours_Template_1.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..41192a15f6f8f8431224b62810afa115b0de2ff6 GIT binary patch literal 8073 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 PzB;~LSx6DZ^{@W{-hS1H literal 0 HcmV?d00001 diff --git a/docs/superpowers/plans/2026-08-29-metrics-extraction.md b/docs/superpowers/plans/2026-08-29-metrics-extraction.md new file mode 100644 index 0000000..eb7d3b6 --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-metrics-extraction.md @@ -0,0 +1,1943 @@ +# Site Metrics Extraction Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extract the site financial roll-up and projection math out of the two Next.js page components into pure, tested functions in `src/lib/metrics/`, so the dashboard, a future MCP server, and a future chat agent all compute identical numbers from one implementation. + +**Architecture:** Six small pure modules under `src/lib/metrics/`, each with one responsibility and a vitest suite. Page components keep their PocketBase fetching and become thin assemblers that call the metrics functions. No behaviour is invented — every formula is lifted verbatim from the existing pages except where this plan explicitly calls out a divergence fix. + +**Tech Stack:** TypeScript, vitest (`environment: node`, `include: src/**/*.test.ts`), PocketBase SDK, Next.js 16 server components. + +--- + +## Why this exists + +The chat agent / MCP server cannot answer "what's my projected profit this month" today, because the math lives inline in a React server component and nothing else can call it. + +While scoping, three real defects were found that this extraction fixes as a side effect: + +**Defect A — the two pages disagree on projected profit for the same site.** +- `src/app/dashboard/sites/page.tsx:114-116` — start date = `job_start_date` → else *latest* fixed-cost date → else now. +- `src/app/dashboard/sites/[id]/page.tsx:282-296` — start date = *earliest* activity date across fixed costs / timesheets / fuel / income, and **ignores `job_start_date` completely**. + +Different start date → different `elapsedMonths` → different `projectedTotal` and `projectedProfit`. A user clicking from the list into the detail page sees two different numbers. + +**Defect B — the detail page's start date is computed from unfiltered global timesheets.** +`src/app/dashboard/sites/[id]/page.tsx:39` fetches `timesheets` with no site filter, and line 118 passes that whole global list into `pickStartDate`. A timesheet belonging to a different site can set this site's start date. (Payroll itself is fine — it filters via `emp.site !== id` at line 55.) + +**Defect C — `status` is computed differently.** The list page can return `"upcoming"`; the detail page has no such branch. + +**Resolution (decided with the product owner, 2026-08-29):** +- Start date = `job_start_date` when set, else the **earliest site-filtered activity date**, else now. +- `RATE_PER_LITRE` becomes one exported constant (it is currently hardcoded in 4 files). +- Scope of this plan is site financials + projection only. Fuel attention and plant utilization are explicitly out of scope. + +**Expected visible change:** sites that have `job_start_date` set will show a different (more correct) projection on the **detail** page than they do today, now matching the list page. This is the intended fix for Defect A, not a regression. + +--- + +## File Structure + +**Create:** + +| File | Responsibility | +|---|---| +| `src/lib/metrics/constants.ts` | Shared rates and time constants. No logic. | +| `src/lib/metrics/duration.ts` | `parseDurationMonths`, `isoWeekKey`, `monthKey` — date/duration parsing only. | +| `src/lib/metrics/site-start-date.ts` | `resolveSiteStartDate` — the single answer to "when did this site start". | +| `src/lib/metrics/site-spend.ts` | `computeSiteSpend` — cost roll-up (payroll, fuel, material, P&G). Knows nothing about time. | +| `src/lib/metrics/site-projection.ts` | `computeSiteProjection` — elapsed/burn/projection/status. Knows nothing about PocketBase. | +| `src/lib/metrics/income-vs-expenses.ts` | Weekly/monthly income-vs-expense series and daily fuel cost. | +| `src/lib/metrics/index.ts` | Barrel re-export — the single import surface for pages and future agent tools. | + +**Modify:** + +| File | Change | +|---|---| +| `src/app/dashboard/sites/page.tsx` | Replace inline roll-up with metrics calls; delete local `parseDurationMonths`. | +| `src/app/dashboard/sites/[id]/page.tsx` | Replace inline roll-up + 5 local helpers with metrics calls; site-filter timesheets. | +| `src/app/dashboard/fuel/page.tsx:11` | Import `RATE_PER_LITRE` instead of redeclaring. | +| `src/app/dashboard/fuel/[vehicleId]/page.tsx:15` | Import `RATE_PER_LITRE` instead of redeclaring. | + +Each module is small enough to hold in context and is independently testable. `site-spend` and `site-projection` are deliberately split: spend is a pure sum over records, projection is a pure function of (spend, contract, timeline). The agent tool layer will want them separately. + +--- + +### Task 1: Shared constants + +**Files:** +- Create: `src/lib/metrics/constants.ts` + +- [ ] **Step 1: Write the constants module** + +```typescript +/** + * Shared constants for site financial metrics. + * + * RATE_PER_LITRE was previously hardcoded in four separate files + * (dashboard/fuel/page.tsx, dashboard/fuel/[vehicleId]/page.tsx, + * dashboard/sites/page.tsx, dashboard/sites/[id]/page.tsx). Changing + * the pump price meant finding all four. It now lives here only. + */ + +/** Current pump price, ZAR per litre. */ +export const RATE_PER_LITRE = 25.65; + +/** Billable working days in a month — used to amortise monthly P&G to a daily figure. */ +export const WORKING_DAYS_PER_MONTH = 21; + +/** Fallback weekly plant capacity when a site has no `hours_per_week` set. */ +export const DEFAULT_SITE_WEEKLY_HOURS = 45; + +export const MS_PER_DAY = 86_400_000; +export const MS_PER_WEEK = MS_PER_DAY * 7; + +/** Mean Gregorian month length. Matches the 30.4375 divisor used by the pages today. */ +export const AVG_DAYS_PER_MONTH = 30.4375; +export const MS_PER_MONTH = MS_PER_DAY * AVG_DAYS_PER_MONTH; +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `npx tsc --noEmit` +Expected: exits 0, no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/metrics/constants.ts +git commit -m "feat(metrics): add shared constants module" +``` + +--- + +### Task 2: Duration and date-bucket helpers + +Lifts `parseDurationMonths` (duplicated verbatim at `sites/page.tsx:188` and `sites/[id]/page.tsx:277`), plus `isoWeekKey` and `monthKey` (`sites/[id]/page.tsx:298-314`). + +**Files:** +- Create: `src/lib/metrics/duration.ts` +- Test: `src/lib/metrics/duration.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +import { describe, it, expect } from "vitest"; +import { parseDurationMonths, isoWeekKey, monthKey } from "./duration"; + +describe("parseDurationMonths", () => { + it("extracts a leading integer", () => { + expect(parseDurationMonths("18 months")).toBe(18); + }); + + it("extracts a decimal", () => { + expect(parseDurationMonths("7.5 months")).toBe(7.5); + }); + + it("extracts a bare number", () => { + expect(parseDurationMonths("24")).toBe(24); + }); + + it("returns 0 for a string with no digits", () => { + expect(parseDurationMonths("ongoing")).toBe(0); + }); + + it("returns 0 for empty string", () => { + expect(parseDurationMonths("")).toBe(0); + }); + + it("returns 0 for undefined", () => { + expect(parseDurationMonths(undefined)).toBe(0); + }); +}); + +describe("monthKey", () => { + it("formats as YYYY-MM with a zero-padded month", () => { + expect(monthKey(new Date(2026, 0, 15))).toBe("2026-01"); + }); + + it("does not pad a two-digit month", () => { + expect(monthKey(new Date(2026, 11, 1))).toBe("2026-12"); + }); +}); + +describe("isoWeekKey", () => { + it("formats as YYYY-Www with a zero-padded week", () => { + // 2026-01-08 is a Thursday in ISO week 2. + expect(isoWeekKey(new Date(2026, 0, 8))).toBe("2026-W02"); + }); + + it("gives the same key for every day of one ISO week", () => { + // Mon 2026-03-02 .. Sun 2026-03-08 + const keys = [2, 3, 4, 5, 6, 7, 8].map((d) => isoWeekKey(new Date(2026, 2, d))); + expect(new Set(keys).size).toBe(1); + }); + + it("rolls to a new key across a week boundary", () => { + const sunday = isoWeekKey(new Date(2026, 2, 8)); + const monday = isoWeekKey(new Date(2026, 2, 9)); + expect(sunday).not.toBe(monday); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/metrics/duration.test.ts` +Expected: FAIL — "Failed to resolve import ./duration". + +- [ ] **Step 3: Write the implementation** + +```typescript +/** + * Duration parsing and date-bucket keys for site metrics. + * + * `parseDurationMonths` was duplicated verbatim in + * dashboard/sites/page.tsx and dashboard/sites/[id]/page.tsx. + * `isoWeekKey` / `monthKey` were private to the site detail page. + */ + +/** + * Pull a month count out of a free-text `sites.duration` string + * ("18 months", "7.5 months", "24"). Returns 0 when no number is present, + * which callers treat as "no known duration" — projection is skipped. + */ +export function parseDurationMonths(s: string | undefined): number { + const m = s?.match(/(\d+(?:\.\d+)?)/); + return m ? Number(m[1]) : 0; +} + +/** ISO-8601 week key, e.g. "2026-W02". Weeks start Monday. */ +export function isoWeekKey(d: Date): string { + const target = new Date(d.valueOf()); + const dayNr = (d.getDay() + 6) % 7; + target.setDate(target.getDate() - dayNr + 3); + const firstThursday = target.valueOf(); + target.setMonth(0, 1); + if (target.getDay() !== 4) { + target.setMonth(0, 1 + ((4 - target.getDay()) + 7) % 7); + } + const weekNum = 1 + Math.ceil((firstThursday - target.valueOf()) / 604800000); + return `${d.getFullYear()}-W${String(weekNum).padStart(2, "0")}`; +} + +/** Calendar month key, e.g. "2026-01". */ +export function monthKey(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/metrics/duration.test.ts` +Expected: PASS, 11 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/metrics/duration.ts src/lib/metrics/duration.test.ts +git commit -m "feat(metrics): extract duration and date-bucket helpers" +``` + +--- + +### Task 3: Unified site start date (fixes Defect A + B) + +This is the behaviour-changing task. Read the "Why this exists" section above before starting. + +**Files:** +- Create: `src/lib/metrics/site-start-date.ts` +- Test: `src/lib/metrics/site-start-date.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +import { describe, it, expect } from "vitest"; +import { resolveSiteStartDate } from "./site-start-date"; + +const NOW = new Date("2026-08-29T00:00:00.000Z"); + +describe("resolveSiteStartDate", () => { + it("prefers an explicit job_start_date over any activity", () => { + const r = resolveSiteStartDate({ + jobStartDate: "2026-01-15", + fixedCosts: [{ date_logged: "2026-03-01" }], + now: NOW, + }); + expect(r.startDate).toBe("2026-01-15"); + expect(r.source).toBe("job_start_date"); + }); + + it("ignores a blank or whitespace job_start_date", () => { + const r = resolveSiteStartDate({ + jobStartDate: " ", + fixedCosts: [{ date_logged: "2026-03-01" }], + now: NOW, + }); + expect(r.startDate).toBe("2026-03-01"); + expect(r.source).toBe("first_activity"); + }); + + it("falls back to the EARLIEST activity date across all sources", () => { + const r = resolveSiteStartDate({ + fixedCosts: [{ date_logged: "2026-05-01" }], + timesheets: [{ date_logged: "2026-02-10" }], + fuelLogs: [{ date_logged: "2026-07-01" }], + now: NOW, + }); + expect(r.startDate).toBe("2026-02-10"); + expect(r.source).toBe("first_activity"); + }); + + it("uses income paid_on when present", () => { + const r = resolveSiteStartDate({ + income: [{ paid_on: "2026-04-02", period_end: "2026-04-30" }], + now: NOW, + }); + expect(r.startDate).toBe("2026-04-02"); + }); + + it("falls back to income period_end when paid_on is absent", () => { + const r = resolveSiteStartDate({ + income: [{ period_end: "2026-04-30" }], + now: NOW, + }); + expect(r.startDate).toBe("2026-04-30"); + }); + + it("skips rows with missing or empty dates", () => { + const r = resolveSiteStartDate({ + fixedCosts: [{ date_logged: undefined }, { date_logged: "" }, { date_logged: "2026-06-01" }], + now: NOW, + }); + expect(r.startDate).toBe("2026-06-01"); + expect(r.source).toBe("first_activity"); + }); + + it("falls back to now when there is no job_start_date and no activity", () => { + const r = resolveSiteStartDate({ now: NOW }); + expect(r.startDate).toBe(NOW.toISOString()); + expect(r.source).toBe("fallback_now"); + }); + + it("is stable regardless of input ordering", () => { + const a = resolveSiteStartDate({ + fixedCosts: [{ date_logged: "2026-05-01" }, { date_logged: "2026-01-01" }], + now: NOW, + }); + const b = resolveSiteStartDate({ + fixedCosts: [{ date_logged: "2026-01-01" }, { date_logged: "2026-05-01" }], + now: NOW, + }); + expect(a.startDate).toBe(b.startDate); + expect(a.startDate).toBe("2026-01-01"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/metrics/site-start-date.test.ts` +Expected: FAIL — "Failed to resolve import ./site-start-date". + +- [ ] **Step 3: Write the implementation** + +```typescript +/** + * The single answer to "when did this site start". + * + * Replaces two divergent implementations that produced different + * projections for the same site: + * - dashboard/sites/page.tsx job_start_date -> LATEST fixed cost -> now + * - dashboard/sites/[id]/page.tsx EARLIEST activity, job_start_date ignored + * + * Agreed behaviour: explicit `job_start_date` wins; otherwise the + * EARLIEST activity date; otherwise now. + * + * IMPORTANT: every array passed in must already be filtered to this site. + * The old detail page passed a global, unfiltered timesheet list, so a + * different site's timesheet could set this site's start date. + */ + +export interface DatedRow { + date_logged?: string; +} + +export interface IncomeDatedRow { + paid_on?: string; + period_end?: string; +} + +export interface ResolveSiteStartDateArgs { + /** `sites.job_start_date`. Blank/whitespace is treated as unset. */ + jobStartDate?: string; + /** All arrays MUST be pre-filtered to the site in question. */ + fixedCosts?: DatedRow[]; + timesheets?: DatedRow[]; + fuelLogs?: DatedRow[]; + income?: IncomeDatedRow[]; + /** Injectable for deterministic tests. */ + now?: Date; +} + +export type SiteStartDateSource = + | "job_start_date" + | "first_activity" + | "fallback_now"; + +export interface SiteStartDate { + startDate: string; + source: SiteStartDateSource; +} + +export function resolveSiteStartDate( + args: ResolveSiteStartDateArgs, +): SiteStartDate { + const explicit = args.jobStartDate?.trim(); + if (explicit) { + return { startDate: explicit, source: "job_start_date" }; + } + + const candidates: string[] = []; + const push = (d: string | undefined) => { + if (d) candidates.push(d); + }; + + for (const r of args.fixedCosts ?? []) push(r.date_logged); + for (const r of args.timesheets ?? []) push(r.date_logged); + for (const r of args.fuelLogs ?? []) push(r.date_logged); + for (const r of args.income ?? []) push(r.paid_on || r.period_end); + + if (candidates.length > 0) { + // ISO dates sort lexicographically; [0] is the earliest. + candidates.sort(); + return { startDate: candidates[0], source: "first_activity" }; + } + + return { + startDate: (args.now ?? new Date()).toISOString(), + source: "fallback_now", + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/metrics/site-start-date.test.ts` +Expected: PASS, 8 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/metrics/site-start-date.ts src/lib/metrics/site-start-date.test.ts +git commit -m "fix(metrics): unify site start date across list and detail pages + +The sites list and site detail pages resolved a site's start date +differently, producing different projected profit for the same site. +Detail also ignored job_start_date and read from a global, unfiltered +timesheet list. One implementation now: job_start_date, else earliest +site-filtered activity, else now." +``` + +--- + +### Task 4: Site spend roll-up + +Lifts the cost aggregation from `sites/[id]/page.tsx:47-109` and the equivalent per-site loops in `sites/page.tsx:62-105`. + +**Files:** +- Create: `src/lib/metrics/site-spend.ts` +- Test: `src/lib/metrics/site-spend.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +import { describe, it, expect } from "vitest"; +import { computeSiteSpend, type EmployeeRateMap } from "./site-spend"; +import { RATE_PER_LITRE } from "./constants"; + +const rates: EmployeeRateMap = new Map([ + ["e1", { rate: 100, site: "s1", name: "Alice" }], + ["e2", { rate: 50, site: "s1", name: "Bob" }], + ["e3", { rate: 999, site: "OTHER", name: "Mallory" }], +]); + +const base = { + siteId: "s1", + fixedCosts: [], + timesheets: [], + employeeRates: rates, + purchaseOrders: [], + fuelLogs: [], +}; + +describe("computeSiteSpend", () => { + it("returns all zeros for empty input", () => { + const r = computeSiteSpend(base); + expect(r.totalSpent).toBe(0); + expect(r.payrollBurn).toBe(0); + expect(r.fuelBurn).toBe(0); + expect(r.materialSum).toBe(0); + expect(r.fixedSum).toBe(0); + expect(r.oneOffSum).toBe(0); + }); + + it("computes payroll as hours x rate", () => { + const r = computeSiteSpend({ + ...base, + timesheets: [{ employee: "e1", hours_worked: 8 }], + }); + expect(r.payrollBurn).toBe(800); + }); + + it("excludes timesheets for employees on another site", () => { + const r = computeSiteSpend({ + ...base, + timesheets: [ + { employee: "e1", hours_worked: 8 }, + { employee: "e3", hours_worked: 8 }, + ], + }); + expect(r.payrollBurn).toBe(800); + }); + + it("excludes timesheets for unknown employees", () => { + const r = computeSiteSpend({ + ...base, + timesheets: [{ employee: "ghost", hours_worked: 8 }], + }); + expect(r.payrollBurn).toBe(0); + }); + + it("treats missing hours_worked as zero", () => { + const r = computeSiteSpend({ + ...base, + timesheets: [{ employee: "e1" }], + }); + expect(r.payrollBurn).toBe(0); + }); + + it("aggregates payroll per employee, sorted by cost descending", () => { + const r = computeSiteSpend({ + ...base, + timesheets: [ + { employee: "e2", hours_worked: 10 }, + { employee: "e1", hours_worked: 4 }, + { employee: "e1", hours_worked: 4 }, + ], + }); + expect(r.payrollByEmployee).toEqual([ + { employeeId: "e1", name: "Alice", hours: 8, cost: 800 }, + { employeeId: "e2", name: "Bob", hours: 10, cost: 500 }, + ]); + }); + + it("costs fuel at RATE_PER_LITRE and reports total litres", () => { + const r = computeSiteSpend({ + ...base, + fuelLogs: [{ litres: 10 }, { litres: 5 }], + }); + expect(r.fuelLitres).toBe(15); + expect(r.fuelBurn).toBeCloseTo(15 * RATE_PER_LITRE, 6); + }); + + it("sums purchase order values as material spend", () => { + const r = computeSiteSpend({ + ...base, + purchaseOrders: [{ total_value: 1000 }, { total_value: 250 }, {}], + }); + expect(r.materialSum).toBe(1250); + }); + + it("splits fixed costs into monthly and one-off", () => { + const r = computeSiteSpend({ + ...base, + fixedCosts: [ + { id: "a", cost: 300, is_recurring: true, date_logged: "2026-01-01" }, + { id: "b", cost: 700, is_recurring: false, date_logged: "2026-01-02" }, + ], + }); + expect(r.fixedSum).toBe(300); + expect(r.oneOffSum).toBe(700); + expect(r.monthlyItems).toHaveLength(1); + expect(r.oneOffItems).toHaveLength(1); + }); + + it("totalSpent is the sum of all five buckets", () => { + const r = computeSiteSpend({ + ...base, + fixedCosts: [ + { id: "a", cost: 300, is_recurring: true, date_logged: "2026-01-01" }, + { id: "b", cost: 700, is_recurring: false, date_logged: "2026-01-02" }, + ], + timesheets: [{ employee: "e1", hours_worked: 8 }], + purchaseOrders: [{ total_value: 1000 }], + fuelLogs: [{ litres: 10 }], + }); + expect(r.totalSpent).toBeCloseTo(300 + 700 + 800 + 1000 + 10 * RATE_PER_LITRE, 6); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/metrics/site-spend.test.ts` +Expected: FAIL — "Failed to resolve import ./site-spend". + +- [ ] **Step 3: Write the implementation** + +```typescript +/** + * Site cost roll-up. Pure — no PocketBase, no clock, no time reasoning. + * Time-based figures (elapsed, projection) live in ./site-projection. + * + * Lifted from dashboard/sites/[id]/page.tsx:47-109, which the sites + * list page duplicated in per-site Map form. + */ +import { partitionFixedCosts, type PnGItemLike } from "../partition-fixed-costs"; +import { RATE_PER_LITRE } from "./constants"; + +/** employee id -> pay rate and owning site id. */ +export type EmployeeRateMap = Map< + string, + { rate: number; site: string; name?: string } +>; + +export interface TimesheetLike { + employee: string; + hours_worked?: number; +} + +export interface PurchaseOrderLike { + total_value?: number; +} + +export interface FuelLogLike { + litres?: number; +} + +export interface SiteSpendInput { + siteId: string; + /** Pre-filtered to this site. */ + fixedCosts: PnGItemLike[]; + /** + * Need NOT be pre-filtered — rows are matched to the site via + * `employeeRates`, so a global timesheet list is safe here. + */ + timesheets: TimesheetLike[]; + employeeRates: EmployeeRateMap; + /** Pre-filtered to this site. */ + purchaseOrders: PurchaseOrderLike[]; + /** Pre-filtered to this site. */ + fuelLogs: FuelLogLike[]; +} + +export interface PayrollByEmployee { + employeeId: string; + name: string; + hours: number; + cost: number; +} + +export interface SiteSpend { + fixedSum: number; + oneOffSum: number; + payrollBurn: number; + materialSum: number; + fuelBurn: number; + fuelLitres: number; + totalSpent: number; + monthlyItems: PnGItemLike[]; + oneOffItems: PnGItemLike[]; + /** Sorted by cost descending. Callers slice for "top N". */ + payrollByEmployee: PayrollByEmployee[]; +} + +export function computeSiteSpend(input: SiteSpendInput): SiteSpend { + const partitioned = partitionFixedCosts(input.fixedCosts); + + let payrollBurn = 0; + const byEmp = new Map(); + for (const t of input.timesheets) { + const emp = input.employeeRates.get(t.employee); + if (!emp || emp.site !== input.siteId) continue; + const hours = t.hours_worked ?? 0; + const cost = hours * emp.rate; + payrollBurn += cost; + const cur = + byEmp.get(t.employee) ?? + { employeeId: t.employee, name: emp.name ?? "", hours: 0, cost: 0 }; + cur.hours += hours; + cur.cost += cost; + byEmp.set(t.employee, cur); + } + + const materialSum = input.purchaseOrders.reduce( + (s, p) => s + (p.total_value ?? 0), + 0, + ); + + const fuelLitres = input.fuelLogs.reduce((s, f) => s + (f.litres ?? 0), 0); + const fuelBurn = fuelLitres * RATE_PER_LITRE; + + const totalSpent = + partitioned.fixedSum + + partitioned.oneOffSum + + payrollBurn + + materialSum + + fuelBurn; + + return { + fixedSum: partitioned.fixedSum, + oneOffSum: partitioned.oneOffSum, + payrollBurn, + materialSum, + fuelBurn, + fuelLitres, + totalSpent, + monthlyItems: partitioned.monthlyItems, + oneOffItems: partitioned.oneOffItems, + payrollByEmployee: Array.from(byEmp.values()).sort((a, b) => b.cost - a.cost), + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/metrics/site-spend.test.ts` +Expected: PASS, 10 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/metrics/site-spend.ts src/lib/metrics/site-spend.test.ts +git commit -m "feat(metrics): extract site spend roll-up" +``` + +--- + +### Task 5: Site projection and status + +Lifts `sites/[id]/page.tsx:110-136` and `sites/page.tsx:106-135`, unifying the `status` ladder so `"upcoming"` exists on both surfaces (Defect C). + +**Files:** +- Create: `src/lib/metrics/site-projection.ts` +- Test: `src/lib/metrics/site-projection.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +import { describe, it, expect } from "vitest"; +import { computeSiteProjection } from "./site-projection"; + +const NOW = new Date("2026-08-29T00:00:00.000Z"); + +describe("computeSiteProjection", () => { + it("extrapolates spend over the full duration at the current run rate", () => { + // Started 6 months ago, 12-month job, R600k spent -> R1.2m projected. + const r = computeSiteProjection({ + contractValue: 2_000_000, + totalSpent: 600_000, + durationMonths: 12, + startDate: "2026-02-28T00:00:00.000Z", + hasExplicitStart: true, + now: NOW, + }); + expect(r.elapsedMonths).toBeCloseTo(6, 1); + expect(r.projectedTotal).toBeCloseTo(1_200_000, -4); + expect(r.projectedProfit).toBeCloseTo(800_000, -4); + }); + + it("reports a loss when projected spend exceeds the contract", () => { + const r = computeSiteProjection({ + contractValue: 1_000_000, + totalSpent: 900_000, + durationMonths: 12, + startDate: "2026-02-28T00:00:00.000Z", + hasExplicitStart: true, + now: NOW, + }); + expect(r.projectedProfit).toBeLessThan(0); + }); + + it("returns zero projection when no time has elapsed", () => { + const r = computeSiteProjection({ + contractValue: 1_000_000, + totalSpent: 0, + durationMonths: 12, + startDate: NOW.toISOString(), + hasExplicitStart: true, + now: NOW, + }); + expect(r.elapsedMonths).toBe(0); + expect(r.projectedTotal).toBe(0); + expect(r.status).toBe("just-started"); + }); + + it("returns zero projection when duration is unknown", () => { + const r = computeSiteProjection({ + contractValue: 1_000_000, + totalSpent: 500_000, + durationMonths: 0, + startDate: "2026-02-28T00:00:00.000Z", + hasExplicitStart: true, + now: NOW, + }); + expect(r.months).toBe(0); + expect(r.projectedTotal).toBe(0); + expect(r.elapsedPct).toBe(0); + }); + + it("caps elapsed months at the contract duration", () => { + const r = computeSiteProjection({ + contractValue: 1_000_000, + totalSpent: 500_000, + durationMonths: 3, + startDate: "2020-01-01T00:00:00.000Z", + hasExplicitStart: true, + now: NOW, + }); + expect(r.elapsedMonths).toBe(3); + expect(r.remainingMonths).toBe(0); + expect(r.elapsedPct).toBe(100); + }); + + it("marks a future explicit start as upcoming", () => { + const r = computeSiteProjection({ + contractValue: 1_000_000, + totalSpent: 0, + durationMonths: 12, + startDate: "2027-01-01T00:00:00.000Z", + hasExplicitStart: true, + now: NOW, + }); + expect(r.status).toBe("upcoming"); + }); + + it("does not mark a future start as upcoming without an explicit start date", () => { + const r = computeSiteProjection({ + contractValue: 1_000_000, + totalSpent: 0, + durationMonths: 12, + startDate: "2027-01-01T00:00:00.000Z", + hasExplicitStart: false, + now: NOW, + }); + expect(r.status).not.toBe("upcoming"); + }); + + it("is 'under' when burn lags elapsed time", () => { + // 50% elapsed, 10% burnt. + const r = computeSiteProjection({ + contractValue: 1_000_000, + totalSpent: 100_000, + durationMonths: 12, + startDate: "2026-02-28T00:00:00.000Z", + hasExplicitStart: true, + now: NOW, + }); + expect(r.status).toBe("under"); + }); + + it("is 'over' when burn outruns elapsed time", () => { + // 50% elapsed, 90% burnt. + const r = computeSiteProjection({ + contractValue: 1_000_000, + totalSpent: 900_000, + durationMonths: 12, + startDate: "2026-02-28T00:00:00.000Z", + hasExplicitStart: true, + now: NOW, + }); + expect(r.status).toBe("over"); + }); + + it("is 'on-track' when burn tracks elapsed time", () => { + // 50% elapsed, 50% burnt. + const r = computeSiteProjection({ + contractValue: 1_000_000, + totalSpent: 500_000, + durationMonths: 12, + startDate: "2026-02-28T00:00:00.000Z", + hasExplicitStart: true, + now: NOW, + }); + expect(r.status).toBe("on-track"); + }); + + it("clamps remaining budget at zero when overspent", () => { + const r = computeSiteProjection({ + contractValue: 1_000_000, + totalSpent: 1_500_000, + durationMonths: 12, + startDate: "2026-02-28T00:00:00.000Z", + hasExplicitStart: true, + now: NOW, + }); + expect(r.remaining).toBe(0); + expect(r.burnPct).toBeCloseTo(150, 6); + }); + + it("reports zero burnPct for a zero contract value", () => { + const r = computeSiteProjection({ + contractValue: 0, + totalSpent: 500, + durationMonths: 12, + startDate: "2026-02-28T00:00:00.000Z", + hasExplicitStart: true, + now: NOW, + }); + expect(r.burnPct).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/metrics/site-projection.test.ts` +Expected: FAIL — "Failed to resolve import ./site-projection". + +- [ ] **Step 3: Write the implementation** + +```typescript +/** + * Run-rate projection and burn status for a site. + * + * Pure: takes an already-computed spend total plus the contract timeline. + * Lifted from dashboard/sites/[id]/page.tsx:110-136 and the duplicate + * ladder in dashboard/sites/page.tsx:106-135. The "upcoming" branch + * previously existed only on the list page; it now applies to both. + */ +import { MS_PER_MONTH, MS_PER_WEEK } from "./constants"; + +export type SiteStatus = + | "upcoming" + | "just-started" + | "under" + | "on-track" + | "over"; + +export interface SiteProjectionInput { + contractValue: number; + /** From computeSiteSpend(...).totalSpent */ + totalSpent: number; + /** From parseDurationMonths(site.duration). 0 means unknown. */ + durationMonths: number; + /** From resolveSiteStartDate(...).startDate */ + startDate: string; + /** True when resolveSiteStartDate returned source === "job_start_date". */ + hasExplicitStart: boolean; + /** Injectable for deterministic tests. */ + now?: Date; +} + +export interface SiteProjection { + months: number; + elapsedMonths: number; + elapsedPct: number; + remainingMonths: number; + weeksActive: number; + remaining: number; + burnPct: number; + projectedTotal: number; + projectedProfit: number; + status: SiteStatus; +} + +export function computeSiteProjection( + input: SiteProjectionInput, +): SiteProjection { + const nowMs = (input.now ?? new Date()).getTime(); + const startMs = new Date(input.startDate).getTime(); + const rawElapsedMs = nowMs - startMs; + const elapsedMs = Math.max(0, rawElapsedMs); + + const months = input.durationMonths; + const elapsedMonths = Math.min(months, elapsedMs / MS_PER_MONTH); + const elapsedPct = months > 0 ? (elapsedMonths / months) * 100 : 0; + const remainingMonths = Math.max(0, months - elapsedMonths); + const weeksActive = elapsedMs / MS_PER_WEEK; + + const remaining = Math.max(0, input.contractValue - input.totalSpent); + const burnPct = + input.contractValue > 0 ? (input.totalSpent / input.contractValue) * 100 : 0; + + const projectedTotal = + elapsedMonths > 0 ? input.totalSpent * (months / elapsedMonths) : 0; + const projectedProfit = input.contractValue - projectedTotal; + + let status: SiteStatus; + if (rawElapsedMs <= 0 && input.hasExplicitStart) status = "upcoming"; + else if (elapsedPct === 0) status = "just-started"; + else if (burnPct < elapsedPct * 0.95) status = "under"; + else if (burnPct > elapsedPct * 1.1) status = "over"; + else status = "on-track"; + + return { + months, + elapsedMonths, + elapsedPct, + remainingMonths, + weeksActive, + remaining, + burnPct, + projectedTotal, + projectedProfit, + status, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/metrics/site-projection.test.ts` +Expected: PASS, 12 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/metrics/site-projection.ts src/lib/metrics/site-projection.test.ts +git commit -m "feat(metrics): extract site projection and burn status" +``` + +--- + +### Task 6: Income vs expenses series + +Lifts `buildWeeklyIncomeVsExpenses`, `buildMonthlyIncomeVsExpenses` and `buildDailyFuelCost` from `sites/[id]/page.tsx:324-459` verbatim, swapping the local `RATE_PER_LITRE` for the shared constant and taking an injectable `now`. + +**Files:** +- Create: `src/lib/metrics/income-vs-expenses.ts` +- Test: `src/lib/metrics/income-vs-expenses.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +import { describe, it, expect } from "vitest"; +import { + buildWeeklyIncomeVsExpenses, + buildMonthlyIncomeVsExpenses, + buildDailyFuelCost, +} from "./income-vs-expenses"; +import { RATE_PER_LITRE } from "./constants"; +import type { EmployeeRateMap } from "./site-spend"; + +const NOW = new Date("2026-08-29T00:00:00.000Z"); + +const rates: EmployeeRateMap = new Map([ + ["e1", { rate: 100, site: "s1" }], + ["e3", { rate: 100, site: "OTHER" }], +]); + +const emptySeries = { + siteId: "s1", + fixedCosts: [], + timesheets: [], + employees: rates, + purchaseOrders: [], + fuelLogs: [], + income: [], + now: NOW, +}; + +describe("buildMonthlyIncomeVsExpenses", () => { + it("emits exactly monthsBack buckets, oldest first", () => { + const r = buildMonthlyIncomeVsExpenses({ ...emptySeries, monthsBack: 6 }); + expect(r).toHaveLength(6); + expect(r[0].month).toBe("2026-03"); + expect(r[5].month).toBe("2026-08"); + }); + + it("accrues fixed costs into the matching month", () => { + const r = buildMonthlyIncomeVsExpenses({ + ...emptySeries, + fixedCosts: [{ date_logged: "2026-08-10", cost: 500 }], + monthsBack: 6, + }); + expect(r[5].expenses).toBe(500); + }); + + it("accrues payroll only for employees on this site", () => { + const r = buildMonthlyIncomeVsExpenses({ + ...emptySeries, + timesheets: [ + { date_logged: "2026-08-10", hours_worked: 5, employee: "e1" }, + { date_logged: "2026-08-10", hours_worked: 5, employee: "e3" }, + ], + monthsBack: 6, + }); + expect(r[5].expenses).toBe(500); + }); + + it("dates purchase orders by `created`", () => { + const r = buildMonthlyIncomeVsExpenses({ + ...emptySeries, + purchaseOrders: [{ created: "2026-07-04", total_value: 900 }], + monthsBack: 6, + }); + expect(r[4].month).toBe("2026-07"); + expect(r[4].expenses).toBe(900); + }); + + it("costs fuel at RATE_PER_LITRE", () => { + const r = buildMonthlyIncomeVsExpenses({ + ...emptySeries, + fuelLogs: [{ date_logged: "2026-08-02", litres: 10 }], + monthsBack: 6, + }); + expect(r[5].expenses).toBeCloseTo(10 * RATE_PER_LITRE, 6); + }); + + it("accrues income and computes net", () => { + const r = buildMonthlyIncomeVsExpenses({ + ...emptySeries, + income: [{ paid_on: "2026-08-15", gross_amount: 1000 }], + fixedCosts: [{ date_logged: "2026-08-10", cost: 400 }], + monthsBack: 6, + }); + expect(r[5].income).toBe(1000); + expect(r[5].net).toBe(600); + }); + + it("ignores rows outside the window", () => { + const r = buildMonthlyIncomeVsExpenses({ + ...emptySeries, + fixedCosts: [{ date_logged: "2020-01-01", cost: 9999 }], + monthsBack: 6, + }); + expect(r.every((b) => b.expenses === 0)).toBe(true); + }); +}); + +describe("buildWeeklyIncomeVsExpenses", () => { + it("emits exactly weeksBack buckets", () => { + const r = buildWeeklyIncomeVsExpenses({ ...emptySeries, weeksBack: 8 }); + expect(r).toHaveLength(8); + }); + + it("accrues an in-window fixed cost and computes net", () => { + const r = buildWeeklyIncomeVsExpenses({ + ...emptySeries, + fixedCosts: [{ date_logged: "2026-08-27", cost: 250 }], + weeksBack: 8, + }); + const total = r.reduce((s, b) => s + b.expenses, 0); + expect(total).toBe(250); + expect(r.reduce((s, b) => s + b.net, 0)).toBe(-250); + }); +}); + +describe("buildDailyFuelCost", () => { + it("emits one bucket per day, oldest first", () => { + const r = buildDailyFuelCost([], 30, NOW); + expect(r).toHaveLength(30); + expect(r[29].date).toBe("2026-08-29"); + }); + + it("sums litres and cost for a matching day", () => { + const r = buildDailyFuelCost( + [{ date_logged: "2026-08-29T09:00:00Z", litres: 40 }], + 30, + NOW, + ); + expect(r[29].litres).toBe(40); + expect(r[29].cost).toBeCloseTo(40 * RATE_PER_LITRE, 6); + }); + + it("ignores days outside the window", () => { + const r = buildDailyFuelCost( + [{ date_logged: "2020-01-01", litres: 999 }], + 30, + NOW, + ); + expect(r.every((b) => b.litres === 0)).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/metrics/income-vs-expenses.test.ts` +Expected: FAIL — "Failed to resolve import ./income-vs-expenses". + +- [ ] **Step 3: Write the implementation** + +```typescript +/** + * Weekly / monthly income-vs-expense series and daily fuel cost. + * Lifted from dashboard/sites/[id]/page.tsx:324-459. + * + * `now` is injectable so the series are deterministic under test. + */ +import { RATE_PER_LITRE } from "./constants"; +import { isoWeekKey, monthKey } from "./duration"; +import type { EmployeeRateMap } from "./site-spend"; + +export interface SeriesFixedCost { + date_logged?: string; + cost?: number; +} +export interface SeriesTimesheet { + date_logged?: string; + hours_worked?: number; + employee: string; +} +export interface SeriesPurchaseOrder { + created?: string; + total_value?: number; +} +export interface SeriesFuelLog { + date_logged?: string; + litres?: number; +} +export interface SeriesIncome { + paid_on?: string; + period_end?: string; + gross_amount?: number; + status?: string; +} + +interface SeriesInputBase { + siteId: string; + fixedCosts: SeriesFixedCost[]; + timesheets: SeriesTimesheet[]; + employees: EmployeeRateMap; + purchaseOrders: SeriesPurchaseOrder[]; + fuelLogs: SeriesFuelLog[]; + income: SeriesIncome[]; + now?: Date; +} + +export interface WeeklyBucket { + week: string; + income: number; + expenses: number; + net: number; +} + +export interface MonthlyBucket { + month: string; + income: number; + expenses: number; + net: number; +} + +export interface DailyFuelBucket { + date: string; + litres: number; + cost: number; +} + +type Bucket = { income: number; expenses: number }; + +/** + * Walk every source into pre-seeded buckets keyed by `keyOf`. Rows whose + * key is not in the window are silently dropped, which is what the + * original page did. + */ +function accrueAll( + args: SeriesInputBase, + buckets: Map, + keyOf: (d: Date) => string, +): void { + const add = ( + date: string | undefined, + amount: number, + kind: "income" | "expenses", + ) => { + if (!date) return; + const b = buckets.get(keyOf(new Date(date))); + if (!b) return; + b[kind] += amount; + }; + + for (const c of args.fixedCosts) add(c.date_logged, c.cost ?? 0, "expenses"); + + for (const t of args.timesheets) { + const emp = args.employees.get(t.employee); + if (!emp || emp.site !== args.siteId) continue; + add(t.date_logged, (t.hours_worked ?? 0) * emp.rate, "expenses"); + } + + // POs have no date_logged — they are dated by record creation. + for (const p of args.purchaseOrders) add(p.created, p.total_value ?? 0, "expenses"); + + for (const f of args.fuelLogs) { + add(f.date_logged, (f.litres ?? 0) * RATE_PER_LITRE, "expenses"); + } + + // Paid claims land on paid_on; unpaid ones accrue at period_end. + for (const c of args.income) { + add(c.paid_on || c.period_end, c.gross_amount ?? 0, "income"); + } +} + +export function buildWeeklyIncomeVsExpenses( + args: SeriesInputBase & { weeksBack: number }, +): WeeklyBucket[] { + const now = args.now ?? new Date(); + const buckets = new Map(); + for (let i = args.weeksBack - 1; i >= 0; i--) { + const d = new Date(now); + d.setDate(d.getDate() - i * 7); + buckets.set(isoWeekKey(d), { income: 0, expenses: 0 }); + } + + accrueAll(args, buckets, isoWeekKey); + + return Array.from(buckets.entries()).map(([week, b]) => ({ + week, + income: b.income, + expenses: b.expenses, + net: b.income - b.expenses, + })); +} + +export function buildMonthlyIncomeVsExpenses( + args: SeriesInputBase & { monthsBack: number }, +): MonthlyBucket[] { + const now = args.now ?? new Date(); + const buckets = new Map(); + for (let i = args.monthsBack - 1; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + buckets.set(monthKey(d), { income: 0, expenses: 0 }); + } + + accrueAll(args, buckets, monthKey); + + return Array.from(buckets.entries()).map(([month, b]) => ({ + month, + income: b.income, + expenses: b.expenses, + net: b.income - b.expenses, + })); +} + +export function buildDailyFuelCost( + fuelLogs: SeriesFuelLog[], + days: number, + now?: Date, +): DailyFuelBucket[] { + const base = now ?? new Date(); + const buckets = new Map(); + for (let i = days - 1; i >= 0; i--) { + const d = new Date(base); + d.setDate(d.getDate() - i); + buckets.set(d.toISOString().slice(0, 10), { litres: 0, cost: 0 }); + } + + for (const f of fuelLogs) { + if (!f.date_logged) continue; + const b = buckets.get(f.date_logged.slice(0, 10)); + if (!b) continue; + b.litres += f.litres ?? 0; + b.cost += (f.litres ?? 0) * RATE_PER_LITRE; + } + + return Array.from(buckets.entries()).map(([date, b]) => ({ + date, + litres: b.litres, + cost: b.cost, + })); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/metrics/income-vs-expenses.test.ts` +Expected: PASS, 12 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/metrics/income-vs-expenses.ts src/lib/metrics/income-vs-expenses.test.ts +git commit -m "feat(metrics): extract income-vs-expenses and daily fuel series" +``` + +--- + +### Task 7: Barrel export + +**Files:** +- Create: `src/lib/metrics/index.ts` + +- [ ] **Step 1: Write the barrel** + +```typescript +/** + * Site metrics. Import from "@/lib/metrics" — this is the single + * surface the dashboard pages, the MCP server and the chat agent + * tool layer all consume, so every caller gets identical numbers. + */ +export * from "./constants"; +export * from "./duration"; +export * from "./site-start-date"; +export * from "./site-spend"; +export * from "./site-projection"; +export * from "./income-vs-expenses"; +``` + +- [ ] **Step 2: Verify the whole suite and types still pass** + +Run: `npx vitest run && npx tsc --noEmit` +Expected: all metrics tests PASS, tsc exits 0. + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/metrics/index.ts +git commit -m "feat(metrics): add barrel export" +``` + +--- + +### Task 8: Wire up the sites list page + +**Files:** +- Modify: `src/app/dashboard/sites/page.tsx` + +- [ ] **Step 1: Record the current output for comparison** + +With the dev server running (`npm run dev`), open `http://localhost:6004/dashboard/sites` and note, for two or three sites, the `spent`, `burnPct`, `projectedTotal` and `status` values shown. Keep these — Step 4 compares against them. + +- [ ] **Step 2: Replace the imports and per-site roll-up** + +Replace lines 1-9 (the import block) with: + +```typescript +import Link from "next/link"; +import { pbAdmin } from "@/lib/pb"; +import { type PnGItemLike } from "@/lib/partition-fixed-costs"; +import { + computeSiteProjection, + computeSiteSpend, + parseDurationMonths, + resolveSiteStartDate, + type EmployeeRateMap, +} from "@/lib/metrics"; +import { Topbar } from "@/components/layout/sidebar"; +import { SitesView, type SiteSummary } from "./sites-view"; +import { + AddSiteButton, + type SiteTemplateOption, +} from "@/components/site-form-dialog"; +``` + +Replace everything from line 32 (`// Per-site roll-ups`) through line 160 (the close of `sites.map`) with: + +```typescript + // Bucket per-site inputs once, then run the shared metrics functions + // per site so this page and the detail page agree by construction. + const fixedCostsBySite = new Map(); + const activityBySite = new Map(); + for (const c of fixedCosts) { + if (!c.site) continue; + const list = fixedCostsBySite.get(c.site) ?? []; + list.push({ + id: c.id, + cost: c.cost ?? 0, + is_recurring: c.is_recurring, + date_logged: c.date_logged, + }); + fixedCostsBySite.set(c.site, list); + + const act = activityBySite.get(c.site) ?? []; + act.push({ date_logged: c.date_logged }); + activityBySite.set(c.site, act); + } + + // employee id -> { rate, site } drives both payroll attribution and + // the timesheet-to-site join. + const employeeRates: EmployeeRateMap = new Map(); + for (const e of employees) { + employeeRates.set(e.id, { rate: e.rate_per_hour ?? 0, site: e.site, name: e.name }); + } + + const posBySite = new Map(); + for (const po of purchaseOrders) { + if (!po.site) continue; + const list = posBySite.get(po.site) ?? []; + list.push({ total_value: po.total_value }); + posBySite.set(po.site, list); + } + + const fuelBySite = new Map(); + for (const f of fuelLogs) { + if (!f.site) continue; + const list = fuelBySite.get(f.site) ?? []; + list.push({ litres: f.litres, date_logged: f.date_logged }); + fuelBySite.set(f.site, list); + } + + const summaries: SiteSummary[] = sites.map((s) => { + const siteFuel = fuelBySite.get(s.id) ?? []; + + const spend = computeSiteSpend({ + siteId: s.id, + fixedCosts: fixedCostsBySite.get(s.id) ?? [], + timesheets, + employeeRates, + purchaseOrders: posBySite.get(s.id) ?? [], + fuelLogs: siteFuel, + }); + + const start = resolveSiteStartDate({ + jobStartDate: s.job_start_date, + fixedCosts: activityBySite.get(s.id) ?? [], + fuelLogs: siteFuel, + }); + + const projection = computeSiteProjection({ + contractValue: s.contract_value ?? 0, + totalSpent: spend.totalSpent, + durationMonths: parseDurationMonths(s.duration), + startDate: start.startDate, + hasExplicitStart: start.source === "job_start_date", + }); + + return { + id: s.id, + projectCode: s.project_code, + name: s.name, + duration: s.duration, + contractValue: s.contract_value ?? 0, + spent: spend.totalSpent, + remaining: projection.remaining, + burnPct: projection.burnPct, + months: projection.months, + elapsedMonths: projection.elapsedMonths, + elapsedPct: projection.elapsedPct, + remainingMonths: projection.remainingMonths, + projectedTotal: projection.projectedTotal, + projectedProfit: projection.projectedProfit, + status: projection.status, + jobStartDate: start.startDate, + hasExplicitStart: start.source === "job_start_date", + }; + }); +``` + +Delete the now-unused local `parseDurationMonths` at the bottom of the file (lines 188-191), and delete the `fullEmployees` re-fetch (lines 70-83) — `employees` is now fetched with the `site` field, so the second round-trip is redundant. + +**Note on the four dropped fields.** The current code returns `fixed`, `payroll`, `material` and `fuel` on each summary (lines 144-147), but `SiteSummary` in `sites-view.tsx:21-41` declares none of them and no component reads them — they are computed and discarded. The replacement above omits them deliberately. If you later want the cost breakdown on the list cards, add the fields to `SiteSummary` first and source them from `spend.fixedSum + spend.oneOffSum`, `spend.payrollBurn`, `spend.materialSum`, `spend.fuelBurn`. + +- [ ] **Step 3: Widen the employees fetch to include `site` and `name`** + +The page currently fetches `fields: "id,rate_per_hour"` and then re-fetches for `site`. Change line 21-23 to a single fetch: + +```typescript + pb.collection("employees").getFullList({ + fields: "id,rate_per_hour,site,name", + }), +``` + +- [ ] **Step 4: Verify the page renders the expected numbers** + +Run: `npx tsc --noEmit` +Expected: exits 0. + +Reload `http://localhost:6004/dashboard/sites` and compare against Step 1: + +- **Sites with `job_start_date` set** — every value must be **identical**. This page already preferred `job_start_date`, so nothing may move. A change here means a mistake in the roll-up; stop and investigate. +- **Sites without `job_start_date`** — `elapsedMonths`, `projectedTotal`, `projectedProfit` and possibly `status` **will change**, and that is expected. The old fallback was the *latest* fixed-cost date (which shrinks elapsed time and inflates the projection); the agreed rule is the *earliest* activity date. Spot-check one such site: its `elapsedMonths` should now be **larger** and `projectedTotal` correspondingly **lower**. + +`spent` and `remaining` are time-independent and must be identical for **every** site, with or without `job_start_date`. If either moved, the spend roll-up is wrong — stop and fix it before touching the detail page. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/dashboard/sites/page.tsx +git commit -m "refactor(sites): use shared metrics for the sites list roll-up" +``` + +--- + +### Task 9: Wire up the site detail page + +This is where numbers legitimately change (Defect A + B). + +**Files:** +- Modify: `src/app/dashboard/sites/[id]/page.tsx` + +- [ ] **Step 1: Record the current output** + +Open `http://localhost:6004/dashboard/sites/` for a site that **has** `job_start_date` set and one that does **not**. Note `spent`, `projectedTotal`, `projectedProfit`, `status` for both. + +- [ ] **Step 2: Confirm the employees fetch carries `site`** + +No change to the timesheets fetch is needed. Timesheets have no `site` field, but line 40 already fetches employees with `fields: "id,rate_per_hour,site,name"`, so the employee→site join is available in memory and Step 3 filters through it. Do **not** add `expand: "employee"` to the timesheets query — it would duplicate data the employee map already has and slow the page down. + +Verify line 40 reads: + +```typescript + pb.collection("employees").getFullList({ fields: "id,rate_per_hour,site,name" }), +``` + +- [ ] **Step 3: Replace the roll-up block** + +Replace the import block (lines 1-13) with: + +```typescript +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ArrowLeft } from "lucide-react"; +import { pbAdmin } from "@/lib/pb"; +import { type PnGItemLike } from "@/lib/partition-fixed-costs"; +import { + buildDailyFuelCost, + buildMonthlyIncomeVsExpenses, + buildWeeklyIncomeVsExpenses, + computeSiteProjection, + computeSiteSpend, + parseDurationMonths, + resolveSiteStartDate, + DEFAULT_SITE_WEEKLY_HOURS, + WORKING_DAYS_PER_MONTH, + type EmployeeRateMap, +} from "@/lib/metrics"; +import { Topbar } from "@/components/layout/sidebar"; +import { Button } from "@/components/ui/button"; +import { EditSiteButton } from "@/components/site-form-dialog"; +import { + SiteDetailView, + type SiteDetailData, + type PnGItem, +} from "./site-detail-view"; +``` + +Delete the local `RATE_PER_LITRE` and `WORKING_DAYS_PER_MONTH` consts (lines 17-18). + +Replace lines 47-164 (from `const empById` through the `buildDailyFuelCost` call) with: + +```typescript + const employeeRates: EmployeeRateMap = new Map(); + for (const e of employees) { + employeeRates.set(e.id, { rate: e.rate_per_hour ?? 0, site: e.site, name: e.name }); + } + + // Only this site's timesheets may influence the start date. Payroll is + // attributed separately inside computeSiteSpend via employeeRates. + const siteTimesheets = timesheets.filter( + (t) => employeeRates.get(t.employee)?.site === id, + ); + + const spend = computeSiteSpend({ + siteId: id, + fixedCosts: fixedCosts.map((c) => ({ + id: c.id, + date_logged: c.date_logged, + description: c.description ?? "", + cost: c.cost ?? 0, + notes: c.notes ?? "", + is_recurring: c.is_recurring, + })), + timesheets, + employeeRates, + purchaseOrders, + fuelLogs, + }); + + const start = resolveSiteStartDate({ + jobStartDate: site.job_start_date, + fixedCosts: fixedCosts as { date_logged?: string }[], + timesheets: siteTimesheets as unknown as { date_logged?: string }[], + fuelLogs: fuelLogs as { date_logged?: string }[], + income: income as { paid_on?: string; period_end?: string }[], + }); + + const projection = computeSiteProjection({ + contractValue: site.contract_value ?? 0, + totalSpent: spend.totalSpent, + durationMonths: parseDurationMonths(site.duration), + startDate: start.startDate, + hasExplicitStart: start.source === "job_start_date", + }); + + const toPnGItem = (r: PnGItemLike): PnGItem => ({ + id: r.id, + date: r.date_logged ?? "", + description: r.description ?? "", + cost: r.cost ?? 0, + notes: r.notes ?? "", + dailyCost: (r.cost ?? 0) / WORKING_DAYS_PER_MONTH, + is_recurring: r.is_recurring !== false, + }); + + const monthlyItems: PnGItem[] = spend.monthlyItems.map(toPnGItem); + const oneOffItems: PnGItem[] = spend.oneOffItems.map(toPnGItem); + + const poList = purchaseOrders + .sort((a, b) => a.purchase_order_number.localeCompare(b.purchase_order_number)) + .map((p) => ({ + id: p.id, + poNumber: p.purchase_order_number, + supplier: p.supplier, + totalValue: p.total_value, + requiredByDate: p.required_by_date, + isVerified: !!p.is_verified, + })); + + const seriesArgs = { + siteId: id, + fixedCosts: fixedCosts as { date_logged?: string; cost?: number }[], + timesheets: timesheets as unknown as { + date_logged?: string; + hours_worked?: number; + employee: string; + }[], + employees: employeeRates, + purchaseOrders: purchaseOrders as { created?: string; total_value?: number }[], + fuelLogs: fuelLogs as { date_logged?: string; litres?: number }[], + income: income as { + paid_on?: string; + period_end?: string; + gross_amount?: number; + status?: string; + }[], + }; + + const weekly = buildWeeklyIncomeVsExpenses({ ...seriesArgs, weeksBack: 8 }); + const monthly = buildMonthlyIncomeVsExpenses({ ...seriesArgs, monthsBack: 6 }); + const dailyFuel = buildDailyFuelCost( + fuelLogs as { date_logged?: string; litres?: number }[], + 30, + ); +``` + +Update the plant-utilization block (lines 166-185) to use the shared constant and the projection's `weeksActive`: + +```typescript + const siteWeeklyHours = site.hours_per_week || DEFAULT_SITE_WEEKLY_HOURS; + const plantUtilization = plant + .filter((v) => (v.cost_per_hour ?? 0) > 0) + .map((v) => { + const reading = Number(v.standard_work_hours) || 0; + const weeklyAvg = + projection.weeksActive > 0 ? reading / projection.weeksActive : 0; + return { + id: v.id, + description: v.description, + qrCodeId: v.qr_code_id ?? "", + projectCode: v.project_code, + currentHours: reading, + costPerHour: v.cost_per_hour || 0, + weeklyAvgHours: weeklyAvg, + weeklyCapacity: siteWeeklyHours, + utilizationPct: siteWeeklyHours > 0 ? (weeklyAvg / siteWeeklyHours) * 100 : 0, + }; + }); +``` + +Update the `data` object (lines 203-239) to read from `spend`, `projection` and `start`: + +```typescript + const data: SiteDetailData = { + id: site.id, + projectCode: site.project_code, + name: site.name, + duration: site.duration, + startDate: start.startDate, + contractValue: site.contract_value ?? 0, + spent: spend.totalSpent, + fixedSum: spend.fixedSum, + payrollBurn: spend.payrollBurn, + materialSum: spend.materialSum, + fuelBurn: spend.fuelBurn, + remaining: projection.remaining, + burnPct: projection.burnPct, + months: projection.months, + elapsedMonths: projection.elapsedMonths, + elapsedPct: projection.elapsedPct, + remainingMonths: projection.remainingMonths, + projectedTotal: projection.projectedTotal, + projectedProfit: projection.projectedProfit, + status: projection.status, + monthlyItems, + oneOffItems, + oneOffSum: spend.oneOffSum, + poList, + fuelLitres: spend.fuelLitres, + fuelLogs: fuelLogs.length, + payrollByEmp: spend.payrollByEmployee + .slice(0, 5) + .map((p) => ({ name: p.name, hours: p.hours, cost: p.cost })), + weekly, + monthly, + dailyFuel, + plantUtilization, + incomeList, + siteWeeklyHours, + }; +``` + +Finally, delete all six now-dead local helpers at the bottom of the file (lines 275-459): `parseDurationMonths`, `pickStartDate`, `isoWeekKey`, `monthKey`, `expenseDate`, `buildWeeklyIncomeVsExpenses`, `buildMonthlyIncomeVsExpenses`, `buildDailyFuelCost`. + +- [ ] **Step 4: Teach the detail view about "upcoming"** + +`computeSiteProjection` can now return `"upcoming"` on this page, which it never could before. Without this step `STATUS_COPY[data.status]` returns `undefined` and the header badge crashes on `status.label`. + +Note there are now **three** definitions of a site-status union: `sites-view.tsx:16`, `site-detail-view.tsx:32`, and the canonical `SiteStatus` in `src/lib/metrics/site-projection.ts` (the only one that includes `"upcoming"`). Consolidating all three is out of scope; this step just widens the detail view to match. + +In `src/app/dashboard/sites/[id]/site-detail-view.tsx`, replace line 32: + +```typescript +export type SiteStatus = "just-started" | "under" | "on-track" | "over"; +``` + +with: + +```typescript +// Mirrors SiteStatus in @/lib/metrics/site-projection — the projection +// can return "upcoming" for sites whose job_start_date is in the future. +export type SiteStatus = + | "upcoming" + | "just-started" + | "under" + | "on-track" + | "over"; +``` + +Then add the missing `upcoming` entry to `STATUS_COPY` at line 143, copying the tone already used by the list page (`sites-view.tsx:47`) so the two surfaces look the same: + +```typescript +const STATUS_COPY: Record< + SiteStatus, + { label: string; tone: string; description: string } +> = { + upcoming: { + label: "Upcoming", + tone: "bg-blue-500/15 text-blue-700 dark:text-blue-400 border-blue-500/30", + description: "Project has not started yet", + }, + "just-started": { +``` + +The badge icon chain at lines 351-359 already falls through to a default for unmatched statuses, so `"upcoming"` renders with the label and no icon. No change needed there. + +- [ ] **Step 5: Verify** + +Run: `npx tsc --noEmit && npx vitest run` +Expected: tsc exits 0, all tests PASS. + +Reload both sites from Step 1 and compare: +- The site **without** `job_start_date`: numbers should be unchanged (it already used earliest-activity), except that its start date no longer picks up other sites' timesheets — a small shift here is the Defect B fix. +- The site **with** `job_start_date`: `projectedTotal` / `projectedProfit` **should** change, and should now **match the sites list page** for that same site. Verify that side by side — this is the acceptance criterion for Defect A. + +- [ ] **Step 6: Commit** + +```bash +git add src/app/dashboard/sites/[id]/page.tsx src/app/dashboard/sites/[id]/site-detail-view.tsx +git commit -m "refactor(sites): use shared metrics on the site detail page + +Detail and list pages now resolve start date identically, so projected +profit agrees between them. Timesheets are site-filtered before feeding +the start-date calculation." +``` + +--- + +### Task 10: De-duplicate RATE_PER_LITRE in the fuel pages + +**Files:** +- Modify: `src/app/dashboard/fuel/page.tsx:8-11` +- Modify: `src/app/dashboard/fuel/[vehicleId]/page.tsx:15` + +- [ ] **Step 1: Replace the constant in `fuel/page.tsx`** + +Delete the comment block and const at lines 8-11: + +```typescript +/** + * R 25.65 per litre — current pump price. Pull from a config collection + * ... + */ +const RATE_PER_LITRE = 25.65; +``` + +Add to the import block at the top of the file: + +```typescript +import { RATE_PER_LITRE } from "@/lib/metrics"; +``` + +- [ ] **Step 2: Replace the constant in `fuel/[vehicleId]/page.tsx`** + +Delete line 15 (`const RATE_PER_LITRE = 25.65;`) and add to the import block: + +```typescript +import { RATE_PER_LITRE } from "@/lib/metrics"; +``` + +- [ ] **Step 3: Confirm no hardcoded rate survives outside the constant** + +Run: `grep -rn "25\.65" src/` +Expected: exactly two kinds of hit — the definition in `src/lib/metrics/constants.ts`, and the two display strings in `site-detail-view.tsx` (lines ~905 and ~976). Those are user-facing copy; optionally interpolate `RATE_PER_LITRE` into them. **No other `const RATE_PER_LITRE = 25.65` should remain.** + +- [ ] **Step 4: Verify** + +Run: `npx tsc --noEmit && npx vitest run` +Expected: tsc exits 0, all tests PASS. + +Load `/dashboard/fuel` and a vehicle detail page; totals should be unchanged. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/dashboard/fuel/page.tsx src/app/dashboard/fuel/[vehicleId]/page.tsx +git commit -m "refactor(fuel): use the shared RATE_PER_LITRE constant" +``` + +--- + +### Task 11: Full verification + +- [ ] **Step 1: Run the whole suite** + +Run: `npx vitest run` +Expected: PASS. The metrics suites add 53 tests (duration 11, start-date 8, spend 10, projection 12, series 12); every pre-existing suite must still pass. + +- [ ] **Step 2: Typecheck** + +Run: `npm run typecheck` +Expected: exits 0. + +- [ ] **Step 3: Lint** + +Run: `npm run lint` +Expected: no new errors. Unused-import warnings here usually mean a leftover helper wasn't deleted in Task 8 or 9. + +- [ ] **Step 4: Production build** + +Run: `npm run build` +Expected: build succeeds. + +- [ ] **Step 5: Confirm the divergence is actually gone** + +For every site that has `job_start_date` set, open the sites list and that site's detail page side by side. `Projected total` and `Projected profit` must match. This is the acceptance criterion for the whole plan. + +- [ ] **Step 6: Confirm the dead code is gone** + +Run: `grep -rn "function parseDurationMonths\|function pickStartDate\|function isoWeekKey\|function monthKey" src/app/` +Expected: **no matches.** All four now live only in `src/lib/metrics/`. + +--- + +## Out of scope + +Deliberately excluded from this plan — track separately: + +- **Fuel attention roll-up** (~150 lines in `src/app/dashboard/page.tsx:104-235`) and **plant utilization**. Needed for "which vehicles need attention" via chat; extract in a follow-up using the same pattern. +- **`getList(1, 500)` truncation** on the overview page (`page.tsx:72-76`). Fixed costs and fuel logs are capped at 500 rows, so attention counts silently under-report once the table exceeds that. Real bug, unrelated to this extraction — worth its own fix. +- **`income_claims` is missing from `src/lib/pb-types.ts`** even though `sites/[id]/page.tsx:44` queries it. Add the interface for consistency. +- **Fuel rate history.** A config collection with effective dates was considered and rejected for now; past months are recosted at today's price. +- **Auth on API routes.** The prerequisite for exposing any of this to a chat agent, and a much larger piece of work. + +## What this unblocks + +Once merged, an agent tool can be a few lines: + +```typescript +import { computeSiteSpend, computeSiteProjection, resolveSiteStartDate, parseDurationMonths } from "@/lib/metrics"; +``` + +The model never does arithmetic — it calls a tool that returns numbers computed by the exact same code the dashboard renders. That is the property that makes the chat app trustworthy on money. diff --git a/scripts/__pycache__/pb-bootstrap-schema.cpython-314.pyc b/scripts/__pycache__/pb-bootstrap-schema.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4ac1e7330fb75f20adbe1fc43701ce5900a5a9b0 GIT binary patch literal 12099 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 b@yeEzJ>T$9c5k*V8_Zkx{DOLf)X@J6P=cT& literal 0 HcmV?d00001