All files / src/lib/whatsapp triage.ts

100% Statements 58/58
100% Branches 40/40
100% Functions 11/11
100% Lines 47/47

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 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203                                                                                9x 9x     9x   9x 9x 9x                               294x 34x 34x 33x         186x       33x 12x 7x         153x     58x                       70x 70x 60x 58x 58x   2x 2x 2x                     86x 86x 72x 70x 70x 70x       2x 2x 2x                                                 65x 15x 4x 4x 2x   50x 28x 28x 27x 27x         28x         27x                               9x                 169x        
/**
 * The admin WhatsApp inbox's triage arithmetic — ages, the SLA level, the
 * 24-hour reply window, and the contact-type labels.
 *
 * WHY THIS MODULE EXISTS
 * Phase 1 deleted `pages/admin/rrm/inbox.tsx` (875 lines), which was the better
 * triage screen; `.context/briefs/whatsapp-inbox-triage.md` is the record of
 * its design. These are the helpers that brief marks "keep, with no change in
 * meaning" — `formatAgeShort`, `formatAgeLong`, `plural`, `slaLevel` — ported
 * here as pure functions so the page, the row and the reply box all spell one
 * number one way, and so their tests move rather than being rewritten thinner.
 *
 * NOTHING HERE READS THE CLOCK. Every function takes the elapsed milliseconds
 * or an explicit `now`, because a helper that calls `Date.now()` itself cannot
 * be tested without freezing time, and the page already has one `now` that
 * every figure on screen must agree with.
 */
 
/**
 * Two numbers, hardcoded in one place, with the reasoning attached.
 *
 * **These are a starting point and the business owns them.** The deleted RRM
 * inbox used 120/60, chosen for a cold-outreach campaign where a stale reply
 * auto-paused sending — a consequence that no longer exists, so the old
 * numbers carry no meaning here (brief §4.2). Nothing reads them from a config
 * table: `RRM_CONFIG_KEYS` never had an inbox or SLA key, and the one that is
 * dying is not a home for a live threshold.
 *
 * The replacements are argued from Meta's rule instead of from a campaign:
 *
 * - **WARN at 2 hours.** A homeowner who asks "is the quote ready" expects an
 *   answer in the same sitting. Two hours is the point at which a message has
 *   stopped being "in progress" and started being "missed".
 * - **BREACH at 4 hours.** Free-form replies are only legal for 24 hours after
 *   the contact's last message. A wait that reaches four hours in the evening
 *   is a wait that stands a real chance of crossing that deadline overnight, at
 *   which point the only way to reach the person is an approved template. The
 *   breach is therefore the point at which the cost of waiting changes in kind,
 *   not just in degree.
 */
export const SLA_WARN_MINUTES = 120;
export const SLA_BREACH_MINUTES = 240;
 
/** Meta's free-form reply window, counted from the contact's last message. */
export const REPLY_WINDOW_HOURS = 24;
 
const MS_PER_MINUTE = 60_000;
const MINUTES_PER_HOUR = 60;
const MINUTES_PER_DAY = 24 * MINUTES_PER_HOUR;
 
/** How breached a wait is. Three levels, so a tone map cannot go out of sync. */
export type SlaLevel = "ok" | "warn" | "breach";
 
/**
 * Elapsed milliseconds between an ISO instant and `now`, clamped at zero.
 *
 * Clamped because the browser clock is not the server's: a client a few
 * minutes behind would otherwise compute a negative age and print "-3m", which
 * reads as a bug rather than as clock skew.
 */
export function elapsedSince(
	iso: string | null | undefined,
	now: Date,
): number | null {
	if (!iso) return null;
	const then = new Date(iso).getTime();
	if (Number.isNaN(then)) return null;
	return Math.max(0, now.getTime() - then);
}
 
/** Elapsed milliseconds as whole minutes. */
export function elapsedMinutes(ms: number): number {
	return Math.floor(ms / MS_PER_MINUTE);
}
 
export function slaLevel(minutes: number): SlaLevel {
	if (minutes >= SLA_BREACH_MINUTES) return "breach";
	if (minutes >= SLA_WARN_MINUTES) return "warn";
	return "ok";
}
 
/** `1 hour` / `2 hours`, so prose ages read as English. */
export function plural(count: number, word: string): string {
	return `${count} ${word}${count === 1 ? "" : "s"}`;
}
 
const pad2 = (value: number): string => String(value).padStart(2, "0");
 
/**
 * The fixed-width age code that LEADS a conversation row: `22m`, `4h12m`,
 * `1d4h`.
 *
 * Minutes are zero-padded inside an hours form so that a column of codes lines
 * up on its right edge under tabular figures — which is the entire reason the
 * chip leads the row rather than trailing it. The operator scans that column
 * top-to-bottom hunting the number that is too big.
 */
