Files
Converge/src/lib/timeline.ts

241 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Assessment, Candidate, Catalog } from "./types";
import type { Rollup } from "./scoring";
/**
* Implementation plan: turns the scored candidates into a dated project
* sequence. Positions are in whole months from the plan start (month 0 is the
* first full month after today), and a project occupies [start, end).
*
* Rules, in order:
* 0. A seller's pinned start and duration always win.
* 1. A tool with a known contract end is scheduled to cut over by that date,
* so the customer does not renew it. If there is no longer time to do so,
* it starts as early as it can and is flagged as needing a bridge extension.
* 2. Everything else is sequenced into the earliest month its workstream has
* capacity: lines with a known saving before those without (spend not yet
* captured), then by wave (quick wins first), then by size of saving.
* Each business unit is its own workstream, with a set number in parallel.
* 3. "Starts after" dependencies hold for everything that is not pinned.
* 4. Savings start at cutover, or at contract end if that comes later —
* the customer keeps paying for a contract until it expires.
*/
/** Migration length in months, by ease-of-switching score (5 = trivial). */
export const DURATION_BY_EASE: Record<number, number> = { 5: 2, 4: 3, 3: 5, 2: 8, 1: 12 };
/** Default migrations each workstream runs at once. */
export const DEFAULT_PARALLEL = 2;
/** Earliest month each wave may start. */
const WAVE_OFFSET: Record<1 | 2 | 3, number> = { 1: 0, 2: 3, 3: 6 };
const MAX_MONTH = 240;
export type PlanItem = {
id: string;
label: string;
products: string;
color: string;
bu: string;
wave: 1 | 2 | 3;
start: number;
end: number;
/** Month the contract runs out (the bar position is the end of that month), null if unknown. */
contractEnd: number | null;
savingsFrom: number;
/** Net annual benefit of the line (after its share of our subscription). */
annualSavings: number;
/** Cost removed from the customer each year once retired. */
grossRemoved: number;
/** One-time cost: migration plus attributed services. */
migrationCost: number;
anchor: "contract" | "sequenced" | "pinned";
/** Contract ends before the migration can finish. */
bridge: boolean;
/** A pinned start sits before a predecessor finishes. */
conflict: boolean;
/** Seller has overridden start, duration or dependencies. */
edited: boolean;
after: string[];
};
export type Plan = {
/** ISO date of month 0 (first of the month). */
startIso: string;
items: PlanItem[];
/** Month the last project completes. */
completion: number;
/** Month full run-rate savings are reached. */
fullRunRate: number;
/** Annual run-rate net savings in effect during each month, 0..span-1. */
ramp: number[];
span: number;
/** Net savings realised inside the planning horizon once ramp-up is counted. */
realizedInHorizon: number;
/** The same horizon if every saving started on day one — the flat model. */
flatInHorizon: number;
bridgeCount: number;
conflictCount: number;
unmappedCount: number;
};
export function planStart(today = new Date()) {
return new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() + 1, 1));
}
const monthIndex = (start: Date, iso: string) => {
const d = new Date(`${iso.length === 7 ? `${iso}-01` : iso}T00:00:00Z`);
if (isNaN(+d)) return null;
return (d.getUTCFullYear() - start.getUTCFullYear()) * 12 + (d.getUTCMonth() - start.getUTCMonth());
};
export function monthDate(startIso: string, n: number) {
const d = new Date(`${startIso}T00:00:00Z`);
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + n, 1));
}
/** Month n as yyyy-mm, the form a pinned start is stored in. */
export function monthKey(startIso: string, n: number) {
return monthDate(startIso, n).toISOString().slice(0, 7);
}
export function monthLabel(startIso: string, n: number, long = false) {
return monthDate(startIso, n).toLocaleDateString("en-US", { month: long ? "long" : "short", year: "numeric", timeZone: "UTC" });
}
export function quarterLabel(startIso: string, n: number) {
const d = monthDate(startIso, n);
return `Q${Math.floor(d.getUTCMonth() / 3) + 1} ${d.getUTCFullYear()}`;
}
export function defaultDuration(c: Pick<Candidate, "switchEase">) {
return DURATION_BY_EASE[Math.round(c.switchEase)] ?? 5;
}
export function buildPlan(a: Assessment, r: Rollup, catalog: Catalog, today = new Date()): Plan {
const start = planStart(today);
const startIso = start.toISOString().slice(0, 10);
const prodById = new Map(catalog.products.map((p) => [p.id, p]));
const buById = new Map(catalog.businessUnits.map((b) => [b.id, b]));
const mapped = r.included.filter((c) => c.productIds.length);
const mappedIds = new Set(mapped.map((c) => c.id));
const parallel = Math.max(1, Math.round(a.planParallel ?? DEFAULT_PARALLEL));
const load = new Map<string, number[]>();
const track = (ws: string) => { if (!load.has(ws)) load.set(ws, []); return load.get(ws)!; };
const occupy = (ws: string, s: number, e: number) => { const l = track(ws); for (let m = s; m < e; m++) l[m] = (l[m] ?? 0) + 1; };
const fits = (ws: string, s: number, e: number) => { const l = track(ws); for (let m = s; m < e; m++) if ((l[m] ?? 0) >= parallel) return false; return true; };
type Work = {
c: Candidate; ws: string; duration: number; after: string[];
due: number | null; pinned: number | null;
base: Omit<PlanItem, "start" | "end" | "savingsFrom" | "anchor" | "bridge" | "conflict">;
};
const work: Work[] = mapped.map((c) => {
const m = r.metrics.get(c.id)!;
const bu = buById.get(prodById.get(c.productIds[0])?.bu ?? "");
const ce = c.renewalDate ? monthIndex(start, c.renewalDate) : null;
const pinnedRaw = c.plan?.start ? monthIndex(start, c.plan.start) : null;
const after = (c.plan?.after ?? []).filter((id) => id !== c.id && mappedIds.has(id));
return {
c,
ws: bu?.id ?? "other",
duration: Math.max(1, Math.round(c.plan?.duration ?? defaultDuration(c))),
after,
due: ce === null ? null : ce + 1, // cut over by the end of the contract month
pinned: pinnedRaw === null ? null : Math.max(0, pinnedRaw),
base: {
id: c.id,
label: `${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "Untitled",
products: c.productIds.map((id) => prodById.get(id)?.name ?? id).join(", "),
color: bu?.color ?? "var(--text-muted)",
bu: bu?.short ?? "",
wave: m.wave,
contractEnd: ce,
annualSavings: m.annualSavings,
grossRemoved: m.grossRemoved,
migrationCost: m.oneTime,
edited: !!(c.plan?.start || c.plan?.duration || c.plan?.after?.length),
after,
},
};
});
const placed = new Map<string, PlanItem>();
const place = (w: Work, s: number, anchor: PlanItem["anchor"], bridge: boolean) => {
const e = s + w.duration;
occupy(w.ws, s, e);
placed.set(w.c.id, {
...w.base, start: s, end: e, anchor, bridge, conflict: false,
savingsFrom: w.due === null ? e : Math.max(e, w.due),
});
};
const depsEnd = (w: Work) => Math.max(0, ...w.after.map((id) => placed.get(id)?.end ?? 0));
const depsReady = (w: Work) => w.after.every((id) => placed.has(id));
// 0. Pinned work is fixed first; it is the seller's decision.
for (const w of work.filter((x) => x.pinned !== null)) {
place(w, w.pinned!, "pinned", w.due !== null && w.pinned! + w.duration > w.due);
}
// 1–2. Contract-anchored work by due date, then the rest by value. A line waits
// until everything it depends on is placed; a dependency cycle is broken by
// ignoring the dependencies of whatever is left.
const metric = (w: Work) => r.metrics.get(w.c.id)!;
const queue = work.filter((w) => w.pinned === null).sort((x, y) => {
if ((x.due === null) !== (y.due === null)) return x.due === null ? 1 : -1;
if (x.due !== null && y.due !== null) return x.due - y.due;
const mx = metric(x), my = metric(y);
return Number(my.annualSavings > 0) - Number(mx.annualSavings > 0)
|| mx.wave - my.wave || my.annualSavings - mx.annualSavings || my.score - mx.score;
});
let ignoreDeps = false;
while (queue.length) {
const i = queue.findIndex((w) => ignoreDeps || depsReady(w));
if (i === -1) { ignoreDeps = true; continue; }
const w = queue.splice(i, 1)[0];
const earliest = ignoreDeps ? 0 : depsEnd(w);
if (w.due !== null) {
const ideal = w.due - w.duration;
if (ideal >= earliest) {
place(w, ideal, "contract", false);
} else {
let s = earliest;
while (!fits(w.ws, s, s + w.duration) && s < MAX_MONTH) s++;
place(w, s, "contract", s + w.duration > w.due);
}
continue;
}
let s = Math.max(earliest, WAVE_OFFSET[metric(w).wave]);
while (!fits(w.ws, s, s + w.duration) && s < MAX_MONTH) s++;
place(w, s, "sequenced", false);
}
// A pinned line that starts before a predecessor finishes is flagged, not moved.
for (const w of work) {
const it = placed.get(w.c.id)!;
if (it.anchor === "pinned" && w.after.some((id) => (placed.get(id)?.end ?? 0) > it.start)) it.conflict = true;
}
const items = [...placed.values()].sort((x, y) => x.start - y.start || x.end - y.end || y.annualSavings - x.annualSavings);
const completion = Math.max(0, ...items.map((i) => i.end));
const fullRunRate = Math.max(0, ...items.map((i) => i.savingsFrom));
const horizon = a.horizonYears * 12;
const span = Math.max(completion, fullRunRate, 1) + 2;
const ramp = Array.from({ length: Math.max(span, horizon) }, (_, m) =>
items.reduce((s, i) => s + (m >= i.savingsFrom ? i.annualSavings : 0), 0));
const realizedInHorizon = ramp.slice(0, horizon).reduce((s, v) => s + v / 12, 0);
const flatInHorizon = items.reduce((s, i) => s + i.annualSavings, 0) * a.horizonYears;
return {
startIso, items, completion, fullRunRate, ramp: ramp.slice(0, span), span,
realizedInHorizon, flatInHorizon,
bridgeCount: items.filter((i) => i.bridge).length,
conflictCount: items.filter((i) => i.conflict).length,
unmappedCount: r.included.length - mapped.length,
};
}