diff --git a/scripts/dev-recommender-smoke.ts b/scripts/dev-recommender-smoke.ts new file mode 100644 index 0000000..b0b3584 --- /dev/null +++ b/scripts/dev-recommender-smoke.ts @@ -0,0 +1,52 @@ +/** + * Dev-only smoke check for the berth recommender. Resolves the first + * port-nimara interest (with desired dims set) and prints the top-N + * recommendations. + * + * pnpm tsx scripts/dev-recommender-smoke.ts + */ +import 'dotenv/config'; +import { eq, isNotNull, and } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { ports } from '@/lib/db/schema/ports'; +import { interests } from '@/lib/db/schema/interests'; +import { recommendBerths } from '@/lib/services/berth-recommender.service'; + +async function main() { + const [port] = await db + .select({ id: ports.id }) + .from(ports) + .where(eq(ports.slug, 'port-nimara')) + .limit(1); + if (!port) throw new Error('port-nimara not found'); + + const [interest] = await db + .select({ id: interests.id }) + .from(interests) + .where(and(eq(interests.portId, port.id), isNotNull(interests.desiredLengthFt))) + .limit(1); + if (!interest) throw new Error('No interest with desired dims set'); + + console.log(`> Recommending berths for interest ${interest.id} on port ${port.id}…`); + const recs = await recommendBerths({ + interestId: interest.id, + portId: port.id, + }); + + console.log(`> ${recs.length} recommendations:`); + for (const r of recs) { + console.log( + ` ${r.mooringNumber.padEnd(5)} tier=${r.tier} fit=${r.fitScore} ` + + `${r.lengthFt}×${r.widthFt}×${r.draftFt} ft buf=${r.sizeBufferPct}% ` + + `${r.reasons.dimensional}; ${r.reasons.pipeline}`, + ); + } +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/src/lib/services/berth-recommender.service.ts b/src/lib/services/berth-recommender.service.ts new file mode 100644 index 0000000..c2fb21a --- /dev/null +++ b/src/lib/services/berth-recommender.service.ts @@ -0,0 +1,631 @@ +/** + * 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'; + +// ─── 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 => (typeof v === 'boolean' ? v : 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 ────────────────────────────────────────────────────────── + +const STAGE_ORDER: Record = { + open: 1, + details_sent: 2, + in_communication: 3, + eoi_sent: 4, + eoi_signed: 5, + deposit_10pct: 6, + contract_sent: 7, + contract_signed: 8, + completed: 9, +}; + +/** Stage at which a berth is "in late stage" (Tier D when active). */ +const LATE_STAGE_THRESHOLD = STAGE_ORDER.deposit_10pct!; // 6 + +export type Tier = 'A' | 'B' | 'C' | 'D'; + +interface TierInputs { + activeInterestCount: number; + lostCount: number; + maxActiveStage: number; +} + +export function classifyTier(t: TierInputs): Tier { + if (t.activeInterestCount > 0 && t.maxActiveStage >= LATE_STAGE_THRESHOLD) return 'D'; + 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; +} + +async function loadInterestInput(interestId: string): Promise { + const [row] = await db + .select({ + desiredLengthFt: interests.desiredLengthFt, + desiredWidthFt: interests.desiredWidthFt, + desiredDraftFt: interests.desiredDraftFt, + portId: interests.portId, + }) + .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; + }; + return { + desiredLengthFt: toNum(row.desiredLengthFt), + desiredWidthFt: toNum(row.desiredWidthFt), + desiredDraftFt: toNum(row.desiredDraftFt), + portId: row.portId, + }; +} + +/** + * 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 Error( + `Recommender: 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`, + ); + } + 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, + COUNT(*) FILTER (WHERE i.archived_at IS NULL AND i.outcome IS NULL) AS active_interest_count, + COUNT(*) FILTER ( + WHERE i.outcome IS NOT NULL AND i.outcome::text LIKE 'lost%' + ) AS lost_count, + COALESCE( + MAX(CASE i.pipeline_stage + WHEN 'open' THEN 1 + WHEN 'details_sent' THEN 2 + WHEN 'in_communication' THEN 3 + WHEN 'eoi_sent' THEN 4 + WHEN 'eoi_signed' THEN 5 + WHEN 'deposit_10pct' THEN 6 + WHEN 'contract_sent' THEN 7 + WHEN 'contract_signed' THEN 8 + 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%' + ) AS latest_fallthrough_at, + COALESCE( + MAX(CASE i.pipeline_stage + WHEN 'open' THEN 1 + WHEN 'details_sent' THEN 2 + WHEN 'in_communication' THEN 3 + WHEN 'eoi_sent' THEN 4 + WHEN 'eoi_signed' THEN 5 + WHEN 'deposit_10pct' THEN 6 + WHEN 'contract_sent' THEN 7 + WHEN 'contract_signed' THEN 8 + ELSE 0 END + ) FILTER (WHERE i.outcome IS NOT NULL AND i.outcome::text LIKE 'lost%'), + 0 + ) AS fallthrough_max_stage, + COUNT(*) 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 + 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)})`; +} diff --git a/tests/unit/services/berth-recommender.test.ts b/tests/unit/services/berth-recommender.test.ts new file mode 100644 index 0000000..5091b2a --- /dev/null +++ b/tests/unit/services/berth-recommender.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect } from 'vitest'; + +import { + classifyTier, + computeHeat, + DEFAULT_RECOMMENDER_SETTINGS, +} from '@/lib/services/berth-recommender.service'; + +describe('classifyTier', () => { + it('"A" when there is no interest history at all', () => { + expect(classifyTier({ activeInterestCount: 0, lostCount: 0, maxActiveStage: 0 })).toBe('A'); + }); + it('"B" when only lost interests exist (no active)', () => { + expect(classifyTier({ activeInterestCount: 0, lostCount: 2, maxActiveStage: 0 })).toBe('B'); + }); + it('"C" when an active interest is in an early stage', () => { + expect(classifyTier({ activeInterestCount: 1, lostCount: 0, maxActiveStage: 3 })).toBe('C'); + }); + it('"C" even when a prior interest was lost, if there is an active one', () => { + expect(classifyTier({ activeInterestCount: 1, lostCount: 5, maxActiveStage: 2 })).toBe('C'); + }); + it('"D" when an active interest is at deposit or beyond', () => { + expect(classifyTier({ activeInterestCount: 1, lostCount: 0, maxActiveStage: 6 })).toBe('D'); + expect(classifyTier({ activeInterestCount: 1, lostCount: 0, maxActiveStage: 8 })).toBe('D'); + }); + it('still "C" at eoi_signed (stage 5) - tier D only kicks in at deposit', () => { + expect(classifyTier({ activeInterestCount: 1, lostCount: 0, maxActiveStage: 5 })).toBe('C'); + }); +}); + +describe('computeHeat', () => { + const w = DEFAULT_RECOMMENDER_SETTINGS; + const NOW = new Date('2026-05-05T00:00:00Z'); + + it('zero heat when nothing in history', () => { + const h = computeHeat( + { + latestFallthroughAt: null, + totalInterestCount: 0, + eoiSignedCount: 0, + fallthroughMaxStage: 0, + }, + w, + NOW, + ); + expect(h.total).toBe(0); + }); + + it('full recency for a fall-through within the last 30 days', () => { + const h = computeHeat( + { + latestFallthroughAt: new Date('2026-04-25T00:00:00Z'), + totalInterestCount: 0, + eoiSignedCount: 0, + fallthroughMaxStage: 1, + }, + w, + NOW, + ); + // recency component should be the full heat_weight_recency (30) + expect(h.recency).toBeCloseTo(30, 1); + }); + + it('zero recency for an ancient fall-through (>1 year)', () => { + const h = computeHeat( + { + latestFallthroughAt: new Date('2024-01-01T00:00:00Z'), + totalInterestCount: 0, + eoiSignedCount: 0, + fallthroughMaxStage: 1, + }, + w, + NOW, + ); + expect(h.recency).toBe(0); + }); + + it('full furthest-stage when the fall-through reached deposit', () => { + const h = computeHeat( + { + latestFallthroughAt: null, + totalInterestCount: 0, + eoiSignedCount: 0, + fallthroughMaxStage: 6, // deposit_10pct + }, + w, + NOW, + ); + expect(h.furthestStage).toBeCloseTo(40, 1); + }); + + it('saturates interest-count at 5+', () => { + const h = computeHeat( + { + latestFallthroughAt: null, + totalInterestCount: 10, + eoiSignedCount: 0, + fallthroughMaxStage: 0, + }, + w, + NOW, + ); + expect(h.interestCount).toBeCloseTo(15, 1); // full weight + }); + + it('saturates EOI-count at 3+', () => { + const h = computeHeat( + { + latestFallthroughAt: null, + totalInterestCount: 0, + eoiSignedCount: 5, + fallthroughMaxStage: 0, + }, + w, + NOW, + ); + expect(h.eoiCount).toBeCloseTo(15, 1); + }); + + it('total ≈ 100 when everything is maxed', () => { + const h = computeHeat( + { + latestFallthroughAt: new Date('2026-04-25T00:00:00Z'), + totalInterestCount: 5, + eoiSignedCount: 3, + fallthroughMaxStage: 6, + }, + w, + NOW, + ); + expect(h.total).toBeGreaterThanOrEqual(99); + expect(h.total).toBeLessThanOrEqual(100); + }); + + it('respects tunable per-port weights (skewed toward recency)', () => { + const skewed = { + heatWeightRecency: 100, + heatWeightFurthestStage: 0, + heatWeightInterestCount: 0, + heatWeightEoiCount: 0, + }; + const recent = computeHeat( + { + latestFallthroughAt: new Date('2026-04-25T00:00:00Z'), + totalInterestCount: 0, + eoiSignedCount: 0, + fallthroughMaxStage: 0, + }, + skewed, + NOW, + ); + expect(recent.total).toBeCloseTo(100, 1); + + const old = computeHeat( + { + latestFallthroughAt: new Date('2024-01-01T00:00:00Z'), + totalInterestCount: 5, + eoiSignedCount: 3, + fallthroughMaxStage: 6, + }, + skewed, + NOW, + ); + expect(old.total).toBe(0); + }); + + it('zero-weights guard (no division-by-zero blow-up)', () => { + const zeros = { + heatWeightRecency: 0, + heatWeightFurthestStage: 0, + heatWeightInterestCount: 0, + heatWeightEoiCount: 0, + }; + const h = computeHeat( + { + latestFallthroughAt: new Date(), + totalInterestCount: 5, + eoiSignedCount: 3, + fallthroughMaxStage: 6, + }, + zeros, + NOW, + ); + expect(h.total).toBe(0); + }); +});