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

196
src/lib/scoring.ts Normal file
View File

@@ -0,0 +1,196 @@
import type { Assessment, Candidate, Catalog, Weights } from "./types";
export const DEFAULT_WEIGHTS: Weights = {
capability: 35,
switchEase: 25,
timeToRoi: 25,
spend: 15,
};
const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
/** ROI speed as a 0–1 score: <=6 months is ideal, >=36 months scores zero. */
export function roiNorm(months: number) {
return clamp01(1 - (months - 6) / 30);
}
export type CandidateMetrics = {
annualSavings: number;
horizonGross: number;
horizonNet: number;
paybackMonths: number | null;
roiPct: number | null;
score: number;
wave: 1 | 2 | 3;
};
export type Rollup = {
metrics: Map<string, CandidateMetrics>;
included: Candidate[];
totalAnnualSpend: number;
addressableSpend: number;
totalAnnualSavings: number;
totalMigrationCost: number;
horizonGross: number;
horizonNet: number;
blendedPaybackMonths: number | null;
savingsPct: number;
avgScore: number;
toolsRetired: number;
vendorsRetired: number;
/** Tools the customer runs after consolidation: our distinct products plus anything left unmapped. */
toolsAfter: number;
/** Vendors after consolidation: us (if anything is mapped) plus the vendors of unmapped tools. */
vendorsAfter: number;
unmappedCount: number;
byWave: Record<1 | 2 | 3, Candidate[]>;
byBu: BuValue[];
byDomain: { domainId: string; name: string; savings: number; spend: number; count: number }[];
};
export type BuValue = {
buId: string; name: string; short: string; color: string;
savings: number; spend: number; migrationCost: number; horizonNet: number;
/** Incumbent lines this BU absorbs (a line mapped to two BUs counts for both). */
count: number;
productIds: string[];
};
export function scoreCandidate(
c: Candidate,
weights: Weights,
maxSpend: number,
horizonYears: number,
): CandidateMetrics {
const cap = clamp01((c.capability - 1) / 4);
const ease = clamp01((c.switchEase - 1) / 4);
const roi = roiNorm(c.timeToRoiMonths);
// Square-root scaling so a single very large line item does not swamp the rest.
const spendN = maxSpend > 0 ? clamp01(Math.sqrt(Math.max(0, c.annualSpend)) / Math.sqrt(maxSpend)) : 0;
const w = weights;
const wSum = w.capability + w.switchEase + w.timeToRoi + w.spend || 1;
const score =
(100 * (w.capability * cap + w.switchEase * ease + w.timeToRoi * roi + w.spend * spendN)) / wSum;
const annualSavings = (c.annualSpend * c.savingsPct) / 100;
const horizonGross = annualSavings * horizonYears;
const horizonNet = horizonGross - c.migrationCost;
const paybackMonths =
annualSavings > 0 ? (c.migrationCost / annualSavings) * 12 : c.migrationCost > 0 ? null : 0;
const roiPct = c.migrationCost > 0 ? (horizonNet / c.migrationCost) * 100 : null;
const wave: 1 | 2 | 3 = score >= 65 ? 1 : score >= 45 ? 2 : 3;
return { annualSavings, horizonGross, horizonNet, paybackMonths, roiPct, score, wave };
}
export function rollup(a: Assessment, catalog: Catalog): Rollup {
const included = a.candidates.filter((c) => c.included);
const maxSpend = Math.max(0, ...included.map((c) => c.annualSpend));
const metrics = new Map<string, CandidateMetrics>();
for (const c of a.candidates) {
metrics.set(c.id, scoreCandidate(c, a.weights, maxSpend, a.horizonYears));
}
const totalAnnualSpend = a.candidates.reduce((s, c) => s + c.annualSpend, 0);
const addressableSpend = included.reduce((s, c) => s + c.annualSpend, 0);
const totalAnnualSavings = included.reduce((s, c) => s + metrics.get(c.id)!.annualSavings, 0);
const totalMigrationCost = included.reduce((s, c) => s + c.migrationCost, 0);
const horizonGross = totalAnnualSavings * a.horizonYears;
const horizonNet = horizonGross - totalMigrationCost;
const blendedPaybackMonths =
totalAnnualSavings > 0 ? (totalMigrationCost / totalAnnualSavings) * 12 : null;
const avgScore = included.length
? included.reduce((s, c) => s + metrics.get(c.id)!.score, 0) / included.length
: 0;
const byWave: Record<1 | 2 | 3, Candidate[]> = { 1: [], 2: [], 3: [] };
for (const c of included) byWave[metrics.get(c.id)!.wave].push(c);
const prodById = new Map(catalog.products.map((p) => [p.id, p]));
// Savings are split evenly across the mapped products so BU credit sums to the total.
const buAgg = new Map<string, { savings: number; spend: number; migrationCost: number; count: number; products: Set<string> }>();
for (const c of included) {
const m = metrics.get(c.id)!;
const bus = [...new Set(c.productIds.map((id) => prodById.get(id)?.bu).filter(Boolean))] as string[];
if (!bus.length) continue;
for (const bu of bus) {
const e = buAgg.get(bu) ?? { savings: 0, spend: 0, migrationCost: 0, count: 0, products: new Set<string>() };
e.savings += m.annualSavings / bus.length;
e.spend += c.annualSpend / bus.length;
e.migrationCost += c.migrationCost / bus.length;
e.count += 1;
for (const id of c.productIds) if (prodById.get(id)?.bu === bu) e.products.add(id);
buAgg.set(bu, e);
}
}
const byBu: BuValue[] = catalog.businessUnits
.filter((b) => buAgg.has(b.id))
.map((b) => {
const e = buAgg.get(b.id)!;
return {
buId: b.id, name: b.name, short: b.short, color: b.color,
savings: e.savings, spend: e.spend, migrationCost: e.migrationCost,
horizonNet: e.savings * a.horizonYears - e.migrationCost,
count: e.count, productIds: [...e.products],
};
})
.sort((x, y) => y.savings - x.savings);
const mapped = included.filter((c) => c.productIds.length);
const unmapped = included.filter((c) => !c.productIds.length);
const vendorKey = (c: Candidate) => c.incumbentVendor.trim().toLowerCase();
const toolsAfter = new Set(mapped.flatMap((c) => c.productIds)).size + unmapped.length;
const vendorsAfter = (mapped.length ? 1 : 0) + new Set(unmapped.map(vendorKey).filter(Boolean)).size;
const domAgg = new Map<string, { savings: number; spend: number; count: number }>();
for (const c of included) {
const m = metrics.get(c.id)!;
const e = domAgg.get(c.domain) ?? { savings: 0, spend: 0, count: 0 };
e.savings += m.annualSavings;
e.spend += c.annualSpend;
e.count += 1;
domAgg.set(c.domain, e);
}
const byDomain = catalog.domains
.filter((d) => domAgg.has(d.id))
.map((d) => ({ domainId: d.id, name: d.name, ...domAgg.get(d.id)! }))
.sort((x, y) => y.savings - x.savings);
return {
metrics,
included,
totalAnnualSpend,
addressableSpend,
totalAnnualSavings,
totalMigrationCost,
horizonGross,
horizonNet,
blendedPaybackMonths,
savingsPct: addressableSpend > 0 ? (totalAnnualSavings / addressableSpend) * 100 : 0,
avgScore,
toolsRetired: included.length,
vendorsRetired: new Set(included.map((c) => c.incumbentVendor.trim().toLowerCase()).filter(Boolean)).size,
toolsAfter,
vendorsAfter,
unmappedCount: unmapped.length,
byWave,
byDomain,
byBu,
};
}
export const WAVE_LABEL: Record<1 | 2 | 3, string> = {
1: "Wave 1 — Quick wins",
2: "Wave 2 — Planned consolidation",
3: "Wave 3 — Strategic / longer horizon",
};
export const WAVE_BLURB: Record<1 | 2 | 3, string> = {
1: "Strong functional parity, low switching friction and fast payback. Start here.",
2: "Clear consolidation value with a manageable migration. Schedule against contract renewals.",
3: "Material savings but higher effort, lower parity or a longer payback. Sequence last or revisit scope.",
};