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

100% Statements 42/42
100% Branches 35/35
100% Functions 7/7
100% Lines 30/30

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 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162                                                                                                                          1610x 465x 465x 462x                         1593x                     142x 517x       12x                                                       208x 208x   207x 207x 207x 207x   207x 198x 195x 190x 177x     9x 7x 4x 2x                         508x 508x   507x 507x 396x 396x 392x    
/**
 * THE ONE DEFINITION OF "OVERDUE" IN THE PORTAL.
 *
 * `/leads` surfaces overdue follow-ups above the board and `/` (Today) pulls
 * the same rows. Two surfaces disagreeing about what "overdue" means is the
 * worst possible bug here — a pro who clears Today and still sees two red
 * codes on Leads stops trusting both screens. So there is exactly one
 * definition and it lives in this file. **Today must import from here rather
 * than recompute it.**
 *
 * WHAT THE SERVER MEANS BY IT
 * `apps/api/src/dal/lead-reminders.dal.ts#countOverdueByProId` counts a
 * reminder as overdue when it is still open and its `dueAt` is in the past.
 * `apps/api/src/dal/leads.dal.ts#getReminderStatsForLeads` hands the kanban
 * payload `nextReminderDueAt` = the earliest `dueAt` among a lead's OPEN
 * reminders (`status IN ('upcoming', 'overdue')` — completed and dismissed
 * ones are excluded there, in SQL).
 *
 * So client-side, "this lead is overdue" is exactly:
 *
 *     nextReminderDueAt !== null && nextReminderDueAt < now
 *
 * — the same predicate the server applies, over a column the server already
 * filtered to open reminders. No second notion of open-ness is invented here,
 * and no second date is consulted (`lastContactedAt` recency is a different
 * thing and is deliberately NOT part of this).
 *
 * ONE KNOWN DIVERGENCE, NOT OURS TO FIX
 * `countOverdueByProId` matches `status = 'upcoming'` only, so a reminder the
 * notification cron has already flipped to `'overdue'` is missing from the
 * `/crm/reminders/counts` total while still being counted here (and by
 * `getReminderStatsForLeads`). That makes the API's badge count a floor, not a
 * ceiling. The rows on this page are derived from `nextReminderDueAt`, which
 * is the complete set — so the page prints `overdueLeads().length`, not the
 * counts endpoint, for anything a pro is asked to act on.
 */
 
/** The shape every overdue helper needs: a lead with its next open reminder. */
export type LeadWithReminder = {
	nextReminderDueAt?: string | null;
};
 
/**
 * THE predicate: is this due date in the past?
 *
 * The primitive form, for callers that hold the date rather than the lead —
 * Today's work queue builds its rows from `nextReminderDueAt` directly. It is
 * exported so that caller can import this instead of re-deriving the sign
 * test, which is how `/leads` and Today come to disagree.
 *
 * No open follow-up is not overdue; an unparseable date is not overdue; a
 * reminder due exactly now is not overdue *yet*.
 *
 * `now` is injectable so tests do not have to freeze the clock and so a list
 * render can take ONE reading of the clock for every row (otherwise a long
 * list can straddle a minute boundary and sort inconsistently with itself).
 */
export function isDueAtOverdue(
	dueAt: string | null | undefined,
	now: number = Date.now(),
): boolean {
	if (!dueAt) return false;
	const due = new Date(dueAt).getTime();
	if (Number.isNaN(due)) return false;
	return due < now;
}
 
/**
 * True when this lead has an open follow-up whose due date has passed.
 *
 * The lead-shaped form of {@link isDueAtOverdue}, and deliberately nothing
 * more than that — one predicate, two call shapes.
 */
export function isLeadOverdue(
	lead: LeadWithReminder,
	now: number = Date.now(),
): boolean {
	return isDueAtOverdue(lead.nextReminderDueAt, now);
}
 
/**
 * The overdue leads, oldest-due first — which is the order a pro should work
 * them in, and the order the mockup prints them in.
 */
export function overdueLeads<T extends LeadWithReminder>(
	leads: readonly T[],
	now: number = Date.now(),
): T[] {
	return leads
		.filter((lead) => isLeadOverdue(lead, now))
		.sort(
			(a, b) =>
				// Non-null by construction: isLeadOverdue rejected null above.
				new Date(a.nextReminderDueAt as string).getTime() -
				new Date(b.nextReminderDueAt as string).getTime(),
		);
}
 
/**
 * A due date as the Job Book prints it: "due 2 days ago", "due yesterday",
 * "due 5 hours ago", "due now", "due in 3 hours", "due tomorrow",
 * "due in 3 days".
 *
 * Why not `formatRelativeDate` from `lib/format-date.ts`: that helper is
 * past-only (it clamps the future to "Today"/"Just now") and prints the
 * compact "2d ago" spelling. A follow-up is a thing in the FUTURE until it
 * isn't, and the mockup spells the overdue row out in words. This is the only
 * bidirectional due-label in the portal; it does not add a second date
 * vocabulary, it is the only one for this direction.
 *
 * SUB-DAY GRANULARITY IS LOAD-BEARING, not decoration. The mockup only shows
 * whole days ("due 2 days ago"), and a first cut of this function rounded to
 * days — which printed "due today" for a call that had been overdue since
 * breakfast. A line that reads as not-overdue on a row whose status code says
 * OVERDUE is the worst of both. Hours are therefore spelled out below a day,
 * and the mockup's day spellings are kept exactly above it.
 */
export function formatDueLabel(
	dueAt: string,
	now: number = Date.now(),
): string {
	const due = new Date(dueAt).getTime();
	if (Number.isNaN(due)) return "";
 
	const delta = due - now;
	const minutes = Math.floor(Math.abs(delta) / 60_000);
	const hours = Math.floor(minutes / 60);
	const days = Math.floor(hours / 24);
 
	if (delta < 0) {
		if (minutes < 60) return "due now";
		if (hours < 24) return `due ${hours} ${hours === 1 ? "hour" : "hours"} ago`;
		if (days === 1) return "due yesterday";
		return `due ${days} days ago`;
	}
 
	if (minutes < 60) return "due within the hour";
	if (hours < 24) return `due in ${hours} ${hours === 1 ? "hour" : "hours"}`;
	if (days === 1) return "due tomorrow";
	return `due in ${days} days`;
}
 
/**
 * How long this lead has been in the book: "18 min", "5 h", "6 d".
 *
 * The mockup's Age column. Tabular figures and a space before the unit so a
 * column of them lines up under `.num`.
 */
export function formatLeadAge(
	dateCreated: string,
	now: number = Date.now(),
): string {
	const created = new Date(dateCreated).getTime();
	if (Number.isNaN(created)) return "";
 
	const minutes = Math.max(0, Math.floor((now - created) / 60_000));
	if (minutes < 60) return `${minutes} min`;
	const hours = Math.floor(minutes / 60);
	if (hours < 24) return `${hours} h`;
	return `${Math.floor(hours / 24)} d`;
}