Initial commit: Converge portfolio rationalization app

Next.js app for mapping a customer's competitive tool landscape onto our
portfolio and building the consolidation business case: assessment
workbench, customer report and Word export, product catalog, competitive
knowledge base, dossier import, shareable landscape questionnaires with a
review inbox, tool-footprint and business-unit value analysis, and an
implementation timeline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 11:48:42 -04:00
commit d5c77543e8
64 changed files with 30861 additions and 0 deletions

170
src/lib/timeline.ts Normal file
View File

@@ -0,0 +1,170 @@
import type { Assessment, 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:
* 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 now and is flagged as needing a short 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 (security, infrastructure and ops
* migrations are run by different teams), with a set number in parallel.
* 3. 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 };
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;
annualSavings: number;
migrationCost: number;
anchor: "contract" | "sequenced";
/** Contract ends before the migration can finish. */
bridge: boolean;
};
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 savings in effect during each month, 0..span-1. */
ramp: number[];
span: number;
/** Savings actually 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 the headline uses. */
flatInHorizon: number;
bridgeCount: number;
unmappedCount: number;
};
const monthIndex = (start: Date, iso: string) => {
const d = new Date(`${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));
}
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 buildPlan(a: Assessment, r: Rollup, catalog: Catalog, today = new Date()): Plan {
const start = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() + 1, 1));
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 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; };
const items: (PlanItem & { duration: number; workstream: string })[] = [];
const base = (c: (typeof mapped)[number]) => {
const m = r.metrics.get(c.id)!;
const bu = buById.get(prodById.get(c.productIds[0])?.bu ?? "");
return {
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 ?? "",
workstream: bu?.id ?? "other",
wave: m.wave,
annualSavings: m.annualSavings,
migrationCost: c.migrationCost,
duration: DURATION_BY_EASE[Math.round(c.switchEase)] ?? 5,
};
};
// 1. Contract-anchored work is fixed first; it is dictated by the customer's paper, not our preference.
const flexible: typeof mapped = [];
for (const c of mapped) {
const ce = c.renewalDate ? monthIndex(start, c.renewalDate) : null;
if (ce === null) { flexible.push(c); continue; }
const b = base(c);
const due = ce + 1; // cut over by the end of the contract month
const bridge = due - b.duration < 0;
const s = bridge ? 0 : due - b.duration;
const e = s + b.duration;
occupy(b.workstream, s, e);
items.push({
...b, start: s, end: e, contractEnd: ce, anchor: "contract", bridge,
savingsFrom: Math.max(e, due),
});
}
// 2. The rest is sequenced into capacity: money first, then quick wins, then the biggest lines.
flexible.sort((x, y) => {
const mx = r.metrics.get(x.id)!, my = r.metrics.get(y.id)!;
return Number(my.annualSavings > 0) - Number(mx.annualSavings > 0)
|| mx.wave - my.wave || my.annualSavings - mx.annualSavings || my.score - mx.score;
});
for (const c of flexible) {
const b = base(c);
let s = WAVE_OFFSET[b.wave];
while (!fits(b.workstream, s, s + b.duration) && s < 240) s++;
occupy(b.workstream, s, s + b.duration);
items.push({ ...b, start: s, end: s + b.duration, contractEnd: null, anchor: "sequenced", bridge: false, savingsFrom: s + b.duration });
}
items.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;
// duration and workstream were only needed for scheduling.
const clean: PlanItem[] = items.map(({ duration: _d, workstream: _w, ...rest }) => rest);
return {
startIso, items: clean, completion, fullRunRate, ramp: ramp.slice(0, span), span,
realizedInHorizon, flatInHorizon,
bridgeCount: clean.filter((i) => i.bridge).length,
unmappedCount: r.included.length - mapped.length,
};
}