Files
Converge/src/lib/scoring.ts
Chris Olson d5c77543e8 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>
2026-09-24 11:48:42 -04:00

197 lines
7.4 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, 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.",
};