@sphoro/bugcatch-web-sdk 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +92 -5
  2. package/dist/bugcatch-call.es.js +586 -0
  3. package/dist/bugcatch-call.es.js.map +1 -0
  4. package/dist/bugcatch-call.umd.js +2 -0
  5. package/dist/bugcatch-call.umd.js.map +1 -0
  6. package/dist/bugcatch-core.es.js +3214 -0
  7. package/dist/bugcatch-core.es.js.map +1 -0
  8. package/dist/bugcatch-panel.es.js +2402 -0
  9. package/dist/bugcatch-panel.es.js.map +1 -0
  10. package/dist/bugcatch-panel.umd.js +1240 -0
  11. package/dist/bugcatch-panel.umd.js.map +1 -0
  12. package/dist/bugcatch.es.js +2 -5522
  13. package/dist/bugcatch.es.js.map +1 -1
  14. package/dist/bugcatch.standalone.umd.js +1536 -0
  15. package/dist/bugcatch.standalone.umd.js.map +1 -0
  16. package/dist/bugcatch.umd.js +89 -1183
  17. package/dist/bugcatch.umd.js.map +1 -1
  18. package/package.json +6 -6
  19. package/src/agent-issues.js +30 -0
  20. package/src/announcements.js +147 -0
  21. package/src/busy.js +18 -0
  22. package/src/call.js +7 -0
  23. package/src/capture.js +9 -0
  24. package/src/chunks.js +56 -0
  25. package/src/gesture.js +33 -0
  26. package/src/icons.js +18 -0
  27. package/src/identity.js +14 -9
  28. package/src/incoming-call.js +8 -30
  29. package/src/index.d.ts +50 -0
  30. package/src/index.js +170 -37
  31. package/src/lazy.js +23 -0
  32. package/src/notice.js +68 -0
  33. package/src/panel/call.js +242 -0
  34. package/src/panel/compose.js +416 -0
  35. package/src/panel/composer.js +235 -0
  36. package/src/panel/icons.js +34 -0
  37. package/src/panel/index.js +251 -0
  38. package/src/panel/known.js +130 -0
  39. package/src/panel/messages.js +176 -0
  40. package/src/panel/notices.js +34 -0
  41. package/src/panel/picks.js +61 -0
  42. package/src/panel/refs.js +321 -0
  43. package/src/panel/render.js +124 -0
  44. package/src/panel/signin.js +228 -0
  45. package/src/panel/styles.js +960 -0
  46. package/src/panel/thread.js +384 -0
  47. package/src/session.js +8 -3
  48. package/src/umd/call.js +6 -0
  49. package/src/umd/lazy.js +98 -0
  50. package/src/umd/panel.js +6 -0
  51. package/src/updates.js +20 -7
  52. package/src/widget.js +243 -3164
