All files / src/components/crm lead-status.ts

100% Statements 17/17
100% Branches 13/13
100% Functions 2/2
100% Lines 12/12

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88                                                                                                    13x 13x 13x       13x 13x                               509x 442x 389x 271x 35x 29x         1901x        
/**
 * A lead's printed status code.
 *
 * **A status is a printed code, never a pill** — 2px radius, `.label` caps,
 * status ink on its own `-light` tint. `components/ui/status-badge.tsx` is
 * that primitive; this module decides WHICH code a lead carries, so the board
 * card, the table row and the overdue band cannot drift apart.
 *
 * THE FIVE CODES, from `.context/mockups/leads-board.html`
 *
 *   WON      terminal won stage ........... success
 *   LOST     terminal lost stage .......... graphite (the mockup's `-archived`)
 *   OVERDUE  an open follow-up past due ... error
 *   NEW      sitting in the entry stage ... see the note below
 *   WAITING  working, with a follow-up set  warning
 *
 * Order matters and is deliberate: a closed lead reads closed even if it has
 * a stale reminder on it, and an overdue follow-up outranks "this is new"
 * because the overdue band is the thing the pro is being asked to act on.
 * A lead in a working stage with nothing scheduled carries no code at all —
 * the Job Book does not print a code that says "nothing to report".
 *
 * ONE DEVIATION FROM THE MOCKUP, STATED
 * The mockup's NEW code is `--action` ink on `--primary-100`. The portal's
 * printed-code primitive carries five ink families (success / warning / error
 * / info / default) and has no `action`; adding one means editing
 * `components/ui/status-ink.ts`, which this change does not own, and whose
 * AA ratios are measured by `status-ink.test.ts` per family. NEW therefore
 * prints in `info`, which is where `StatusBadge`'s own word list already sent
 * the string "new". It is a hue deviation, not an information one.
 *
 * `UNREAD` (the mockup's sixth code, for a homeowner reply on the inquiry
 * thread) is not derivable: the kanban payload carries no per-lead unread
 * count. Deliberately omitted rather than faked — see the task notes.
 */
 
import type { StatusTone } from "../ui/status-ink";
import { isLeadOverdue, type LeadWithReminder } from "./lead-overdue";
 
/** What `StatusBadge` needs to print one code. */
export interface LeadStatusCode {
	/** The word, as printed. `StatusBadge` applies the `.label` caps. */
	code: string;
	/** The ink family. */
	tone: StatusTone;
}
 
/** The stage shape this module reads — just its type discriminator. */
export type StageLike = { stageType: string };
 
export const LEAD_STATUS_WON: LeadStatusCode = { code: "Won", tone: "success" };
export const LEAD_STATUS_LOST: LeadStatusCode = { code: "Lost", tone: "default" };
export const LEAD_STATUS_OVERDUE: LeadStatusCode = {
	code: "Overdue",
	tone: "error",
};
export const LEAD_STATUS_NEW: LeadStatusCode = { code: "New", tone: "info" };
export const LEAD_STATUS_WAITING: LeadStatusCode = {
	code: "Waiting",
	tone: "warning",
};
 
/**
 * The one code this lead carries, or null when it carries none.
 *
 * `now` is injectable for the same reason it is on `isLeadOverdue`: one
 * reading of the clock per render, so a list cannot disagree with itself.
 */
export function leadStatusCode(
	lead: LeadWithReminder & { nextReminderDueAt?: string | null },
	stage: StageLike | undefined,
	now: number = Date.now(),
): LeadStatusCode | null {
	if (stage?.stageType === "system_terminal_won") return LEAD_STATUS_WON;
	if (stage?.stageType === "system_terminal_lost") return LEAD_STATUS_LOST;
	if (isLeadOverdue(lead, now)) return LEAD_STATUS_OVERDUE;
	if (stage?.stageType === "system_entry") return LEAD_STATUS_NEW;
	if (lead.nextReminderDueAt) return LEAD_STATUS_WAITING;
	return null;
}
 
/** True for the two terminal stages — a closed column reads back, not forward. */
export function isTerminalStage(stageType: string): boolean {
	return (
		stageType === "system_terminal_won" || stageType === "system_terminal_lost"
	);
}