diff --git a/RAW_DOCS/Plant_Hours_Template_1.xlsx b/RAW_DOCS/Plant_Hours_Template_1.xlsx new file mode 100644 index 0000000..41192a1 Binary files /dev/null and b/RAW_DOCS/Plant_Hours_Template_1.xlsx differ 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 0000000..4ac1e73 Binary files /dev/null and b/scripts/__pycache__/pb-bootstrap-schema.cpython-314.pyc differ