@@ -0,0 +1,61 @@
1
+ // Questions the assistant answers by itself, offered as one tap above the form.
2
+
3
+ import * as ICONS from './icons.js';
4
+ import { escapeHtml } from './render.js';
5
+
6
+ export function mountPicks(ctx) {
7
+ const {
8
+ client, cfg, titleInput, descInput, picks,
9
+ } = ctx;
10
+
11
+ // One tap instead of a sentence: the report's title becomes the question,
12
+ // and the assistant knows exactly what is being asked — it looks the
13
+ // reporter up, answers from their account, and asks whether that settled
14
+ // it. Tapping the chosen one again lets it go.
15
+ let agentIssues = [];
16
+ let pickedIssue = null;
17
+
18
+ function drawPicks() {
19
+ if (!agentIssues.length) { picks.hidden = true; picks.innerHTML = ''; return; }
20
+ picks.hidden = false;
21
+ picks.innerHTML = `
22
+ <p class="picks-h">Get an answer right away</p>
23
+ <div class="picks-grid" role="group" aria-label="Common questions">
24
+ ${agentIssues.map((issue) => `
25
+ <button type="button" class="pick" data-pick="${escapeHtml(issue.key)}"
26
+ aria-pressed="${pickedIssue?.key === issue.key}">
27
+ <span class="t">${escapeHtml(issue.title)}</span>
28
+ ${issue.hint ? `<span class="h">${escapeHtml(issue.hint)}</span>` : ''}
29
+ </button>`).join('')}
30
+ </div>
31
+ ${pickedIssue ? `<p class="picks-note">${ICONS.check}<span>Our assistant will check your account and reply here. Add anything else below, then send.</span></p>` : ''}`;
32
+ }
33
+
34
+ picks.addEventListener('click', (e) => {
35
+ const btn = e.target.closest('[data-pick]');
36
+ if (!btn) return;
37
+ const issue = agentIssues.find((i) => i.key === btn.dataset.pick);
38
+ if (!issue) return;
39
+ const titleWasPick = pickedIssue && titleInput.value.trim() === pickedIssue.title;
40
+ if (pickedIssue?.key === issue.key) {
41
+ pickedIssue = null;
42
+ if (titleWasPick) titleInput.value = '';
43
+ } else {
44
+ pickedIssue = issue;
45
+ // Their own words are never overwritten — only an empty title, or the
46
+ // one the previous tap put there.
47
+ if (!titleInput.value.trim() || titleWasPick) titleInput.value = issue.title;
48
+ }
49
+ titleInput.dispatchEvent(new Event('input', { bubbles: true }));
50
+ drawPicks();
51
+ if (pickedIssue) descInput.focus();
52
+ });
53
+
54
+ if (cfg.agentIssues !== false && typeof client.agentIssues === 'function') {
55
+ client.agentIssues()
56
+ .then((rows) => { agentIssues = rows; drawPicks(); })
57
+ .catch(() => { /* no tiles is the safe answer to every failure */ });
58
+ }
59
+
60
+ ctx.pickedIssue = () => pickedIssue;
61
+ }
@@ -0,0 +1,321 @@
1
+ // Which record is this about.
2
+ //
3
+ // What this project asks for, and what the reporter has answered.
4
+ //
5
+ // The question is asked at Send and not before, which is the whole design of
6
+ // it. A form that opens with "loan number" above "what went wrong" tells
7
+ // somebody they have paperwork to do before they may complain, and most of
8
+ // them will not — while the same question, asked once, after they have said
9
+ // their piece, and only when what they said did not already contain the
10
+ // answer, is a reasonable thing to be asked. Most reporters never see it: the
11
+ // loan number was in the sentence.
12
+
13
+ import * as ICONS from './icons.js';
14
+ import { readReferences, validateReference } from '../references.js';
15
+ import { escapeHtml } from './render.js';
16
+
17
+ export function mountReferences(ctx) {
18
+ const {
19
+ client, say, titleInput, descInput, sendBtn, refsBox,
20
+ } = ctx;
21
+
22
+ let refConfig = { policy: 'off', fields: [] };
23
+ let refOpen = false;
24
+ let refChoice = null; // which field they are answering; null = not picked yet
25
+ let refValue = '';
26
+ let refError = '';
27
+ // What we read in their text but could not place for sure, offered as taps:
28
+ // [{ value, kinds: [{ key, label }] }]. `refMode` says why the card is open —
29
+ // 'confirm' when the text had two answers, 'suggest' when it had an unlabelled
30
+ // one, 'ask' when it had none.
31
+ let refCandidates = [];
32
+ let refMode = 'ask';
33
+ // Set once they have answered or explicitly skipped, so Send stops asking.
34
+ let refSettled = false;
35
+ // Set once we have said "that does not look right" about the current value.
36
+ // Its own flag rather than reusing refSettled, which was the first attempt
37
+ // and was a loop: the warning returns early, the next press re-validates the
38
+ // same unchanged value, warns again, and the report can never be sent. One
39
+ // warning per value, and then it is their call.
40
+ let refWarned = false;
41
+
42
+ client.referenceFields()
43
+ .then((cfg) => { refConfig = cfg; showFound(); })
44
+ .catch(() => { /* off is the safe answer to every failure */ });
45
+
46
+ /** What the text says: found (sure), conflicts (ask which), loose (offer). */
47
+ function readText() {
48
+ if (refConfig.policy === 'off' || !refConfig.fields.length) return { found: [], conflicts: [], loose: [] };
49
+ return readReferences(refConfig.fields, [titleInput.value, descInput.value]);
50
+ }
51
+
52
+ /** Sure enough to send without a question: something found, nothing contested. */
53
+ function textAnswers(read = readText()) {
54
+ return read.found.length > 0 && read.conflicts.length === 0;
55
+ }
56
+
57
+ /** The answer to send, or an empty list. */
58
+ function answeredReference() {
59
+ if (!refChoice || !refValue.trim()) return [];
60
+ return [{ key: refChoice, value: refValue.trim().slice(0, 120) }];
61
+ }
62
+
63
+ // A quiet line under the description while they type, so somebody who put
64
+ // their loan number in the sentence can see it was read and does not go
65
+ // looking for a box to type it into again.
66
+ const foundNote = document.createElement('p');
67
+ foundNote.className = 'ref-found';
68
+ foundNote.setAttribute('aria-live', 'polite');
69
+ foundNote.hidden = true;
70
+ descInput.closest('.field').after(foundNote);
71
+ let foundTimer;
72
+ function showFound() {
73
+ const read = readText();
74
+ if (textAnswers(read)) {
75
+ const list = read.found.slice(0, 2).map((r) => `${escapeHtml(r.label)} <b>${escapeHtml(shortId(r.value))}</b>`).join(', ');
76
+ foundNote.innerHTML = `${ICONS.check}<span>Found your ${list} — no need to add it again.</span>`;
77
+ foundNote.hidden = false;
78
+ // They have answered in the text; a card still asking would be not listening.
79
+ if (refOpen && !answeredReference().length) { refOpen = false; drawRefs(); }
80
+ } else {
81
+ foundNote.hidden = true;
82
+ foundNote.innerHTML = '';
83
+ }
84
+ }
85
+ const queueFound = () => { clearTimeout(foundTimer); foundTimer = setTimeout(showFound, 250); };
86
+ titleInput.addEventListener('input', queueFound);
87
+ descInput.addEventListener('input', queueFound);
88
+
89
+ /** A UUID is long; the chip shows its ends, the title shows all of it. */
90
+ function shortId(value) {
91
+ return value.length > 20 ? `${value.slice(0, 8)}…${value.slice(-4)}` : value;
92
+ }
93
+
94
+ function candidateFor(value) {
95
+ const v = value.trim().toLowerCase();
96
+ return refCandidates.find((c) => c.value.toLowerCase() === v) || null;
97
+ }
98
+
99
+ /** Pick a candidate: its value, and its kind when only one kind fits. */
100
+ function takeCandidate(c) {
101
+ refValue = c.value;
102
+ if (c.kinds.length === 1) refChoice = c.kinds[0].key;
103
+ else if (!c.kinds.some((k) => k.key === refChoice)) refChoice = null;
104
+ refError = '';
105
+ refWarned = false;
106
+ }
107
+
108
+ function drawRefs() {
109
+ if (!refOpen) { refsBox.hidden = true; refsBox.innerHTML = ''; return; }
110
+ const field = refConfig.fields.find((f) => f.key === refChoice) || null;
111
+ const optional = refConfig.policy !== 'required';
112
+ const current = candidateFor(refValue);
113
+ // Kinds worth offering: the ones the picked value could be, when we know;
114
+ // every kind otherwise. Never zero — a guess of ours must not lock out the
115
+ // right answer.
116
+ const kinds = current && current.kinds.length > 1
117
+ ? refConfig.fields.filter((f) => current.kinds.some((k) => k.key === f.key))
118
+ : refConfig.fields;
119
+ // Two loan numbers: the kind is not in question, only which one — so the
120
+ // kind chips would be a second question nobody needs to answer.
121
+ const sharedKind = refCandidates.length > 1
122
+ && refCandidates.every((c) => c.kinds.length === 1 && c.kinds[0].key === refCandidates[0].kinds[0].key);
123
+
124
+ const head = {
125
+ confirm: refCandidates.length > 1
126
+ ? ['Which one is this about?', 'We found more than one ID in your message. Pick the one this problem is about.']
127
+ : ['Is this the right ID?', 'This could be more than one kind of ID. Tell us which it is.'],
128
+ suggest: ['Is this your ID?', 'We spotted this in your message. Tell us what it is so we can find your record.'],
129
+ ask: optional
130
+ ? ['One more thing', 'So we can find your record — this helps us answer faster.']
131
+ : ['We need one detail', 'We need one of these to look this up.'],
132
+ }[refMode];
133
+
134
+ refsBox.hidden = false;
135
+ // A handful of kinds read best as chips to tap; a long list goes back to
136
+ // a select so the card does not turn into a wall of pills.
137
+ const many = kinds.length > 6;
138
+ refsBox.innerHTML = `
139
+ <div class="ask-head">
140
+ <span class="avatar" aria-hidden="true">${ICONS.sparkle}</span>
141
+ <div>
142
+ <strong>${escapeHtml(head[0])}</strong>
143
+ <p class="why">${escapeHtml(head[1])}</p>
144
+ </div>
145
+ </div>
146
+ ${refCandidates.length > 1 ? `
147
+ <div class="chips cands" role="group" aria-label="IDs found in your message">
148
+ ${refCandidates.map((c, i) => `
149
+ <button type="button" data-ref-cand="${i}" title="${escapeHtml(c.value)}"
150
+ aria-pressed="${!!current && current === c}">${escapeHtml(shortId(c.value))}</button>
151
+ `).join('')}
152
+ </div>` : ''}
153
+ ${kinds.length > 1 && !many && !sharedKind ? `
154
+ <div class="chips" role="group" aria-label="Which detail you are giving">
155
+ ${kinds.map((f) => `
156
+ <button type="button" data-ref-kind="${escapeHtml(f.key)}" aria-pressed="${!!field && f.key === field.key}">${escapeHtml(f.label)}</button>
157
+ `).join('')}
158
+ </div>` : ''}
159
+ <div class="row">
160
+ ${many ? `
161
+ <select class="ref-kind" aria-label="Which detail you are giving">
162
+ ${field ? '' : '<option value="" selected disabled>What is it?</option>'}
163
+ ${kinds.map((f) => `
164
+ <option value="${escapeHtml(f.key)}" ${field && f.key === field.key ? 'selected' : ''}>${escapeHtml(f.label)}</option>
165
+ `).join('')}
166
+ </select>` : ''}
167
+ <input class="ref-value${refError ? ' invalid' : ''}" type="${field?.inputMode === 'email' ? 'email' : 'text'}"
168
+ inputmode="${escapeHtml(field?.inputMode === 'numeric' || field?.inputMode === 'tel' ? 'numeric' : 'text')}"
169
+ value="${escapeHtml(refValue)}"
170
+ placeholder="${escapeHtml(field ? (field.placeholder || field.label) : 'Your ID')}"
171
+ aria-label="${escapeHtml(field ? field.label : 'Your ID')}" ${refError ? 'aria-invalid="true"' : ''} />
172
+ </div>
173
+ ${field?.hint ? `<p class="hint">${escapeHtml(field.hint)}</p>` : ''}
174
+ ${refError ? `<p class="field-err" role="alert">${ICONS.alert}<span>${escapeHtml(refError)}</span></p>` : ''}
175
+ ${optional ? `<button type="button" class="link" data-act="ref-skip">${
176
+ refMode === 'ask' ? 'I don\'t have this — send without it' : 'Not sure — send as it is'}</button>` : ''}`;
177
+
178
+ refsBox.querySelectorAll('[data-ref-cand]').forEach((chip) => {
179
+ chip.addEventListener('click', () => {
180
+ takeCandidate(refCandidates[Number(chip.dataset.refCand)]);
181
+ drawRefs();
182
+ });
183
+ });
184
+ refsBox.querySelectorAll('[data-ref-kind]').forEach((chip) => {
185
+ chip.addEventListener('click', () => {
186
+ refChoice = chip.dataset.refKind;
187
+ refError = '';
188
+ refWarned = false;
189
+ drawRefs();
190
+ });
191
+ });
192
+ const value = refsBox.querySelector('.ref-value');
193
+ value.addEventListener('input', () => {
194
+ refValue = value.value;
195
+ refError = '';
196
+ // A changed value has not been warned about yet.
197
+ refWarned = false;
198
+ });
199
+ // Enter in the box is the same gesture as pressing Send, which is what
200
+ // somebody who has just been asked one question expects it to be.
201
+ value.addEventListener('keydown', (e) => {
202
+ if (e.key === 'Enter') { e.preventDefault(); sendBtn.click(); }
203
+ });
204
+ refsBox.querySelector('.ref-kind')?.addEventListener('change', (e) => {
205
+ refChoice = e.target.value;
206
+ refError = '';
207
+ drawRefs();
208
+ refsBox.querySelector('.ref-value')?.focus();
209
+ });
210
+ refsBox.querySelector('[data-act="ref-skip"]')?.addEventListener('click', () => {
211
+ // Skipping is answering. Without this the panel asks again on the next
212
+ // press and the reporter is in a loop with a button that does nothing.
213
+ // The text still goes; the server reads it either way.
214
+ refSettled = true;
215
+ refOpen = false;
216
+ refChoice = null;
217
+ refValue = '';
218
+ refError = '';
219
+ refWarned = false;
220
+ drawRefs();
221
+ say('');
222
+ });
223
+
224
+ // Focus where the next decision is: the kind, when that is all that is
225
+ // missing; the box otherwise.
226
+ const next = !field && refValue ? refsBox.querySelector('[data-ref-kind], .ref-kind') : null;
227
+ (next || value).focus();
228
+ }
229
+
230
+ /**
231
+ * Open the question, pre-filled with the best of what the text offered.
232
+ *
233
+ * Conflicts first — the text said two things and only the reporter knows
234
+ * which — then loose IDs, which are probably the answer and only need a
235
+ * name. Nothing, and it is the plain question it always was.
236
+ */
237
+ function openRefs(read) {
238
+ refCandidates = read.conflicts.length ? read.conflicts : read.loose.slice(0, 4);
239
+ refMode = read.conflicts.length ? 'confirm' : read.loose.length ? 'suggest' : 'ask';
240
+ refOpen = true;
241
+ if (refCandidates.length) {
242
+ // One contested value: pre-fill it so they only choose what it is. Two
243
+ // or more: pick nothing, because picking is the question.
244
+ if (refCandidates.length === 1) takeCandidate(refCandidates[0]);
245
+ else {
246
+ refValue = '';
247
+ const [first] = refCandidates;
248
+ const same = refCandidates.every((c) => c.kinds.length === 1 && c.kinds[0].key === first.kinds[0].key);
249
+ refChoice = same ? first.kinds[0].key : null;
250
+ }
251
+ } else {
252
+ refChoice = refChoice || refConfig.fields[0].key;
253
+ }
254
+ drawRefs();
255
+ }
256
+
257
+ /**
258
+ * Whether Send should stop and ask.
259
+ *
260
+ * Never when the text answered it outright. Once when the text said two
261
+ * things or something unlabelled — on every policy, because sending the
262
+ * wrong record's id with confidence is worse than one extra tap. And the
263
+ * plain question on `optional`/`required` projects as before.
264
+ */
265
+ function needsReference() {
266
+ if (refConfig.policy === 'off' || !refConfig.fields.length) return false;
267
+ if (answeredReference().length) return false;
268
+ const read = readText();
269
+ if (textAnswers(read)) return false;
270
+ if (refSettled && refConfig.policy !== 'required') return false;
271
+ return read;
272
+ }
273
+
274
+ /**
275
+ * Whether Send should stop here: true when it asked, or warned, instead.
276
+ *
277
+ * Ask, if this project asks and nothing they wrote answered it. The first
278
+ * press opens the question rather than sending; the second sends. That
279
+ * costs one extra press on projects that want an identifier, and it is the
280
+ * only shape that does not put paperwork in front of a person who came here
281
+ * to say something is broken.
282
+ */
283
+ function holdForReference() {
284
+ // First, though: a value in the box but no kind picked. Asked rather than
285
+ // guessed — a wrong kind joins on the wrong table.
286
+ if (refOpen && refValue.trim() && !refChoice) {
287
+ refError = 'Pick what this ID is.';
288
+ drawRefs();
289
+ return true;
290
+ }
291
+
292
+ const read = needsReference();
293
+ if (read) {
294
+ if (!refOpen) openRefs(read); else drawRefs();
295
+ say('');
296
+ return true;
297
+ }
298
+
299
+ // They chose a field and typed something that is not that shape. Advisory
300
+ // on the server — a value that fails is still what they believe identifies
301
+ // them, and it is kept — but worth one chance to fix a typo here, where the
302
+ // number is still on screen.
303
+ if (refChoice && refValue.trim()) {
304
+ const field = refConfig.fields.find((f) => f.key === refChoice);
305
+ if (!refWarned && field && !validateReference(field, refValue)) {
306
+ refError = `That does not look like a ${field.label.toLowerCase()}. Press Send again to use it anyway.`;
307
+ // Once, and then it is their call. Our idea of the shape of somebody's
308
+ // account number is not better than theirs, and refusing outright is
309
+ // how a genuine reference gets lost — so the second press sends it and
310
+ // the API keeps it, marked as not matching the expected format.
311
+ refWarned = true;
312
+ drawRefs();
313
+ return true;
314
+ }
315
+ }
316
+ return false;
317
+ }
318
+
319
+ ctx.holdForReference = holdForReference;
320
+ ctx.answeredReference = answeredReference;
321
+ }
@@ -0,0 +1,124 @@
1
+ // Markup the panel's screens share: the header, empty and loading states, and
2
+ // the small formatting helpers every screen reaches for — dates, statuses and
3
+ // escaping. Pure functions of their arguments; nothing here touches the DOM
4
+ // except the two scroll helpers, which are handed the element they move.
5
+
6
+ import * as ICONS from './icons.js';
7
+
8
+ /**
9
+ * The panel's header, on every screen. The assistant's avatar carries the
10
+ * identity; `back` adds the way out of a report to the list of them.
11
+ */
12
+ export function headHtml({ title, sub, id = '', back = false }) {
13
+ return `
14
+ <div class="head">
15
+ ${back ? `<button class="icon-btn back" type="button" data-act="back" aria-label="Back to your reports">${ICONS.back}</button>` : ''}
16
+ <div class="mark" aria-hidden="true">${ICONS.sparkle}</div>
17
+ <div class="titles">
18
+ <h2${id ? ` id="${id}"` : ''}>${escapeHtml(title)}</h2>
19
+ <p>${escapeHtml(sub)}</p>
20
+ </div>
21
+ <button class="icon-btn" type="button" data-act="close" aria-label="Close">${ICONS.close}</button>
22
+ </div>`;
23
+ }
24
+
25
+ export function emptyHtml(text, icon) {
26
+ return `<div class="empty"><span class="empty-ico" aria-hidden="true">${icon}</span>${escapeHtml(text)}</div>`;
27
+ }
28
+
29
+ /** Placeholder rows while the list loads — the shape of what is coming. */
30
+ export function skeletonRows(n) {
31
+ return Array.from({ length: n }, () => `
32
+ <div class="row" aria-hidden="true">
33
+ <span class="row-ico"></span>
34
+ <span class="who"><span class="skel" style="width:70%"></span><span class="skel" style="width:40%;height:9px"></span></span>
35
+ </div>`).join('') + '<span class="sr-only">Loading…</span>';
36
+ }
37
+
38
+ export function skeletonBubbles() {
39
+ return `
40
+ <div aria-hidden="true" style="display:flex;flex-direction:column;gap:10px;padding-top:6px">
41
+ <span class="skel" style="width:42%;height:34px;border-radius:16px;align-self:center"></span>
42
+ <span class="skel" style="width:66%;height:52px;border-radius:18px"></span>
43
+ <span class="skel" style="width:48%;height:38px;border-radius:18px;align-self:flex-end"></span>
44
+ </div>
45
+ <span class="sr-only">Loading…</span>`;
46
+ }
47
+
48
+ export function initials(name) {
49
+ const parts = String(name).trim().split(/\s+/).filter(Boolean);
50
+ if (!parts.length) return '?';
51
+ return (parts[0][0] + (parts.length > 1 ? parts[parts.length - 1][0] : '')).toUpperCase();
52
+ }
53
+
54
+ export function dayKey(iso) {
55
+ const d = new Date(iso);
56
+ return Number.isFinite(d.getTime()) ? `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}` : null;
57
+ }
58
+
59
+ /** "Today" / "Yesterday" / "Mon, 14 Sep" — the label on a day separator. */
60
+ export function dayLabel(iso) {
61
+ const d = new Date(iso);
62
+ const today = new Date();
63
+ const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1);
64
+ if (dayKey(d) === dayKey(today)) return 'Today';
65
+ if (dayKey(d) === dayKey(yesterday)) return 'Yesterday';
66
+ try {
67
+ return d.toLocaleDateString(undefined, {
68
+ weekday: 'short', day: 'numeric', month: 'short',
69
+ ...(d.getFullYear() !== today.getFullYear() ? { year: 'numeric' } : {}),
70
+ });
71
+ } catch {
72
+ return relativeDay(iso);
73
+ }
74
+ }
75
+
76
+ /** "14:05" or "2:05 PM", in the reader's own convention. */
77
+ export function clockTime(iso) {
78
+ const d = new Date(iso);
79
+ if (!Number.isFinite(d.getTime())) return '';
80
+ try {
81
+ return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
82
+ } catch {
83
+ return '';
84
+ }
85
+ }
86
+
87
+ /** Whether the reader is at the foot of the thread, within a line or two. */
88
+ export function atLatest(thread) {
89
+ return thread.scrollHeight - thread.scrollTop - thread.clientHeight < 40;
90
+ }
91
+
92
+ export function scrollToLatest(thread, { smooth = false } = {}) {
93
+ const calm = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
94
+ if (smooth && !calm && thread.scrollTo) thread.scrollTo({ top: thread.scrollHeight, behavior: 'smooth' });
95
+ else thread.scrollTop = thread.scrollHeight;
96
+ }
97
+
98
+ /** Status as a word a reporter would use, not the enum the database stores. */
99
+ export function statusWord(status) {
100
+ return {
101
+ OPEN: 'Open',
102
+ IN_PROGRESS: 'In progress',
103
+ RESOLVED: 'Fixed',
104
+ CLOSED: 'Closed',
105
+ }[status] || String(status || '').toLowerCase();
106
+ }
107
+
108
+ /** "today" / "3 days ago" — enough for a history list, no library. */
109
+ export function relativeDay(iso) {
110
+ const then = new Date(iso).getTime();
111
+ if (!Number.isFinite(then)) return '';
112
+ const days = Math.floor((Date.now() - then) / 86400000);
113
+ if (days <= 0) return 'today';
114
+ if (days === 1) return 'yesterday';
115
+ if (days < 30) return `${days} days ago`;
116
+ const months = Math.floor(days / 30);
117
+ return months === 1 ? 'a month ago' : `${months} months ago`;
118
+ }
119
+
120
+ export function escapeHtml(s) {
121
+ return String(s).replace(/[&<>"']/g, (c) => (
122
+ { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
123
+ ));
124
+ }