/** * 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; } 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); } export function computeDealHealth(input: DealHealthInput): DealHealth { let score = 50; const signals: DealHealthSignal[] = []; // 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.`, }); } // 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 }; }