All files / src/components/whatsapp SlaStrip.tsx

100% Statements 21/21
100% Branches 34/34
100% Functions 5/5
100% Lines 21/21

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 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246                                                                                                  3x           3x           3x   3x                               3x   3x 3x                                                             83x   83x       83x 83x 83x 83x   83x                                                 2x                                                                                         2x                                           30x 30x 30x     30x                           249x                    
import { AlertTriangle, CheckCircle2, Clock, Search } from "lucide-react";
import { cn } from "../../lib/utils";
import { Input } from "../ui/input";
import {
	STATUS_BAND_TINT,
	STATUS_CODE_TYPE,
	STATUS_INK,
	type StatusTone,
} from "../ui/status-ink";
import {
	elapsedMinutes,
	elapsedSince,
	formatAgeLong,
	formatAgeShort,
	SLA_BREACH_MINUTES,
	type SlaLevel,
	slaLevel,
} from "../../lib/whatsapp/triage";
 
/**
 * The inbox's figure rail: WAITING ON US · OLDEST · ANSWERED TODAY, then one
 * sentence that says what the figures mean and offers to jump to the row.
 *
 * EVERY FIGURE HERE IS SERVER-OWNED, AND THAT IS THE POINT
 * `waitingCount` is the `total` of `GET /conversations?waitingOnUs=true` and
 * `answeredToday` / `oldestWaitingAt` come off `GET /stats`. None of the three
 * is counted in the browser, because this list is paginated: a count over the
 * visible 25 rows describes the page, not the inbox, and labelling it "waiting
 * on us" would be a lie that looks like a feature. `unreadCount` cannot stand
 * in for any of them either — the inbox zeroes it the moment a thread is
 * selected, so merely looking would destroy the signal.
 *
 * `role="status"`, NOT `role="alert"`. The list refetches on a 30-second
 * staleTime and the thread polls every ten seconds; an assertive live region
 * would interrupt the operator mid-reply on every tick. The jump button sits
 * OUTSIDE the live region so a polite re-announcement never re-reads a
 * control.
 *
 * Nothing here is colour-only: each level pairs an icon with words.
 */
 
/**
 * The strip's own band tone, by how breached the oldest wait is.
 *
 * `ok` is the NEUTRAL ground, not an info tint: a band that is coloured even
 * when everything is fine teaches the operator to stop reading it, and the
 * mockup's `.notice` is grey card stock for exactly that reason. Colour is
 * spent on warn and breach only.
 */
const LEVEL_TONE: Record<SlaLevel, StatusTone> = {
	ok: "default",
	warn: "warning",
	breach: "error",
};
 
const LEVEL_ICON: Record<SlaLevel, typeof Clock> = {
	ok: Clock,
	warn: Clock,
	breach: AlertTriangle,
};
 
const SLA_HOURS = SLA_BREACH_MINUTES / 60;
 
const LEVEL_HEADLINE: Record<SlaLevel, string> = {
	ok: `Within the ${SLA_HOURS}-hour reply target`,
	warn: `Approaching the ${SLA_HOURS}-hour reply target`,
	breach: `Over the ${SLA_HOURS}-hour reply target`,
};
 
/**
 * Said when people ARE waiting but the oldest instant is not known — `/stats`
 * still loading, or failed.
 *
 * It exists because the alternative is worse than useless. `oldestWaitingAt`
 * and the count come from two different queries, so the age can be absent
 * while the count is six; printing the `ok` headline then would announce
 * "within the 4-hour reply target" over a backlog that may be a day old. A
 * figure we do not have is said to be missing, never assumed to be fine.
 */
const UNKNOWN_AGE_HEADLINE = "Oldest wait unknown";
 
const CAPTION = "text-foreground-muted";
const FIGURE = "text-sm font-semibold tabular-nums text-foreground-default";
 
export type SlaStripProps = {
	/** Inbox-wide count of conversations with nothing outbound since their last inbound. */
	waitingCount: number;
	/** ISO instant the oldest unanswered contact wrote, or null on an empty backlog. */
	oldestWaitingAt: string | null;
	/** Replies a human sent today, IST. Undefined while stats are loading. */
	answeredToday: number | undefined;
	/** Who is waiting longest, for the sentence and the jump. */
	oldest?: { id: number | null; name: string } | null;
	onJump?: (id: number) => void;
	search: string;
	onSearchChange: (value: string) => void;
	/** `Date.now()` as one value, so every figure on screen agrees. */
	now: Date;
};
 
