/** * Rule-based deal-health scoring. NO LLMs - every output traces back to a * dated/structured input the rep can see and contest. The chip displayed * on the interest header exposes the per-signal breakdown via tooltip so * an anti-AI stakeholder reading the screen never sees a black box. * * Inputs (all already available on getInterestById): * - pipelineStage + per-doc sub-status (eoiDocStatus, etc.) * - dateLastContact, dateFirstContact, dateEoiSent, dateEoiSigned, * dateReservationSigned, dateContractSent, dateContractSigned, * dateDepositReceived * - depositExpectedAmount (numeric string) * * Scoring rubric (0–100, higher is healthier): * Base 50. * +5 if any activity log entry landed in the last 7 days (active engagement). * +20 if the rep has logged contact in the last 7 days. * +10 if contact within 14 days (and the 7d bonus didn't fire). * -15 if no contact logged in 30+ days. * -10 if the deal is older than 30d and still in 'enquiry' or 'qualified'. * +10 for each stage past enquiry the deal has reached (capped at +30). * -10 if EOI was sent more than 14d ago and isn't signed yet. * -10 if reservation was signed but no deposit recorded in 21d. * -10 if contract was sent more than 14d ago and isn't signed yet. * +5 if outcome is 'won' (sanity bump, though won deals don't show this). * * Score buckets: * ≥70 → 'hot' (green; rep is on top of it) * 40-69 → 'warm' (amber; needs attention) * <40 → 'cold' (rose; at risk) * * The full signals[] array is surfaced to the UI so the tooltip can render * "+20 contacted 3 days ago", "-10 EOI awaiting signature 19d" etc. */ import type { PipelineStage } from '@/lib/constants'; import { PIPELINE_STAGES } from '@/lib/constants'; export interface DealHealthInput { pipelineStage: string; outcome?: string | null; archivedAt?: string | null; dateFirstContact?: string | Date | null; dateLastContact?: string | Date | null; dateEoiSent?: string | Date | null; dateEoiSigned?: string | Date | null; dateReservationSigned?: string | Date | null; dateContractSent?: string | Date | null; dateContractSigned?: string | Date | null; dateDepositReceived?: string | Date | null; eoiDocStatus?: string | null; reservationDocStatus?: string | null; contractDocStatus?: string | null; /** Optional: count of contact_log entries in the last 7 days. Drives the * +5 "active engagement" signal. When omitted the signal is skipped - keep * the scoring function pure / synchronous so the chip can render without a * separate fetch on every interest list row. */ recentActivityCount?: number | null; /** Phase 2 - risk signals captured in deal-pulse-trigger-audit.md. * Any of these populated → strong negative signal pushed onto the * chip so reps can triage cooling deals at a glance. All optional; * callers populate from existing schema (document_events for * declines, reservations.cancelled_at, interest_berths conflict). */ dateDocumentDeclined?: string | Date | null; dateReservationCancelled?: string | Date | null; dateBerthSoldToOther?: string | Date | null; /** Optional per-port config that lets admins disable individual * signals or rename their tier labels. When omitted, defaults * apply - current callers stay byte-identical without changes. */ config?: DealHealthConfig | null; } export interface DealHealthConfig { enabled?: boolean; signals?: Partial>; labels?: { hot?: string; warm?: string; cold?: string; }; } export interface DealHealthSignal { /** Stable id useful for keying the tooltip rows. */ id: string; /** +N or -N (signed integer for explicit math). */ delta: number; /** Plain-English explanation surfaced in the tooltip. */ detail: string; } export interface DealHealth { score: number; pulse: 'cold' | 'warm' | 'hot'; signals: DealHealthSignal[]; } function daysSince(iso: string | Date | null | undefined): number | null { if (!iso) return null; const t = iso instanceof Date ? iso.getTime() : new Date(iso).getTime(); if (Number.isNaN(t)) return null; return Math.floor((Date.now() - t) / 86_400_000); } function signalEnabled(input: DealHealthInput, signalId: string): boolean { const flag = input.config?.signals?.[signalId]; return flag !== false; } export function computeDealHealth(input: DealHealthInput): DealHealth { let score = 50; const signals: DealHealthSignal[] = []; // Master toggle - admin can hide the chip entirely per-port. // Returning the neutral shape keeps callers happy; the chip uses // a separate "visible" prop derived from config.enabled before // calling compute. We still return real data so reports can read it. // Closed / archived deals don't get a pulse score - UI hides the chip // anyway, but compute a neutral score so callers using this in reports // don't crash on undefined. if (input.archivedAt || input.outcome) { return { score: 50, pulse: 'warm', signals: [] }; } // Active engagement: counts every distinct activity-log entry in the last // 7 days. Surfaces "the rep is actively working this deal" separately from // the coarse dateLastContact bump (which only moves on the most-recent // entry's date). A 5-call week scores +5 once; we don't double-count. if (input.recentActivityCount !== null && input.recentActivityCount !== undefined) { if (input.recentActivityCount >= 1) { score += 5; signals.push({ id: 'active_engagement', delta: +5, detail: `${input.recentActivityCount} activity log entr${ input.recentActivityCount === 1 ? 'y' : 'ies' } in the last 7d - rep is engaged.`, }); } } const contactDays = daysSince(input.dateLastContact); if (contactDays !== null) { if (contactDays <= 7) { score += 20; signals.push({ id: 'contact_recent', delta: +20, detail: `Contact logged ${contactDays}d ago - fresh.`, }); } else if (contactDays <= 14) { score += 10; signals.push({ id: 'contact_warm', delta: +10, detail: `Contact logged ${contactDays}d ago - still warm.`, }); } else if (contactDays >= 30) { score -= 15; signals.push({ id: 'contact_stale', delta: -15, detail: `No contact logged in ${contactDays}d - going cold.`, }); } } // Stage progress: every step past enquiry signals momentum. const stageIdx = PIPELINE_STAGES.indexOf(input.pipelineStage as PipelineStage); if (stageIdx > 0) { const bonus = Math.min(30, stageIdx * 10); score += bonus; signals.push({ id: 'stage_progress', delta: +bonus, detail: `Reached ${input.pipelineStage.replace(/_/g, ' ')} stage.`, }); } // Age penalty for stuck top-of-funnel leads. const firstDays = daysSince(input.dateFirstContact); if ( firstDays !== null && firstDays >= 30 && (input.pipelineStage === 'enquiry' || input.pipelineStage === 'qualified') ) { score -= 10; signals.push({ id: 'stuck_top_funnel', delta: -10, detail: `Deal opened ${firstDays}d ago and still pre-EOI.`, }); } // EOI in-flight too long. const eoiSentDays = daysSince(input.dateEoiSent); if ( eoiSentDays !== null && eoiSentDays >= 14 && input.eoiDocStatus !== 'signed' && !input.dateEoiSigned ) { score -= 10; signals.push({ id: 'eoi_awaiting', delta: -10, detail: `EOI awaiting signature for ${eoiSentDays}d.`, }); } // Reservation signed but deposit not received. const reservationDays = daysSince(input.dateReservationSigned); if (reservationDays !== null && reservationDays >= 21 && !input.dateDepositReceived) { score -= 10; signals.push({ id: 'deposit_pending', delta: -10, detail: `Reservation signed ${reservationDays}d ago but deposit not recorded.`, }); } // Contract awaiting signature. const contractSentDays = daysSince(input.dateContractSent); if ( contractSentDays !== null && contractSentDays >= 14 && input.contractDocStatus !== 'signed' && !input.dateContractSigned ) { score -= 10; signals.push({ id: 'contract_awaiting', delta: -10, detail: `Contract awaiting signature for ${contractSentDays}d.`, }); } // Phase 2 - positive momentum signals. // EOI sent recently: forward motion that the original score didn't // surface (the awaiting penalty only fires after 14d). Brightens the // chip for fresh-EOI deals so reps see progress. const eoiSentDaysPos = daysSince(input.dateEoiSent); if (eoiSentDaysPos !== null && eoiSentDaysPos <= 14 && signalEnabled(input, 'eoi_sent_recent')) { score += 5; signals.push({ id: 'eoi_sent_recent', delta: +5, detail: `EOI sent ${eoiSentDaysPos}d ago - awaiting signature.`, }); } // Deposit received: near-commit signal. Was previously invisible on // the chip even though it's one of the strongest forward signals. const depositDays = daysSince(input.dateDepositReceived); if (depositDays !== null && signalEnabled(input, 'deposit_received')) { score += 15; signals.push({ id: 'deposit_received', delta: +15, detail: `Deposit received ${depositDays}d ago.`, }); } // Contract signed: closed-loop reinforcement. The interest's outcome // flips to 'won' shortly after, but until that happens the contract // signature is a strong positive signal. const contractSignedDays = daysSince(input.dateContractSigned); if (contractSignedDays !== null && signalEnabled(input, 'contract_signed')) { score += 10; signals.push({ id: 'contract_signed', delta: +10, detail: `Contract signed ${contractSignedDays}d ago.`, }); } // Phase 2 - risk signals. These are the strongest cooling indicators // and previously didn't move the chip at all, leaving reps to discover // them by clicking into the detail page. // Document declined (EOI/contract/reservation rejected by the client). const declinedDays = daysSince(input.dateDocumentDeclined); if (declinedDays !== null && signalEnabled(input, 'document_declined')) { score -= 25; signals.push({ id: 'document_declined', delta: -25, detail: `Client declined a document ${declinedDays}d ago - intervene.`, }); } // Reservation cancelled: booked-then-cancelled is a high-value warning. const reservationCancelledDays = daysSince(input.dateReservationCancelled); if (reservationCancelledDays !== null && signalEnabled(input, 'reservation_cancelled')) { score -= 20; signals.push({ id: 'reservation_cancelled', delta: -20, detail: `Reservation cancelled ${reservationCancelledDays}d ago.`, }); } // Berth resold to a different deal - this interest is effectively dead // (the asset they wanted is gone). Sharp drop so the chip turns cold. const berthSoldDays = daysSince(input.dateBerthSoldToOther); if (berthSoldDays !== null && signalEnabled(input, 'berth_sold_to_other')) { score -= 30; signals.push({ id: 'berth_sold_to_other', delta: -30, detail: `Primary berth was sold to a different deal ${berthSoldDays}d ago.`, }); } // Clamp to [0, 100]. score = Math.max(0, Math.min(100, score)); const pulse: DealHealth['pulse'] = score >= 70 ? 'hot' : score >= 40 ? 'warm' : 'cold'; return { score, pulse, signals }; }