From 7bd6a7659fd13b63df550d51eb4fcb754e06fbc5 Mon Sep 17 00:00:00 2001 From: DEls Date: Sat, 29 Aug 2026 19:47:09 +0200 Subject: [PATCH] feat(metrics): extract site projection and burn status Run-rate extrapolation, remaining budget and the under/on-track/over ladder. The 'upcoming' status previously existed only on the list page and now applies to both surfaces. Co-Authored-By: Claude Opus 5 --- src/lib/metrics/site-projection.test.ts | 163 ++++++++++++++++++++++++ src/lib/metrics/site-projection.ts | 88 +++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 src/lib/metrics/site-projection.test.ts create mode 100644 src/lib/metrics/site-projection.ts diff --git a/src/lib/metrics/site-projection.test.ts b/src/lib/metrics/site-projection.test.ts new file mode 100644 index 0000000..76694c0 --- /dev/null +++ b/src/lib/metrics/site-projection.test.ts @@ -0,0 +1,163 @@ +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); + }); +}); \ No newline at end of file diff --git a/src/lib/metrics/site-projection.ts b/src/lib/metrics/site-projection.ts new file mode 100644 index 0000000..8634c63 --- /dev/null +++ b/src/lib/metrics/site-projection.ts @@ -0,0 +1,88 @@ +/** + * 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; + // Strictly-future start only. A job starting at this exact instant has + // elapsed 0 and falls through to "just-started", which the test suite pins. + 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, + }; +} \ No newline at end of file