export function SlaStrip({
	waitingCount,
	oldestWaitingAt,
	answeredToday,
	oldest,
	onJump,
	search,
	onSearchChange,
	now,
}: SlaStripProps) {
	// Through `elapsedSince` rather than inline arithmetic: it clamps clock
	// skew to zero and returns null for an unparseable instant, so a bad value
	// prints an em dash instead of "NaNm".
	const waitingMs = elapsedSince(oldestWaitingAt, now);
	const level: SlaLevel =
		waitingMs === null ? "ok" : slaLevel(elapsedMinutes(waitingMs));
	// An empty backlog is its own state, and it outranks the age arithmetic:
	// the two queries behind this strip poll independently, so a stale
	// `oldestWaitingAt` must never paint "Inbox zero" red.
	const empty = waitingCount === 0;
	const tone: StatusTone = empty ? "success" : LEVEL_TONE[level];
	const Icon = empty ? CheckCircle2 : LEVEL_ICON[level];
	const jumpId = oldest?.id ?? null;
 
	return (
		<div className="border-b border-border-default">
			<div className="flex flex-wrap items-center gap-x-6 gap-y-2 px-4 py-2">
				<Figure caption="Waiting on us" value={String(waitingCount)} />
				<Figure
					caption="Oldest"
					value={waitingMs === null ? "—" : formatAgeShort(waitingMs)}
					spoken={
						waitingMs === null
							? undefined
							: `Waiting ${formatAgeLong(waitingMs)}`
					}
				/>
				<Figure
					caption="Answered today"
					value={answeredToday === undefined ? "—" : String(answeredToday)}
				/>
				<div className="ml-auto flex min-w-48 items-center gap-2">
					<Search
						aria-hidden="true"
						className="h-4 w-4 shrink-0 text-foreground-subtle"
					/>
					<Input
						type="search"
						value={search}
						onChange={(event) => onSearchChange(event.target.value)}
						placeholder="Phone or name"
						aria-label="Search conversations by phone or name"
						className="h-8 pointer-coarse:min-h-11"
					/>
				</div>
			</div>
 
			<div
				className={cn(
					"flex flex-wrap items-center gap-x-2 gap-y-1 px-4 py-1.5 text-sm",
					STATUS_BAND_TINT[tone],
				)}
			>
				{/*
				 * Polite, and only the sentence. The figures above are in the same
				 * strip but outside this region: a screen-reader user does not need
				 * three numbers re-read every thirty seconds, they need to be told
				 * when the oldest wait crosses a line.
				 */}
				<p
					role="status"
					className="flex min-w-0 flex-wrap items-center gap-x-2 text-foreground-default"
				>
					<Icon
						aria-hidden="true"
						className={cn("h-4 w-4 shrink-0", STATUS_INK[tone])}
					/>
					<span className={cn(STATUS_CODE_TYPE, STATUS_INK[tone])}>
						{empty
							? "Inbox zero"
							: waitingMs === null
								? UNKNOWN_AGE_HEADLINE
								: LEVEL_HEADLINE[level]}
					</span>
					<span className="min-w-0">
						{empty
							? "Nothing is waiting on us."
							: describeBacklog(waitingCount, oldest?.name, waitingMs)}
					</span>
				</p>
 
				{!empty && jumpId !== null && onJump && (
					<button
						type="button"
						onClick={() => onJump(jumpId)}
						className="ml-auto h-6 rounded-sm px-2 text-xs font-semibold text-foreground-default underline-offset-4 hover:underline focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:outline-none pointer-coarse:min-h-11"
					>
						Jump to it
					</button>
				)}
			</div>
		</div>
	);
}
 
/**
 * The sentence. It names the person, spells the age out in prose rather than
 * in the chip's code form, and ends with the inbox-wide total — so the strip
 * is readable without decoding `4h12m`, and so "one person is late" cannot be
 * mistaken for "forty people are late".
 */
function describeBacklog(
	count: number,
	name: string | undefined,
	waitingMs: number | null,
): string {
	const who = name?.trim() || "The oldest contact";
	const age = waitingMs === null ? null : formatAgeLong(waitingMs);
	const lead = age
		? `${who} has been waiting ${age}`
		: `${who} is waiting for a reply`;
	return count === 1
		? `${lead}.`
		: `${lead}, ${count} unanswered in total.`;
}
 
function Figure({
	caption,
	value,
	spoken,
}: {
	caption: string;
	value: string;
	spoken?: string;
}) {
	return (
		<span className="flex items-baseline gap-2">
			<span className={cn(STATUS_CODE_TYPE, CAPTION)}>{caption}</span>
			<span className={FIGURE}>
				{value}
				{spoken && <span className="sr-only"> ({spoken})</span>}
			</span>
		</span>
	);
}