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 = { 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) { 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(); 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; }; 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(); 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, }; }