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:
parent
e0419f9ae9
commit
7bd6a7659f
2 changed files with 251 additions and 0 deletions
163
src/lib/metrics/site-projection.test.ts
Normal file
163
src/lib/metrics/site-projection.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
88
src/lib/metrics/site-projection.ts
Normal file
88
src/lib/metrics/site-projection.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue