Files
pn-new-crm/src/lib/services/deal-health.ts

322 lines
11 KiB
TypeScript
Raw Normal View History

feat(pipeline): 9→7 stage refactor + v1.1 hardening wave Replaces the legacy 9-stage pipeline with 7 canonical stages (enquiry → qualified → eoi → reservation → deposit_paid → contract → nurturing) plus three doc sub-status columns (eoi_doc_status, reservation_doc_status, contract_doc_status) that track sent/signed within a single stage instead of branching it. Schema (migration 0062): - interests gains assigned_to, deposit_expected_amount/currency, three doc-status columns, two documenso-id columns, and date_reservation_signed. - New tables: qualification_criteria (per-port admin-configurable), interest_qualifications (per-interest state), payments (deposit / balance / refund records keyed to interest + client). - Default qualification criteria seeded for every existing port. - Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into the new stage + doc-status + outcome shape. Migration 0063 adds interest_contact_log.voice_transcript and template_used columns for v1.1-A/B (quick-template buttons + voice transcription via Web Speech API). v1.1 phase work bundled here: - A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on the contact-log compose dialog (useVoiceTranscription hook). - C: berth-rules-engine wraps state writes in pg_advisory_xact_lock with an idempotent re-read; emits rule_evaluated audit traces. - D: Documenso webhook: reservation/contract sub-status stamping moved out of the PDF-download try-block so a download failure no longer swallows the stamp. New integration test coverage. - E: /admin/qualification-criteria CRUD page + admin component. - F: default_new_interest_owner exposed in System Settings. - G: recentActivityCount + active_engagement deal-pulse signal surfaced as a chip on interests + hot-deals card. - H: interest_assigned notification on assignedTo change (skips self-assign, uses a dedupe key). Plus the supporting components: AssignedToChip, DealPulseChip, PaymentsSection, QualificationChecklist, MultiEoiChip, SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner, SupplementalInfoRequestButton, UserPicker. Tests: 1370/1370 vitest pass (added deal-health unit suite + expanded constants/validators/pipeline-transitions coverage). tsc clean, eslint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:39:21 +02:00
/**
* Rule-based deal-health scoring. NO LLMs - every output traces back to a
feat(pipeline): 9→7 stage refactor + v1.1 hardening wave Replaces the legacy 9-stage pipeline with 7 canonical stages (enquiry → qualified → eoi → reservation → deposit_paid → contract → nurturing) plus three doc sub-status columns (eoi_doc_status, reservation_doc_status, contract_doc_status) that track sent/signed within a single stage instead of branching it. Schema (migration 0062): - interests gains assigned_to, deposit_expected_amount/currency, three doc-status columns, two documenso-id columns, and date_reservation_signed. - New tables: qualification_criteria (per-port admin-configurable), interest_qualifications (per-interest state), payments (deposit / balance / refund records keyed to interest + client). - Default qualification criteria seeded for every existing port. - Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into the new stage + doc-status + outcome shape. Migration 0063 adds interest_contact_log.voice_transcript and template_used columns for v1.1-A/B (quick-template buttons + voice transcription via Web Speech API). v1.1 phase work bundled here: - A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on the contact-log compose dialog (useVoiceTranscription hook). - C: berth-rules-engine wraps state writes in pg_advisory_xact_lock with an idempotent re-read; emits rule_evaluated audit traces. - D: Documenso webhook: reservation/contract sub-status stamping moved out of the PDF-download try-block so a download failure no longer swallows the stamp. New integration test coverage. - E: /admin/qualification-criteria CRUD page + admin component. - F: default_new_interest_owner exposed in System Settings. - G: recentActivityCount + active_engagement deal-pulse signal surfaced as a chip on interests + hot-deals card. - H: interest_assigned notification on assignedTo change (skips self-assign, uses a dedupe key). Plus the supporting components: AssignedToChip, DealPulseChip, PaymentsSection, QualificationChecklist, MultiEoiChip, SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner, SupplementalInfoRequestButton, UserPicker. Tests: 1370/1370 vitest pass (added deal-health unit suite + expanded constants/validators/pipeline-transitions coverage). tsc clean, eslint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:39:21 +02:00
* 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 (0100, 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
feat(pipeline): 9→7 stage refactor + v1.1 hardening wave Replaces the legacy 9-stage pipeline with 7 canonical stages (enquiry → qualified → eoi → reservation → deposit_paid → contract → nurturing) plus three doc sub-status columns (eoi_doc_status, reservation_doc_status, contract_doc_status) that track sent/signed within a single stage instead of branching it. Schema (migration 0062): - interests gains assigned_to, deposit_expected_amount/currency, three doc-status columns, two documenso-id columns, and date_reservation_signed. - New tables: qualification_criteria (per-port admin-configurable), interest_qualifications (per-interest state), payments (deposit / balance / refund records keyed to interest + client). - Default qualification criteria seeded for every existing port. - Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into the new stage + doc-status + outcome shape. Migration 0063 adds interest_contact_log.voice_transcript and template_used columns for v1.1-A/B (quick-template buttons + voice transcription via Web Speech API). v1.1 phase work bundled here: - A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on the contact-log compose dialog (useVoiceTranscription hook). - C: berth-rules-engine wraps state writes in pg_advisory_xact_lock with an idempotent re-read; emits rule_evaluated audit traces. - D: Documenso webhook: reservation/contract sub-status stamping moved out of the PDF-download try-block so a download failure no longer swallows the stamp. New integration test coverage. - E: /admin/qualification-criteria CRUD page + admin component. - F: default_new_interest_owner exposed in System Settings. - G: recentActivityCount + active_engagement deal-pulse signal surfaced as a chip on interests + hot-deals card. - H: interest_assigned notification on assignedTo change (skips self-assign, uses a dedupe key). Plus the supporting components: AssignedToChip, DealPulseChip, PaymentsSection, QualificationChecklist, MultiEoiChip, SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner, SupplementalInfoRequestButton, UserPicker. Tests: 1370/1370 vitest pass (added deal-health unit suite + expanded constants/validators/pipeline-transitions coverage). tsc clean, eslint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:39:21 +02:00
* 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.
feat(post-audit): Phase 1.3 + 1.4 + Phase 2 signals + pulse admin Phase 1.3 — signing-invitation role copy - Order-agnostic phrasing (was assuming client→developer→approver order; ports configure any sequence so the "client has already signed" assumption was brittle). - Explicit developer-role branch + safe default for unknown roles. Phase 1.4 — supplemental form per-port URL - New supplemental_form_url registry entry (email.from section). - Threaded through getPortEmailConfig → PortEmailConfig.supplementalFormUrl. - /api/v1/interests/[id]/supplemental-info-request resolves the link via per-port URL when set, falls back to /public/supplemental-info/<token> CRM route when blank. Phase 2 — deal-pulse signal expansion + admin config - Compute function gains: - +5 eoi_sent_recent (≤14d) — was previously invisible - +15 deposit_received — strongest near-commit signal - +10 contract_signed — closed-loop reinforcement until outcome flips - -25 document_declined — strongest cooling signal - -20 reservation_cancelled — booked-then-cancelled warning - -30 berth_sold_to_other — primary berth lost to another deal - Each signal honours optional per-port `signal_<id>_enabled` toggle. - Registry adds master toggle (pulse_enabled), per-signal toggles, and per-port label overrides (Hot/Warm/Cold rename). - New /admin/pulse page mounted via RegistryDrivenForm. - AdminSectionsBrowser entry under Configuration. Data-wiring for the 3 risk signals (declined/cancelled/sold-to-other) needs follow-up: requires either schema timestamps on interests or derivation from event tables. Master plan §B captures the gap. Tests: 1374/1374 passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:57:55 +02:00
* 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. */
feat(post-audit): Phase 1.3 + 1.4 + Phase 2 signals + pulse admin Phase 1.3 — signing-invitation role copy - Order-agnostic phrasing (was assuming client→developer→approver order; ports configure any sequence so the "client has already signed" assumption was brittle). - Explicit developer-role branch + safe default for unknown roles. Phase 1.4 — supplemental form per-port URL - New supplemental_form_url registry entry (email.from section). - Threaded through getPortEmailConfig → PortEmailConfig.supplementalFormUrl. - /api/v1/interests/[id]/supplemental-info-request resolves the link via per-port URL when set, falls back to /public/supplemental-info/<token> CRM route when blank. Phase 2 — deal-pulse signal expansion + admin config - Compute function gains: - +5 eoi_sent_recent (≤14d) — was previously invisible - +15 deposit_received — strongest near-commit signal - +10 contract_signed — closed-loop reinforcement until outcome flips - -25 document_declined — strongest cooling signal - -20 reservation_cancelled — booked-then-cancelled warning - -30 berth_sold_to_other — primary berth lost to another deal - Each signal honours optional per-port `signal_<id>_enabled` toggle. - Registry adds master toggle (pulse_enabled), per-signal toggles, and per-port label overrides (Hot/Warm/Cold rename). - New /admin/pulse page mounted via RegistryDrivenForm. - AdminSectionsBrowser entry under Configuration. Data-wiring for the 3 risk signals (declined/cancelled/sold-to-other) needs follow-up: requires either schema timestamps on interests or derivation from event tables. Master plan §B captures the gap. Tests: 1374/1374 passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:57:55 +02:00
config?: DealHealthConfig | null;
}
export interface DealHealthConfig {
enabled?: boolean;
signals?: Partial<Record<string, boolean>>;
labels?: {
hot?: string;
warm?: string;
cold?: string;
};
feat(pipeline): 9→7 stage refactor + v1.1 hardening wave Replaces the legacy 9-stage pipeline with 7 canonical stages (enquiry → qualified → eoi → reservation → deposit_paid → contract → nurturing) plus three doc sub-status columns (eoi_doc_status, reservation_doc_status, contract_doc_status) that track sent/signed within a single stage instead of branching it. Schema (migration 0062): - interests gains assigned_to, deposit_expected_amount/currency, three doc-status columns, two documenso-id columns, and date_reservation_signed. - New tables: qualification_criteria (per-port admin-configurable), interest_qualifications (per-interest state), payments (deposit / balance / refund records keyed to interest + client). - Default qualification criteria seeded for every existing port. - Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into the new stage + doc-status + outcome shape. Migration 0063 adds interest_contact_log.voice_transcript and template_used columns for v1.1-A/B (quick-template buttons + voice transcription via Web Speech API). v1.1 phase work bundled here: - A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on the contact-log compose dialog (useVoiceTranscription hook). - C: berth-rules-engine wraps state writes in pg_advisory_xact_lock with an idempotent re-read; emits rule_evaluated audit traces. - D: Documenso webhook: reservation/contract sub-status stamping moved out of the PDF-download try-block so a download failure no longer swallows the stamp. New integration test coverage. - E: /admin/qualification-criteria CRUD page + admin component. - F: default_new_interest_owner exposed in System Settings. - G: recentActivityCount + active_engagement deal-pulse signal surfaced as a chip on interests + hot-deals card. - H: interest_assigned notification on assignedTo change (skips self-assign, uses a dedupe key). Plus the supporting components: AssignedToChip, DealPulseChip, PaymentsSection, QualificationChecklist, MultiEoiChip, SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner, SupplementalInfoRequestButton, UserPicker. Tests: 1370/1370 vitest pass (added deal-health unit suite + expanded constants/validators/pipeline-transitions coverage). tsc clean, eslint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:39:21 +02:00
}
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);
}
feat(post-audit): Phase 1.3 + 1.4 + Phase 2 signals + pulse admin Phase 1.3 — signing-invitation role copy - Order-agnostic phrasing (was assuming client→developer→approver order; ports configure any sequence so the "client has already signed" assumption was brittle). - Explicit developer-role branch + safe default for unknown roles. Phase 1.4 — supplemental form per-port URL - New supplemental_form_url registry entry (email.from section). - Threaded through getPortEmailConfig → PortEmailConfig.supplementalFormUrl. - /api/v1/interests/[id]/supplemental-info-request resolves the link via per-port URL when set, falls back to /public/supplemental-info/<token> CRM route when blank. Phase 2 — deal-pulse signal expansion + admin config - Compute function gains: - +5 eoi_sent_recent (≤14d) — was previously invisible - +15 deposit_received — strongest near-commit signal - +10 contract_signed — closed-loop reinforcement until outcome flips - -25 document_declined — strongest cooling signal - -20 reservation_cancelled — booked-then-cancelled warning - -30 berth_sold_to_other — primary berth lost to another deal - Each signal honours optional per-port `signal_<id>_enabled` toggle. - Registry adds master toggle (pulse_enabled), per-signal toggles, and per-port label overrides (Hot/Warm/Cold rename). - New /admin/pulse page mounted via RegistryDrivenForm. - AdminSectionsBrowser entry under Configuration. Data-wiring for the 3 risk signals (declined/cancelled/sold-to-other) needs follow-up: requires either schema timestamps on interests or derivation from event tables. Master plan §B captures the gap. Tests: 1374/1374 passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:57:55 +02:00
function signalEnabled(input: DealHealthInput, signalId: string): boolean {
const flag = input.config?.signals?.[signalId];
return flag !== false;
}
feat(pipeline): 9→7 stage refactor + v1.1 hardening wave Replaces the legacy 9-stage pipeline with 7 canonical stages (enquiry → qualified → eoi → reservation → deposit_paid → contract → nurturing) plus three doc sub-status columns (eoi_doc_status, reservation_doc_status, contract_doc_status) that track sent/signed within a single stage instead of branching it. Schema (migration 0062): - interests gains assigned_to, deposit_expected_amount/currency, three doc-status columns, two documenso-id columns, and date_reservation_signed. - New tables: qualification_criteria (per-port admin-configurable), interest_qualifications (per-interest state), payments (deposit / balance / refund records keyed to interest + client). - Default qualification criteria seeded for every existing port. - Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into the new stage + doc-status + outcome shape. Migration 0063 adds interest_contact_log.voice_transcript and template_used columns for v1.1-A/B (quick-template buttons + voice transcription via Web Speech API). v1.1 phase work bundled here: - A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on the contact-log compose dialog (useVoiceTranscription hook). - C: berth-rules-engine wraps state writes in pg_advisory_xact_lock with an idempotent re-read; emits rule_evaluated audit traces. - D: Documenso webhook: reservation/contract sub-status stamping moved out of the PDF-download try-block so a download failure no longer swallows the stamp. New integration test coverage. - E: /admin/qualification-criteria CRUD page + admin component. - F: default_new_interest_owner exposed in System Settings. - G: recentActivityCount + active_engagement deal-pulse signal surfaced as a chip on interests + hot-deals card. - H: interest_assigned notification on assignedTo change (skips self-assign, uses a dedupe key). Plus the supporting components: AssignedToChip, DealPulseChip, PaymentsSection, QualificationChecklist, MultiEoiChip, SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner, SupplementalInfoRequestButton, UserPicker. Tests: 1370/1370 vitest pass (added deal-health unit suite + expanded constants/validators/pipeline-transitions coverage). tsc clean, eslint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:39:21 +02:00
export function computeDealHealth(input: DealHealthInput): DealHealth {
let score = 50;
const signals: DealHealthSignal[] = [];
// Master toggle - admin can hide the chip entirely per-port.
feat(post-audit): Phase 1.3 + 1.4 + Phase 2 signals + pulse admin Phase 1.3 — signing-invitation role copy - Order-agnostic phrasing (was assuming client→developer→approver order; ports configure any sequence so the "client has already signed" assumption was brittle). - Explicit developer-role branch + safe default for unknown roles. Phase 1.4 — supplemental form per-port URL - New supplemental_form_url registry entry (email.from section). - Threaded through getPortEmailConfig → PortEmailConfig.supplementalFormUrl. - /api/v1/interests/[id]/supplemental-info-request resolves the link via per-port URL when set, falls back to /public/supplemental-info/<token> CRM route when blank. Phase 2 — deal-pulse signal expansion + admin config - Compute function gains: - +5 eoi_sent_recent (≤14d) — was previously invisible - +15 deposit_received — strongest near-commit signal - +10 contract_signed — closed-loop reinforcement until outcome flips - -25 document_declined — strongest cooling signal - -20 reservation_cancelled — booked-then-cancelled warning - -30 berth_sold_to_other — primary berth lost to another deal - Each signal honours optional per-port `signal_<id>_enabled` toggle. - Registry adds master toggle (pulse_enabled), per-signal toggles, and per-port label overrides (Hot/Warm/Cold rename). - New /admin/pulse page mounted via RegistryDrivenForm. - AdminSectionsBrowser entry under Configuration. Data-wiring for the 3 risk signals (declined/cancelled/sold-to-other) needs follow-up: requires either schema timestamps on interests or derivation from event tables. Master plan §B captures the gap. Tests: 1374/1374 passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:57:55 +02:00
// 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
feat(pipeline): 9→7 stage refactor + v1.1 hardening wave Replaces the legacy 9-stage pipeline with 7 canonical stages (enquiry → qualified → eoi → reservation → deposit_paid → contract → nurturing) plus three doc sub-status columns (eoi_doc_status, reservation_doc_status, contract_doc_status) that track sent/signed within a single stage instead of branching it. Schema (migration 0062): - interests gains assigned_to, deposit_expected_amount/currency, three doc-status columns, two documenso-id columns, and date_reservation_signed. - New tables: qualification_criteria (per-port admin-configurable), interest_qualifications (per-interest state), payments (deposit / balance / refund records keyed to interest + client). - Default qualification criteria seeded for every existing port. - Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into the new stage + doc-status + outcome shape. Migration 0063 adds interest_contact_log.voice_transcript and template_used columns for v1.1-A/B (quick-template buttons + voice transcription via Web Speech API). v1.1 phase work bundled here: - A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on the contact-log compose dialog (useVoiceTranscription hook). - C: berth-rules-engine wraps state writes in pg_advisory_xact_lock with an idempotent re-read; emits rule_evaluated audit traces. - D: Documenso webhook: reservation/contract sub-status stamping moved out of the PDF-download try-block so a download failure no longer swallows the stamp. New integration test coverage. - E: /admin/qualification-criteria CRUD page + admin component. - F: default_new_interest_owner exposed in System Settings. - G: recentActivityCount + active_engagement deal-pulse signal surfaced as a chip on interests + hot-deals card. - H: interest_assigned notification on assignedTo change (skips self-assign, uses a dedupe key). Plus the supporting components: AssignedToChip, DealPulseChip, PaymentsSection, QualificationChecklist, MultiEoiChip, SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner, SupplementalInfoRequestButton, UserPicker. Tests: 1370/1370 vitest pass (added deal-health unit suite + expanded constants/validators/pipeline-transitions coverage). tsc clean, eslint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:39:21 +02:00
// 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.`,
feat(pipeline): 9→7 stage refactor + v1.1 hardening wave Replaces the legacy 9-stage pipeline with 7 canonical stages (enquiry → qualified → eoi → reservation → deposit_paid → contract → nurturing) plus three doc sub-status columns (eoi_doc_status, reservation_doc_status, contract_doc_status) that track sent/signed within a single stage instead of branching it. Schema (migration 0062): - interests gains assigned_to, deposit_expected_amount/currency, three doc-status columns, two documenso-id columns, and date_reservation_signed. - New tables: qualification_criteria (per-port admin-configurable), interest_qualifications (per-interest state), payments (deposit / balance / refund records keyed to interest + client). - Default qualification criteria seeded for every existing port. - Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into the new stage + doc-status + outcome shape. Migration 0063 adds interest_contact_log.voice_transcript and template_used columns for v1.1-A/B (quick-template buttons + voice transcription via Web Speech API). v1.1 phase work bundled here: - A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on the contact-log compose dialog (useVoiceTranscription hook). - C: berth-rules-engine wraps state writes in pg_advisory_xact_lock with an idempotent re-read; emits rule_evaluated audit traces. - D: Documenso webhook: reservation/contract sub-status stamping moved out of the PDF-download try-block so a download failure no longer swallows the stamp. New integration test coverage. - E: /admin/qualification-criteria CRUD page + admin component. - F: default_new_interest_owner exposed in System Settings. - G: recentActivityCount + active_engagement deal-pulse signal surfaced as a chip on interests + hot-deals card. - H: interest_assigned notification on assignedTo change (skips self-assign, uses a dedupe key). Plus the supporting components: AssignedToChip, DealPulseChip, PaymentsSection, QualificationChecklist, MultiEoiChip, SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner, SupplementalInfoRequestButton, UserPicker. Tests: 1370/1370 vitest pass (added deal-health unit suite + expanded constants/validators/pipeline-transitions coverage). tsc clean, eslint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:39:21 +02:00
});
}
}
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.`,
feat(pipeline): 9→7 stage refactor + v1.1 hardening wave Replaces the legacy 9-stage pipeline with 7 canonical stages (enquiry → qualified → eoi → reservation → deposit_paid → contract → nurturing) plus three doc sub-status columns (eoi_doc_status, reservation_doc_status, contract_doc_status) that track sent/signed within a single stage instead of branching it. Schema (migration 0062): - interests gains assigned_to, deposit_expected_amount/currency, three doc-status columns, two documenso-id columns, and date_reservation_signed. - New tables: qualification_criteria (per-port admin-configurable), interest_qualifications (per-interest state), payments (deposit / balance / refund records keyed to interest + client). - Default qualification criteria seeded for every existing port. - Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into the new stage + doc-status + outcome shape. Migration 0063 adds interest_contact_log.voice_transcript and template_used columns for v1.1-A/B (quick-template buttons + voice transcription via Web Speech API). v1.1 phase work bundled here: - A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on the contact-log compose dialog (useVoiceTranscription hook). - C: berth-rules-engine wraps state writes in pg_advisory_xact_lock with an idempotent re-read; emits rule_evaluated audit traces. - D: Documenso webhook: reservation/contract sub-status stamping moved out of the PDF-download try-block so a download failure no longer swallows the stamp. New integration test coverage. - E: /admin/qualification-criteria CRUD page + admin component. - F: default_new_interest_owner exposed in System Settings. - G: recentActivityCount + active_engagement deal-pulse signal surfaced as a chip on interests + hot-deals card. - H: interest_assigned notification on assignedTo change (skips self-assign, uses a dedupe key). Plus the supporting components: AssignedToChip, DealPulseChip, PaymentsSection, QualificationChecklist, MultiEoiChip, SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner, SupplementalInfoRequestButton, UserPicker. Tests: 1370/1370 vitest pass (added deal-health unit suite + expanded constants/validators/pipeline-transitions coverage). tsc clean, eslint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:39:21 +02:00
});
} else if (contactDays <= 14) {
score += 10;
signals.push({
id: 'contact_warm',
delta: +10,
detail: `Contact logged ${contactDays}d ago - still warm.`,
feat(pipeline): 9→7 stage refactor + v1.1 hardening wave Replaces the legacy 9-stage pipeline with 7 canonical stages (enquiry → qualified → eoi → reservation → deposit_paid → contract → nurturing) plus three doc sub-status columns (eoi_doc_status, reservation_doc_status, contract_doc_status) that track sent/signed within a single stage instead of branching it. Schema (migration 0062): - interests gains assigned_to, deposit_expected_amount/currency, three doc-status columns, two documenso-id columns, and date_reservation_signed. - New tables: qualification_criteria (per-port admin-configurable), interest_qualifications (per-interest state), payments (deposit / balance / refund records keyed to interest + client). - Default qualification criteria seeded for every existing port. - Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into the new stage + doc-status + outcome shape. Migration 0063 adds interest_contact_log.voice_transcript and template_used columns for v1.1-A/B (quick-template buttons + voice transcription via Web Speech API). v1.1 phase work bundled here: - A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on the contact-log compose dialog (useVoiceTranscription hook). - C: berth-rules-engine wraps state writes in pg_advisory_xact_lock with an idempotent re-read; emits rule_evaluated audit traces. - D: Documenso webhook: reservation/contract sub-status stamping moved out of the PDF-download try-block so a download failure no longer swallows the stamp. New integration test coverage. - E: /admin/qualification-criteria CRUD page + admin component. - F: default_new_interest_owner exposed in System Settings. - G: recentActivityCount + active_engagement deal-pulse signal surfaced as a chip on interests + hot-deals card. - H: interest_assigned notification on assignedTo change (skips self-assign, uses a dedupe key). Plus the supporting components: AssignedToChip, DealPulseChip, PaymentsSection, QualificationChecklist, MultiEoiChip, SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner, SupplementalInfoRequestButton, UserPicker. Tests: 1370/1370 vitest pass (added deal-health unit suite + expanded constants/validators/pipeline-transitions coverage). tsc clean, eslint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:39:21 +02:00
});
} else if (contactDays >= 30) {
score -= 15;
signals.push({
id: 'contact_stale',
delta: -15,
detail: `No contact logged in ${contactDays}d - going cold.`,
feat(pipeline): 9→7 stage refactor + v1.1 hardening wave Replaces the legacy 9-stage pipeline with 7 canonical stages (enquiry → qualified → eoi → reservation → deposit_paid → contract → nurturing) plus three doc sub-status columns (eoi_doc_status, reservation_doc_status, contract_doc_status) that track sent/signed within a single stage instead of branching it. Schema (migration 0062): - interests gains assigned_to, deposit_expected_amount/currency, three doc-status columns, two documenso-id columns, and date_reservation_signed. - New tables: qualification_criteria (per-port admin-configurable), interest_qualifications (per-interest state), payments (deposit / balance / refund records keyed to interest + client). - Default qualification criteria seeded for every existing port. - Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into the new stage + doc-status + outcome shape. Migration 0063 adds interest_contact_log.voice_transcript and template_used columns for v1.1-A/B (quick-template buttons + voice transcription via Web Speech API). v1.1 phase work bundled here: - A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on the contact-log compose dialog (useVoiceTranscription hook). - C: berth-rules-engine wraps state writes in pg_advisory_xact_lock with an idempotent re-read; emits rule_evaluated audit traces. - D: Documenso webhook: reservation/contract sub-status stamping moved out of the PDF-download try-block so a download failure no longer swallows the stamp. New integration test coverage. - E: /admin/qualification-criteria CRUD page + admin component. - F: default_new_interest_owner exposed in System Settings. - G: recentActivityCount + active_engagement deal-pulse signal surfaced as a chip on interests + hot-deals card. - H: interest_assigned notification on assignedTo change (skips self-assign, uses a dedupe key). Plus the supporting components: AssignedToChip, DealPulseChip, PaymentsSection, QualificationChecklist, MultiEoiChip, SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner, SupplementalInfoRequestButton, UserPicker. Tests: 1370/1370 vitest pass (added deal-health unit suite + expanded constants/validators/pipeline-transitions coverage). tsc clean, eslint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:39:21 +02:00
});
}
}
// 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.
feat(post-audit): Phase 1.3 + 1.4 + Phase 2 signals + pulse admin Phase 1.3 — signing-invitation role copy - Order-agnostic phrasing (was assuming client→developer→approver order; ports configure any sequence so the "client has already signed" assumption was brittle). - Explicit developer-role branch + safe default for unknown roles. Phase 1.4 — supplemental form per-port URL - New supplemental_form_url registry entry (email.from section). - Threaded through getPortEmailConfig → PortEmailConfig.supplementalFormUrl. - /api/v1/interests/[id]/supplemental-info-request resolves the link via per-port URL when set, falls back to /public/supplemental-info/<token> CRM route when blank. Phase 2 — deal-pulse signal expansion + admin config - Compute function gains: - +5 eoi_sent_recent (≤14d) — was previously invisible - +15 deposit_received — strongest near-commit signal - +10 contract_signed — closed-loop reinforcement until outcome flips - -25 document_declined — strongest cooling signal - -20 reservation_cancelled — booked-then-cancelled warning - -30 berth_sold_to_other — primary berth lost to another deal - Each signal honours optional per-port `signal_<id>_enabled` toggle. - Registry adds master toggle (pulse_enabled), per-signal toggles, and per-port label overrides (Hot/Warm/Cold rename). - New /admin/pulse page mounted via RegistryDrivenForm. - AdminSectionsBrowser entry under Configuration. Data-wiring for the 3 risk signals (declined/cancelled/sold-to-other) needs follow-up: requires either schema timestamps on interests or derivation from event tables. Master plan §B captures the gap. Tests: 1374/1374 passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:57:55 +02:00
// 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.`,
feat(post-audit): Phase 1.3 + 1.4 + Phase 2 signals + pulse admin Phase 1.3 — signing-invitation role copy - Order-agnostic phrasing (was assuming client→developer→approver order; ports configure any sequence so the "client has already signed" assumption was brittle). - Explicit developer-role branch + safe default for unknown roles. Phase 1.4 — supplemental form per-port URL - New supplemental_form_url registry entry (email.from section). - Threaded through getPortEmailConfig → PortEmailConfig.supplementalFormUrl. - /api/v1/interests/[id]/supplemental-info-request resolves the link via per-port URL when set, falls back to /public/supplemental-info/<token> CRM route when blank. Phase 2 — deal-pulse signal expansion + admin config - Compute function gains: - +5 eoi_sent_recent (≤14d) — was previously invisible - +15 deposit_received — strongest near-commit signal - +10 contract_signed — closed-loop reinforcement until outcome flips - -25 document_declined — strongest cooling signal - -20 reservation_cancelled — booked-then-cancelled warning - -30 berth_sold_to_other — primary berth lost to another deal - Each signal honours optional per-port `signal_<id>_enabled` toggle. - Registry adds master toggle (pulse_enabled), per-signal toggles, and per-port label overrides (Hot/Warm/Cold rename). - New /admin/pulse page mounted via RegistryDrivenForm. - AdminSectionsBrowser entry under Configuration. Data-wiring for the 3 risk signals (declined/cancelled/sold-to-other) needs follow-up: requires either schema timestamps on interests or derivation from event tables. Master plan §B captures the gap. Tests: 1374/1374 passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:57:55 +02:00
});
}
// 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
feat(post-audit): Phase 1.3 + 1.4 + Phase 2 signals + pulse admin Phase 1.3 — signing-invitation role copy - Order-agnostic phrasing (was assuming client→developer→approver order; ports configure any sequence so the "client has already signed" assumption was brittle). - Explicit developer-role branch + safe default for unknown roles. Phase 1.4 — supplemental form per-port URL - New supplemental_form_url registry entry (email.from section). - Threaded through getPortEmailConfig → PortEmailConfig.supplementalFormUrl. - /api/v1/interests/[id]/supplemental-info-request resolves the link via per-port URL when set, falls back to /public/supplemental-info/<token> CRM route when blank. Phase 2 — deal-pulse signal expansion + admin config - Compute function gains: - +5 eoi_sent_recent (≤14d) — was previously invisible - +15 deposit_received — strongest near-commit signal - +10 contract_signed — closed-loop reinforcement until outcome flips - -25 document_declined — strongest cooling signal - -20 reservation_cancelled — booked-then-cancelled warning - -30 berth_sold_to_other — primary berth lost to another deal - Each signal honours optional per-port `signal_<id>_enabled` toggle. - Registry adds master toggle (pulse_enabled), per-signal toggles, and per-port label overrides (Hot/Warm/Cold rename). - New /admin/pulse page mounted via RegistryDrivenForm. - AdminSectionsBrowser entry under Configuration. Data-wiring for the 3 risk signals (declined/cancelled/sold-to-other) needs follow-up: requires either schema timestamps on interests or derivation from event tables. Master plan §B captures the gap. Tests: 1374/1374 passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:57:55 +02:00
// 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.`,
feat(post-audit): Phase 1.3 + 1.4 + Phase 2 signals + pulse admin Phase 1.3 — signing-invitation role copy - Order-agnostic phrasing (was assuming client→developer→approver order; ports configure any sequence so the "client has already signed" assumption was brittle). - Explicit developer-role branch + safe default for unknown roles. Phase 1.4 — supplemental form per-port URL - New supplemental_form_url registry entry (email.from section). - Threaded through getPortEmailConfig → PortEmailConfig.supplementalFormUrl. - /api/v1/interests/[id]/supplemental-info-request resolves the link via per-port URL when set, falls back to /public/supplemental-info/<token> CRM route when blank. Phase 2 — deal-pulse signal expansion + admin config - Compute function gains: - +5 eoi_sent_recent (≤14d) — was previously invisible - +15 deposit_received — strongest near-commit signal - +10 contract_signed — closed-loop reinforcement until outcome flips - -25 document_declined — strongest cooling signal - -20 reservation_cancelled — booked-then-cancelled warning - -30 berth_sold_to_other — primary berth lost to another deal - Each signal honours optional per-port `signal_<id>_enabled` toggle. - Registry adds master toggle (pulse_enabled), per-signal toggles, and per-port label overrides (Hot/Warm/Cold rename). - New /admin/pulse page mounted via RegistryDrivenForm. - AdminSectionsBrowser entry under Configuration. Data-wiring for the 3 risk signals (declined/cancelled/sold-to-other) needs follow-up: requires either schema timestamps on interests or derivation from event tables. Master plan §B captures the gap. Tests: 1374/1374 passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:57:55 +02:00
});
}
// 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
feat(post-audit): Phase 1.3 + 1.4 + Phase 2 signals + pulse admin Phase 1.3 — signing-invitation role copy - Order-agnostic phrasing (was assuming client→developer→approver order; ports configure any sequence so the "client has already signed" assumption was brittle). - Explicit developer-role branch + safe default for unknown roles. Phase 1.4 — supplemental form per-port URL - New supplemental_form_url registry entry (email.from section). - Threaded through getPortEmailConfig → PortEmailConfig.supplementalFormUrl. - /api/v1/interests/[id]/supplemental-info-request resolves the link via per-port URL when set, falls back to /public/supplemental-info/<token> CRM route when blank. Phase 2 — deal-pulse signal expansion + admin config - Compute function gains: - +5 eoi_sent_recent (≤14d) — was previously invisible - +15 deposit_received — strongest near-commit signal - +10 contract_signed — closed-loop reinforcement until outcome flips - -25 document_declined — strongest cooling signal - -20 reservation_cancelled — booked-then-cancelled warning - -30 berth_sold_to_other — primary berth lost to another deal - Each signal honours optional per-port `signal_<id>_enabled` toggle. - Registry adds master toggle (pulse_enabled), per-signal toggles, and per-port label overrides (Hot/Warm/Cold rename). - New /admin/pulse page mounted via RegistryDrivenForm. - AdminSectionsBrowser entry under Configuration. Data-wiring for the 3 risk signals (declined/cancelled/sold-to-other) needs follow-up: requires either schema timestamps on interests or derivation from event tables. Master plan §B captures the gap. Tests: 1374/1374 passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:57:55 +02:00
// (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.`,
});
}
feat(pipeline): 9→7 stage refactor + v1.1 hardening wave Replaces the legacy 9-stage pipeline with 7 canonical stages (enquiry → qualified → eoi → reservation → deposit_paid → contract → nurturing) plus three doc sub-status columns (eoi_doc_status, reservation_doc_status, contract_doc_status) that track sent/signed within a single stage instead of branching it. Schema (migration 0062): - interests gains assigned_to, deposit_expected_amount/currency, three doc-status columns, two documenso-id columns, and date_reservation_signed. - New tables: qualification_criteria (per-port admin-configurable), interest_qualifications (per-interest state), payments (deposit / balance / refund records keyed to interest + client). - Default qualification criteria seeded for every existing port. - Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into the new stage + doc-status + outcome shape. Migration 0063 adds interest_contact_log.voice_transcript and template_used columns for v1.1-A/B (quick-template buttons + voice transcription via Web Speech API). v1.1 phase work bundled here: - A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on the contact-log compose dialog (useVoiceTranscription hook). - C: berth-rules-engine wraps state writes in pg_advisory_xact_lock with an idempotent re-read; emits rule_evaluated audit traces. - D: Documenso webhook: reservation/contract sub-status stamping moved out of the PDF-download try-block so a download failure no longer swallows the stamp. New integration test coverage. - E: /admin/qualification-criteria CRUD page + admin component. - F: default_new_interest_owner exposed in System Settings. - G: recentActivityCount + active_engagement deal-pulse signal surfaced as a chip on interests + hot-deals card. - H: interest_assigned notification on assignedTo change (skips self-assign, uses a dedupe key). Plus the supporting components: AssignedToChip, DealPulseChip, PaymentsSection, QualificationChecklist, MultiEoiChip, SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner, SupplementalInfoRequestButton, UserPicker. Tests: 1370/1370 vitest pass (added deal-health unit suite + expanded constants/validators/pipeline-transitions coverage). tsc clean, eslint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:39:21 +02:00
// 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 };
}