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 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-08-29 19:47:09 +02:00
parent e0419f9ae9
commit 7bd6a7659f
2 changed files with 251 additions and 0 deletions

View file

@ -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);
});
});

View file

@ -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,
};
}