export function formatAgeShort(ms: number): string {
	const minutes = elapsedMinutes(Math.max(0, ms));
	if (minutes < MINUTES_PER_HOUR) return `${minutes}m`;
	if (minutes < MINUTES_PER_DAY) {
		const hours = Math.floor(minutes / MINUTES_PER_HOUR);
		return `${hours}h${pad2(minutes % MINUTES_PER_HOUR)}m`;
	}
	const days = Math.floor(minutes / MINUTES_PER_DAY);
	const hours = Math.floor((minutes % MINUTES_PER_DAY) / MINUTES_PER_HOUR);
	return hours === 0 ? `${days}d` : `${days}d${hours}h`;
}
 
/**
 * The spoken form: `4 hours 12 minutes`.
 *
 * A screen reader hearing the chip must not hear "4 h 12 m". Every age printed
 * as a code is therefore also rendered `sr-only` in this form, and the SLA
 * strip uses it in prose.
 */
export function formatAgeLong(ms: number): string {
	const minutes = elapsedMinutes(Math.max(0, ms));
	if (minutes < MINUTES_PER_HOUR) return plural(minutes, "minute");
	if (minutes < MINUTES_PER_DAY) {
		const hours = Math.floor(minutes / MINUTES_PER_HOUR);
		const rest = minutes % MINUTES_PER_HOUR;
		return rest === 0
			? plural(hours, "hour")
			: `${plural(hours, "hour")} ${plural(rest, "minute")}`;
	}
	const days = Math.floor(minutes / MINUTES_PER_DAY);
	const hours = Math.floor((minutes % MINUTES_PER_DAY) / MINUTES_PER_HOUR);
	return hours === 0
		? plural(days, "day")
		: `${plural(days, "day")} ${plural(hours, "hour")}`;
}
 
/**
 * When the free-form reply window closes, or null once it has.
 *
 * `serverValue` is `windowExpiresAt` from `GET /conversations/:id`, which the
 * API returns as null once closed and never as a past instant — the deadline
 * governs whether a send is LEGAL, so the server owns it and a browser five
 * minutes fast must not offer a reply Meta will reject with 131047. The
 * fallback exists only for the list row, which carries
 * `lastCustomerMessageAt` but not the expiry.
 */
export function replyWindowExpiry(
	serverValue: string | null | undefined,
	lastCustomerMessageAt: string | null | undefined,
	now: Date,
): Date | null {
	// `null` AND `undefined` ARE DIFFERENT ANSWERS, and conflating them is the
	// bug this branch exists to prevent. `null` is the server saying "closed",
	// and is final. `undefined` is the server not having been asked — the list
	// row carries `lastCustomerMessageAt` but no expiry — and only then is the
	// browser's own 24-hour arithmetic used as a fallback.
	if (serverValue !== undefined) {
		if (!serverValue) return null;
		const expiry = new Date(serverValue);
		if (!Number.isNaN(expiry.getTime()) && expiry > now) return expiry;
		return null;
	}
	if (!lastCustomerMessageAt) return null;
	const last = new Date(lastCustomerMessageAt).getTime();
	if (Number.isNaN(last)) return null;
	const expiry = new Date(last + REPLY_WINDOW_HOURS * 60 * MS_PER_MINUTE);
	return expiry > now ? expiry : null;
}
 
/** `21h46m` left on the window — the same code form as an age. */
export function formatRemainingShort(expiry: Date, now: Date): string {
	return formatAgeShort(Math.max(0, expiry.getTime() - now.getTime()));
}
 
/** `21 hours 46 minutes` left — the spoken form. */
export function formatRemainingLong(expiry: Date, now: Date): string {
	return formatAgeLong(Math.max(0, expiry.getTime() - now.getTime()));
}
 
/**
 * The contact-type tag's words.
 *
 * `customer` prints as HOMEOWNER and `unknown` as UNMATCHED. This is a
 * UI-LAYER RELABEL ONLY — `WA_CONTACT_TYPES`, the `contactType` query filter,
 * the tab values and the API's auto-mapper all key on the string `"customer"`,
 * and renaming the column would be a migration for a caption.
 *
 * `unknown` is the auto-mapper's default and so the common case for a number
 * that matches neither a pro nor an inquiry. It gets a word of its own rather
 * than an empty slot, because "we do not know who this is" is information an
 * operator acts on.
 */
export const CONTACT_TYPE_LABEL = {
	pro: "Pro",
	customer: "Homeowner",
	unknown: "Unmatched",
} as const satisfies Record<string, string>;
 
export type WaContactType = keyof typeof CONTACT_TYPE_LABEL;
 
export function contactTypeLabel(type: string): string {
	return (
		CONTACT_TYPE_LABEL[type as WaContactType] ?? CONTACT_TYPE_LABEL.unknown
	);
}