/** * Berth recommender (plan §4.4 + §13). * * Pure SQL ranking - no AI. The single CTE chain: * * 1. interest_input - the desired dimensions + port for this interest * 2. feasible - berths in the port that can fit the yacht * (length/width/draft >= desired) AND don't exceed * the configured max-oversize percentage * 3. tier_inputs - per-berth aggregates over interest_berths + * interests: active count, lost count, max stage * among active interests, latest fall-through * timestamp, total historical interest count, * EOI-signed count * 4. classified - assign tier A/B/C/D * * The feasible berths are returned to JavaScript for fit-score + heat * calculation - keeping that out of SQL lets per-port admins tune the * heat weights without a code deploy. * * Heat scoring (only relevant for tier B; berths surface back into the * recommender pool after a fall-through): * - recency : how recently did the last interest fall through * - furthest stage : how close did the prior interest get to closing * - historical interest : how often does this berth attract interest * - historical EOI count : how often does interest convert to EOI * * Per-port settings (defaults below) drive the cap, fall-through policy, * heat weights, and tier-D visibility. */ import { and, eq, inArray, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { systemSettings } from '@/lib/db/schema/system'; import { interests } from '@/lib/db/schema/interests'; import { yachts } from '@/lib/db/schema/yachts'; import { CodedError } from '@/lib/errors'; // ─── Settings ────────────────────────────────────────────────────────────── export interface RecommenderSettings { maxOversizePct: number; topNDefault: number; fallthroughPolicy: 'immediate_with_heat' | 'cooldown' | 'never_auto_recommend'; fallthroughCooldownDays: number; heatWeightRecency: number; heatWeightFurthestStage: number; heatWeightInterestCount: number; heatWeightEoiCount: number; tierLadderHideLateStage: boolean; } export const DEFAULT_RECOMMENDER_SETTINGS: RecommenderSettings = { maxOversizePct: 30, topNDefault: 8, fallthroughPolicy: 'immediate_with_heat', fallthroughCooldownDays: 30, heatWeightRecency: 30, heatWeightFurthestStage: 40, heatWeightInterestCount: 15, heatWeightEoiCount: 15, tierLadderHideLateStage: true, }; const SETTINGS_KEYS = { maxOversizePct: 'recommender_max_oversize_pct', topNDefault: 'recommender_top_n_default', fallthroughPolicy: 'fallthrough_policy', fallthroughCooldownDays: 'fallthrough_cooldown_days', heatWeightRecency: 'heat_weight_recency', heatWeightFurthestStage: 'heat_weight_furthest_stage', heatWeightInterestCount: 'heat_weight_interest_count', heatWeightEoiCount: 'heat_weight_eoi_count', tierLadderHideLateStage: 'tier_ladder_hide_late_stage', } as const; /** * Reads recommender settings for the port, layered over the defaults. * Per-port row wins; null portId row is the global fallback; defaults * cover anything still missing. */ export async function loadRecommenderSettings(portId: string): Promise { const rows = await db .select({ key: systemSettings.key, value: systemSettings.value, portId: systemSettings.portId }) .from(systemSettings) .where( and( inArray(systemSettings.key, Object.values(SETTINGS_KEYS)), sql`(${systemSettings.portId} = ${portId} OR ${systemSettings.portId} IS NULL)`, ), ); const portRows = new Map(); const globalRows = new Map(); for (const r of rows) { if (r.portId === portId) portRows.set(r.key, r.value); else if (r.portId === null) globalRows.set(r.key, r.value); } const pick = (key: string, parse: (v: unknown) => T | null, fallback: T): T => { const portVal = portRows.has(key) ? parse(portRows.get(key)) : null; if (portVal !== null) return portVal; const globalVal = globalRows.has(key) ? parse(globalRows.get(key)) : null; if (globalVal !== null) return globalVal; return fallback; }; const asNumber = (v: unknown): number | null => { if (v === null || v === undefined) return null; if (typeof v === 'number') return Number.isFinite(v) ? v : null; if (typeof v === 'string') { const n = parseFloat(v); return Number.isFinite(n) ? n : null; } return null; }; const asBool = (v: unknown): boolean | null => { if (typeof v === 'boolean') return v; // Some admin UIs (or older settings rows) persist booleans as the // strings "true" / "false" inside the JSONB blob. Without this // tolerant parse, a per-port override quietly falls through to the // default and the admin's tuning has no effect. if (typeof v === 'string') { if (v === 'true') return true; if (v === 'false') return false; } return null; }; const asPolicy = (v: unknown): RecommenderSettings['fallthroughPolicy'] | null => { if (v === 'immediate_with_heat' || v === 'cooldown' || v === 'never_auto_recommend') { return v; } return null; }; return { maxOversizePct: pick( SETTINGS_KEYS.maxOversizePct, asNumber, DEFAULT_RECOMMENDER_SETTINGS.maxOversizePct, ), topNDefault: pick( SETTINGS_KEYS.topNDefault, asNumber, DEFAULT_RECOMMENDER_SETTINGS.topNDefault, ), fallthroughPolicy: pick( SETTINGS_KEYS.fallthroughPolicy, asPolicy, DEFAULT_RECOMMENDER_SETTINGS.fallthroughPolicy, ), fallthroughCooldownDays: pick( SETTINGS_KEYS.fallthroughCooldownDays, asNumber, DEFAULT_RECOMMENDER_SETTINGS.fallthroughCooldownDays, ), heatWeightRecency: pick( SETTINGS_KEYS.heatWeightRecency, asNumber, DEFAULT_RECOMMENDER_SETTINGS.heatWeightRecency, ), heatWeightFurthestStage: pick( SETTINGS_KEYS.heatWeightFurthestStage, asNumber, DEFAULT_RECOMMENDER_SETTINGS.heatWeightFurthestStage, ), heatWeightInterestCount: pick( SETTINGS_KEYS.heatWeightInterestCount, asNumber, DEFAULT_RECOMMENDER_SETTINGS.heatWeightInterestCount, ), heatWeightEoiCount: pick( SETTINGS_KEYS.heatWeightEoiCount, asNumber, DEFAULT_RECOMMENDER_SETTINGS.heatWeightEoiCount, ), tierLadderHideLateStage: pick( SETTINGS_KEYS.tierLadderHideLateStage, asBool, DEFAULT_RECOMMENDER_SETTINGS.tierLadderHideLateStage, ), }; } // ─── Tier mapping ────────────────────────────────────────────────────────── // L-001: modern 7-stage ranks own the canonical positions; legacy 9-stage // keys map to their post-refactor equivalents so historical interests in // the recommender stage-aware queries continue to rank correctly. const STAGE_ORDER: Record = { // modern enquiry: 1, qualified: 2, nurturing: 2, eoi: 3, reservation: 4, deposit_paid: 5, contract: 6, // legacy aliases open: 1, details_sent: 1, in_communication: 2, eoi_sent: 3, eoi_signed: 3, deposit_10pct: 5, contract_sent: 6, contract_signed: 6, completed: 6, }; /** Stage at which a berth is "in late stage" (Tier D when active). */ const LATE_STAGE_THRESHOLD = STAGE_ORDER.deposit_paid!; // 5 export type Tier = 'A' | 'B' | 'C' | 'D'; interface TierInputs { activeInterestCount: number; lostCount: number; maxActiveStage: number; /** Berth's status column. Reconciles against the interest_berths * aggregates: a berth flagged "Under Offer" or "Sold" via the * status column alone (admin-set, NocoDB import, or a stale row * with no live interest_berths entry) shouldn't fall into Tier A. * Optional for backcompat — pure aggregate-based callers still * classify correctly when this is undefined. */ status?: string; } export function classifyTier(t: TierInputs): Tier { // Berth status overrides the aggregate path. A sold berth is // effectively closed — treat it as late stage. An Under Offer // berth has at least one party engaged even if interest_berths // doesn't echo them (e.g. admin manually flipped status). Both // collapse the "Open · Under Offer" contradiction surfaced in UAT // 2026-05-26. Sold > UnderOffer > active interest aggregates. const normStatus = (t.status ?? '').toLowerCase(); if (normStatus === 'sold') return 'D'; if (t.activeInterestCount > 0 && t.maxActiveStage >= LATE_STAGE_THRESHOLD) return 'D'; if (normStatus === 'under offer' || normStatus === 'under_offer') { return t.activeInterestCount > 0 ? 'C' : 'C'; } if (t.activeInterestCount > 0) return 'C'; if (t.lostCount > 0) return 'B'; return 'A'; } // ─── Heat scoring ────────────────────────────────────────────────────────── interface HeatInputs { latestFallthroughAt: Date | null; totalInterestCount: number; eoiSignedCount: number; /** Stage code at the time of the most recent fall-through. */ fallthroughMaxStage: number; } export interface HeatBreakdown { recency: number; furthestStage: number; interestCount: number; eoiCount: number; total: number; } /** * 0..100 heat score with per-port-tunable weights. Each component is * normalized 0..1 before being multiplied by its weight; the totals sum * to 100 by construction (the four weights are expected to sum to 100, * but the function clamps regardless so admin-tuning errors don't * produce out-of-range scores). */ export function computeHeat( inputs: HeatInputs, weights: Pick< RecommenderSettings, | 'heatWeightRecency' | 'heatWeightFurthestStage' | 'heatWeightInterestCount' | 'heatWeightEoiCount' >, now: Date = new Date(), ): HeatBreakdown { const weightSum = weights.heatWeightRecency + weights.heatWeightFurthestStage + weights.heatWeightInterestCount + weights.heatWeightEoiCount; const norm = weightSum > 0 ? 100 / weightSum : 0; // 1.0 if fallthrough <= 30 days, decays linearly to 0 at 365 days. let recency = 0; if (inputs.latestFallthroughAt) { const ageDays = (now.getTime() - inputs.latestFallthroughAt.getTime()) / 86_400_000; if (ageDays <= 30) recency = 1; else if (ageDays >= 365) recency = 0; else recency = 1 - (ageDays - 30) / (365 - 30); } // 0 if max stage is open; 1 at signed/deposit/contract. const furthestStage = inputs.fallthroughMaxStage <= 1 ? 0 : Math.min(1, (inputs.fallthroughMaxStage - 1) / (LATE_STAGE_THRESHOLD - 1)); // 0 at 0 interests, 1 at 5+ interests. const interestCount = Math.min(1, inputs.totalInterestCount / 5); // 0 at 0 EOIs, 1 at 3+ EOIs. const eoiCount = Math.min(1, inputs.eoiSignedCount / 3); const r = recency * weights.heatWeightRecency * norm; const f = furthestStage * weights.heatWeightFurthestStage * norm; const ic = interestCount * weights.heatWeightInterestCount * norm; const ec = eoiCount * weights.heatWeightEoiCount * norm; const total = Math.max(0, Math.min(100, r + f + ic + ec)); return { recency: Math.round(r * 100) / 100, furthestStage: Math.round(f * 100) / 100, interestCount: Math.round(ic * 100) / 100, eoiCount: Math.round(ec * 100) / 100, total: Math.round(total * 100) / 100, }; } // ─── Recommend ──────────────────────────────────────────────────────────── export interface RecommendBerthsArgs { interestId: string; portId: string; /** Override the per-port topNDefault. */ topN?: number; /** Override the per-port maxOversizePct. */ maxOversizePct?: number; /** Show late-stage (Tier D) berths even when the per-port setting hides them. */ showLateStage?: boolean; /** Optional rep-supplied amenity filters. */ amenityFilters?: { minPowerCapacityKw?: number; requiredVoltage?: number; requiredAccess?: string; requiredMooringType?: string; requiredCleatCapacity?: string; }; } export interface Recommendation { berthId: string; mooringNumber: string; area: string | null; tier: Tier; fitScore: number; sizeBufferPct: number | null; heat: HeatBreakdown | null; reasons: { dimensional: string; pipeline: string; amenities?: string; heat?: string; }; lengthFt: number | null; widthFt: number | null; draftFt: number | null; status: string; amenities: { powerCapacity: number | null; voltage: number | null; access: string | null; mooringType: string | null; cleatCapacity: string | null; }; } interface RawRow extends Record { berthId: string; mooringNumber: string; area: string | null; status: string; lengthFt: string | null; widthFt: string | null; draftFt: string | null; powerCapacity: string | null; voltage: string | null; access: string | null; mooringType: string | null; cleatCapacity: string | null; activeInterestCount: number; lostCount: number; maxActiveStage: number; latestFallthroughAt: Date | null; fallthroughMaxStage: number; totalInterestCount: number; eoiSignedCount: number; } interface InterestInput { desiredLengthFt: number | null; desiredWidthFt: number | null; desiredDraftFt: number | null; portId: string; /** Audit/debug aid — which dimension set the predicates were built * from. Set by `loadInterestInput` based on the dual-source toggle * + yacht link. Not consumed by SQL; surfaced in tests and trace * logs only. */ dimensionsSource: 'interest' | 'yacht'; } async function loadInterestInput(interestId: string): Promise { const [row] = await db .select({ desiredLengthFt: interests.desiredLengthFt, desiredWidthFt: interests.desiredWidthFt, desiredDraftFt: interests.desiredDraftFt, portId: interests.portId, useYachtDimensions: interests.useYachtDimensions, yachtId: interests.yachtId, }) .from(interests) .where(eq(interests.id, interestId)) .limit(1); if (!row) return null; const toNum = (v: string | null): number | null => { if (v === null) return null; const n = parseFloat(v); return Number.isFinite(n) ? n : null; }; // Dual-source resolution: when the toggle is on AND a yacht is // linked AND that yacht carries dimension data, the recommender // uses the yacht's dims. Falls back to the rep-entered desired* // columns whenever any of those preconditions fail. if (row.useYachtDimensions && row.yachtId) { const [yachtRow] = await db .select({ lengthFt: yachts.lengthFt, widthFt: yachts.widthFt, draftFt: yachts.draftFt, }) .from(yachts) .where(eq(yachts.id, row.yachtId)) .limit(1); if (yachtRow) { const yLen = toNum(yachtRow.lengthFt); const yWid = toNum(yachtRow.widthFt); const yDrft = toNum(yachtRow.draftFt); // Only switch to yacht dims when at least one yacht measurement // is present — otherwise we'd silently downgrade an interest // with desired dims set to "no dims at all". if (yLen !== null || yWid !== null || yDrft !== null) { return { desiredLengthFt: yLen, desiredWidthFt: yWid, desiredDraftFt: yDrft, portId: row.portId, dimensionsSource: 'yacht', }; } } } return { desiredLengthFt: toNum(row.desiredLengthFt), desiredWidthFt: toNum(row.desiredWidthFt), desiredDraftFt: toNum(row.desiredDraftFt), portId: row.portId, dimensionsSource: 'interest', }; } /** * Run the recommender. Returns ranked recommendations top-N. Multi-port * isolation is enforced both in the CTE (`b.port_id = $portId`) and via * the interest's own port_id - cross-port queries fail explicitly * rather than leak (§14.10 critical). */ export async function recommendBerths(args: RecommendBerthsArgs): Promise { const settings = await loadRecommenderSettings(args.portId); const interestInput = await loadInterestInput(args.interestId); if (!interestInput) return []; if (interestInput.portId !== args.portId) { // Defensive: caller passed a port that doesn't own this interest. throw new CodedError('RECOMMENDER_INTEREST_PORT_MISMATCH', { internalMessage: `interest ${args.interestId} belongs to port ${interestInput.portId}, not ${args.portId}`, }); } const oversizePct = args.maxOversizePct ?? settings.maxOversizePct; const topN = args.topN ?? settings.topNDefault; const showLateStage = args.showLateStage ?? !settings.tierLadderHideLateStage; const oversizeMultiplier = 1 + oversizePct / 100; const predicates: ReturnType[] = [ sql`b.port_id = ${args.portId}`, sql`b.status <> 'sold'`, ]; if (interestInput.desiredLengthFt !== null) { predicates.push(sql`b.length_ft::numeric >= ${interestInput.desiredLengthFt}`); predicates.push( sql`b.length_ft::numeric <= ${interestInput.desiredLengthFt}::numeric * ${oversizeMultiplier}::numeric`, ); } else if (interestInput.desiredWidthFt !== null) { // Width-only feasibility: cap the length using a generous L/W ratio // so the recommender doesn't surface a 200 ft berth for a 30 ft beam // request. Plan §4.4 promised an upper bound; without this branch the // null-length path skipped the cap entirely. predicates.push( sql`b.length_ft::numeric <= ${interestInput.desiredWidthFt}::numeric * 8::numeric * ${oversizeMultiplier}::numeric`, ); } if (interestInput.desiredWidthFt !== null) { predicates.push(sql`b.width_ft::numeric >= ${interestInput.desiredWidthFt}`); } if (interestInput.desiredDraftFt !== null) { predicates.push(sql`b.draft_ft::numeric >= ${interestInput.desiredDraftFt}`); } if (args.amenityFilters?.minPowerCapacityKw != null) { predicates.push(sql`b.power_capacity::numeric >= ${args.amenityFilters.minPowerCapacityKw}`); } if (args.amenityFilters?.requiredVoltage != null) { predicates.push(sql`b.voltage::numeric = ${args.amenityFilters.requiredVoltage}`); } if (args.amenityFilters?.requiredAccess) { predicates.push(sql`b.access = ${args.amenityFilters.requiredAccess}`); } if (args.amenityFilters?.requiredMooringType) { predicates.push(sql`b.mooring_type = ${args.amenityFilters.requiredMooringType}`); } if (args.amenityFilters?.requiredCleatCapacity) { predicates.push(sql`b.cleat_capacity = ${args.amenityFilters.requiredCleatCapacity}`); } const whereClause = sql.join(predicates, sql` AND `); const rawRows = await db.execute(sql` WITH feasible AS ( SELECT b.* FROM berths b WHERE ${whereClause} ), aggregates AS ( SELECT f.id AS berth_id, -- Active = is_specific_interest=true junction rows only (matches -- the public-map "Under Offer" filter). An EOI-bundle-only link -- (is_specific_interest=false, is_in_eoi_bundle=true) is legal -- coverage, not a pitch, and shouldn't demote the berth. COUNT(*) FILTER ( WHERE i.archived_at IS NULL AND i.outcome IS NULL AND ib.is_specific_interest = true ) AS active_interest_count, COUNT(*) FILTER ( WHERE i.outcome IS NOT NULL AND (i.outcome::text LIKE 'lost%' OR i.outcome = 'cancelled') ) AS lost_count, COALESCE( MAX(CASE i.pipeline_stage WHEN 'enquiry' THEN 1 WHEN 'nurturing' THEN 2 WHEN 'qualified' THEN 3 WHEN 'eoi' THEN 4 WHEN 'reservation' THEN 5 WHEN 'deposit_paid' THEN 6 WHEN 'contract' THEN 7 ELSE 0 END ) FILTER (WHERE i.archived_at IS NULL AND i.outcome IS NULL), 0 ) AS max_active_stage, MAX(i.outcome_at) FILTER ( WHERE i.outcome IS NOT NULL AND (i.outcome::text LIKE 'lost%' OR i.outcome = 'cancelled') ) AS latest_fallthrough_at, COALESCE( MAX(CASE i.pipeline_stage WHEN 'enquiry' THEN 1 WHEN 'nurturing' THEN 2 WHEN 'qualified' THEN 3 WHEN 'eoi' THEN 4 WHEN 'reservation' THEN 5 WHEN 'deposit_paid' THEN 6 WHEN 'contract' THEN 7 ELSE 0 END ) FILTER (WHERE i.outcome IS NOT NULL AND (i.outcome::text LIKE 'lost%' OR i.outcome = 'cancelled')), 0 ) AS fallthrough_max_stage, -- COUNT(ib.berth_id) (not COUNT(*)) so a berth with no junction -- rows reports 0 - the LEFT JOIN otherwise produces a single -- NULL-right-side row that COUNT(*) would tally as 1 and inflate -- the heat interest-count component for berths with no history. -- The FILTER also enforces port isolation defense-in-depth: an -- orphan junction row whose interest belongs to a different port -- (which the new cross-port guard now prevents but pre-existing -- data may carry) shouldn't inflate this count. COUNT(ib.berth_id) FILTER (WHERE i.id IS NOT NULL) AS total_interest_count, COUNT(*) FILTER (WHERE i.eoi_status = 'signed') AS eoi_signed_count FROM feasible f LEFT JOIN interest_berths ib ON ib.berth_id = f.id LEFT JOIN interests i ON i.id = ib.interest_id AND i.port_id = ${args.portId} GROUP BY f.id ) SELECT f.id AS "berthId", f.mooring_number AS "mooringNumber", f.area, f.status, f.length_ft AS "lengthFt", f.width_ft AS "widthFt", f.draft_ft AS "draftFt", f.power_capacity AS "powerCapacity", f.voltage, f.access, f.mooring_type AS "mooringType", f.cleat_capacity AS "cleatCapacity", a.active_interest_count::int AS "activeInterestCount", a.lost_count::int AS "lostCount", a.max_active_stage::int AS "maxActiveStage", a.latest_fallthrough_at AS "latestFallthroughAt", a.fallthrough_max_stage::int AS "fallthroughMaxStage", a.total_interest_count::int AS "totalInterestCount", a.eoi_signed_count::int AS "eoiSignedCount" FROM feasible f JOIN aggregates a ON a.berth_id = f.id `); const rows = (rawRows as { rows?: RawRow[] }).rows ?? (rawRows as unknown as RawRow[]); // Apply tier classification + fall-through cooldown filter + heat scoring + fit ranking in JS. const cooldownCutoff = settings.fallthroughPolicy === 'cooldown' ? new Date(Date.now() - settings.fallthroughCooldownDays * 86_400_000) : null; const recommendations: Recommendation[] = []; for (const r of rows) { const tier = classifyTier(r); if (tier === 'D' && !showLateStage) continue; if (tier === 'B' && settings.fallthroughPolicy === 'never_auto_recommend') continue; if ( tier === 'B' && cooldownCutoff && r.latestFallthroughAt && new Date(r.latestFallthroughAt) > cooldownCutoff ) { continue; } const lengthFt = r.lengthFt === null ? null : parseFloat(r.lengthFt); const widthFt = r.widthFt === null ? null : parseFloat(r.widthFt); const draftFt = r.draftFt === null ? null : parseFloat(r.draftFt); const sizeBufferPct = interestInput.desiredLengthFt && lengthFt ? Math.round( ((lengthFt - interestInput.desiredLengthFt) / interestInput.desiredLengthFt) * 1000, ) / 10 : null; const heat = tier === 'B' ? computeHeat( { latestFallthroughAt: r.latestFallthroughAt ? new Date(r.latestFallthroughAt) : null, totalInterestCount: r.totalInterestCount, eoiSignedCount: r.eoiSignedCount, fallthroughMaxStage: r.fallthroughMaxStage, }, settings, ) : null; // Fit score: tier weight + 1/(1+buffer) closeness + heat boost. // Tier base scores: A=80, B=60+heat, C=40, D=20. Smaller buffer adds up to 20. const tierBase = { A: 80, B: 60, C: 40, D: 20 }[tier]; const closeness = sizeBufferPct === null ? 10 : Math.max(0, 20 - Math.abs(sizeBufferPct) / Math.max(1, oversizePct / 20)); const heatBoost = heat ? heat.total / 5 : 0; const fitScore = Math.max(0, Math.min(100, tierBase + closeness + heatBoost)); recommendations.push({ berthId: r.berthId, mooringNumber: r.mooringNumber, area: r.area, tier, fitScore: Math.round(fitScore * 10) / 10, sizeBufferPct, heat, reasons: { dimensional: sizeBufferPct === null ? 'No length specified - all feasible berths shown' : `Within ${Math.abs(sizeBufferPct)}% of desired length`, pipeline: pipelineReason(tier, r), ...(heat ? { heat: heatSummary(heat) } : {}), ...(args.amenityFilters && Object.keys(args.amenityFilters).length > 0 ? { amenities: 'All required amenities matched' } : {}), }, lengthFt, widthFt, draftFt, status: r.status, amenities: { powerCapacity: r.powerCapacity === null ? null : parseFloat(r.powerCapacity), voltage: r.voltage === null ? null : parseFloat(r.voltage), access: r.access, mooringType: r.mooringType, cleatCapacity: r.cleatCapacity, }, }); } recommendations.sort((a, b) => { const tierRank = (t: Tier): number => ({ A: 1, B: 2, C: 3, D: 4 })[t]; if (tierRank(a.tier) !== tierRank(b.tier)) return tierRank(a.tier) - tierRank(b.tier); return b.fitScore - a.fitScore; }); return recommendations.slice(0, topN); } function pipelineReason(tier: Tier, r: RawRow): string { switch (tier) { case 'A': return 'No prior interest history'; case 'B': return `Previously fell through (${r.lostCount} time${r.lostCount === 1 ? '' : 's'})`; case 'C': return `${r.activeInterestCount} active interest${r.activeInterestCount === 1 ? '' : 's'} in early stage`; case 'D': return 'Active interest in late stage'; } } function heatSummary(h: HeatBreakdown): string { return `Heat ${h.total.toFixed(0)}/100 (recency ${h.recency.toFixed(0)}, stage ${h.furthestStage.toFixed(0)}, count ${h.interestCount.toFixed(0)}, EOI ${h.eoiCount.toFixed(0)})`; }