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>
);
}
|