readyroom 0.0.0 → 0.1.2
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.
- package/README.md +358 -1
- package/dist/server.mjs +21559 -0
- package/dist/worker/760a79b513c62d05de332c66a928fb95896bc5d3-index.html +743 -0
- package/dist/worker/README.md +1 -0
- package/dist/worker/worker.js +19775 -0
- package/dist/worker/worker.js.map +8 -0
- package/package.json +43 -13
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>heads-up</title>
|
|
7
|
+
<style>
|
|
8
|
+
/* No external fonts, no external origins, no images fetched over the network.
|
|
9
|
+
Everything below is self-contained so the page renders with the network cut. */
|
|
10
|
+
|
|
11
|
+
:root {
|
|
12
|
+
--ink: #111;
|
|
13
|
+
--paper: #fff;
|
|
14
|
+
--muted: #555;
|
|
15
|
+
--line: #111;
|
|
16
|
+
--line-soft: #999;
|
|
17
|
+
--blocking: #000;
|
|
18
|
+
--low: #666;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
* { box-sizing: border-box; }
|
|
22
|
+
|
|
23
|
+
html, body {
|
|
24
|
+
margin: 0;
|
|
25
|
+
padding: 0;
|
|
26
|
+
background: var(--paper);
|
|
27
|
+
color: var(--ink);
|
|
28
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
|
29
|
+
font-size: 16px;
|
|
30
|
+
line-height: 1.45;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
body { padding: 16px; max-width: 1100px; margin: 0 auto; }
|
|
34
|
+
|
|
35
|
+
h1 { font-size: 1.25rem; margin: 0 0 4px 0; letter-spacing: 0.02em; }
|
|
36
|
+
h2 { font-size: 1rem; margin: 24px 0 8px 0; text-transform: uppercase; letter-spacing: 0.08em; border-bottom: 2px solid var(--line); padding-bottom: 4px; }
|
|
37
|
+
h3 { font-size: 0.95rem; margin: 0 0 8px 0; }
|
|
38
|
+
|
|
39
|
+
.sub { color: var(--muted); font-size: 0.85rem; margin: 0 0 12px 0; }
|
|
40
|
+
|
|
41
|
+
/* Status + error banners: text carries the meaning, not colour. */
|
|
42
|
+
.banner {
|
|
43
|
+
border: 2px solid var(--line);
|
|
44
|
+
padding: 8px 10px;
|
|
45
|
+
margin: 8px 0;
|
|
46
|
+
font-size: 0.85rem;
|
|
47
|
+
}
|
|
48
|
+
.banner[hidden] { display: none; }
|
|
49
|
+
.banner .tag { font-weight: bold; text-transform: uppercase; letter-spacing: 0.06em; }
|
|
50
|
+
|
|
51
|
+
#tokenbar { border: 2px solid var(--line); padding: 12px; margin: 12px 0; }
|
|
52
|
+
#tokenbar[hidden] { display: none; }
|
|
53
|
+
#tokenbar label { display: block; font-weight: bold; margin-bottom: 6px; }
|
|
54
|
+
#tokenbar .row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
|
55
|
+
#tokenbar input { flex: 1 1 240px; min-height: 44px; padding: 8px; font: inherit; border: 2px solid var(--line); background: var(--paper); color: var(--ink); }
|
|
56
|
+
|
|
57
|
+
button {
|
|
58
|
+
font: inherit;
|
|
59
|
+
min-height: 44px;
|
|
60
|
+
min-width: 44px;
|
|
61
|
+
padding: 8px 12px;
|
|
62
|
+
border: 2px solid var(--line);
|
|
63
|
+
background: var(--paper);
|
|
64
|
+
color: var(--ink);
|
|
65
|
+
cursor: pointer;
|
|
66
|
+
}
|
|
67
|
+
button:focus-visible, input:focus-visible, a:focus-visible {
|
|
68
|
+
outline: 3px solid var(--ink);
|
|
69
|
+
outline-offset: 2px;
|
|
70
|
+
}
|
|
71
|
+
/* No hover-only affordances: hover is a bonus, never the only signal. */
|
|
72
|
+
button:hover { background: #eee; }
|
|
73
|
+
|
|
74
|
+
.stream-group { margin: 0 0 20px 0; }
|
|
75
|
+
.stream-head { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; }
|
|
76
|
+
.stream-name { font-weight: bold; }
|
|
77
|
+
.stream-count { color: var(--muted); font-size: 0.85rem; }
|
|
78
|
+
|
|
79
|
+
.card {
|
|
80
|
+
border: 2px solid var(--line);
|
|
81
|
+
padding: 12px;
|
|
82
|
+
margin: 10px 0;
|
|
83
|
+
background: var(--paper);
|
|
84
|
+
}
|
|
85
|
+
/* Selection is carried by a thick left border AND a text marker, not colour alone. */
|
|
86
|
+
.card.selected { border-left-width: 10px; }
|
|
87
|
+
.card .selmark { display: none; font-weight: bold; text-transform: uppercase; letter-spacing: 0.06em; font-size: 0.75rem; }
|
|
88
|
+
.card.selected .selmark { display: inline; }
|
|
89
|
+
|
|
90
|
+
.card-head { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; margin-bottom: 6px; }
|
|
91
|
+
.age { color: var(--muted); font-size: 0.85rem; }
|
|
92
|
+
|
|
93
|
+
.badge {
|
|
94
|
+
display: inline-block;
|
|
95
|
+
border: 2px solid var(--line);
|
|
96
|
+
padding: 1px 6px;
|
|
97
|
+
font-size: 0.75rem;
|
|
98
|
+
text-transform: uppercase;
|
|
99
|
+
letter-spacing: 0.06em;
|
|
100
|
+
font-weight: bold;
|
|
101
|
+
}
|
|
102
|
+
.badge.urgency-blocking { border-style: double; border-width: 4px; }
|
|
103
|
+
.badge.urgency-low { border-style: dotted; }
|
|
104
|
+
.badge.urgency-normal { border-style: solid; }
|
|
105
|
+
.badge.status-answered { border-style: dashed; }
|
|
106
|
+
|
|
107
|
+
.question { font-weight: bold; margin: 6px 0; }
|
|
108
|
+
.context {
|
|
109
|
+
white-space: pre-wrap;
|
|
110
|
+
border-left: 3px solid var(--line-soft);
|
|
111
|
+
padding: 6px 10px;
|
|
112
|
+
margin: 8px 0;
|
|
113
|
+
color: var(--ink);
|
|
114
|
+
font-size: 0.9rem;
|
|
115
|
+
}
|
|
116
|
+
.context[hidden] { display: none; }
|
|
117
|
+
|
|
118
|
+
.options { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; }
|
|
119
|
+
.options button[aria-pressed="true"] { border-style: double; border-width: 4px; font-weight: bold; }
|
|
120
|
+
|
|
121
|
+
.answer-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 8px; }
|
|
122
|
+
.answer-row label { font-size: 0.85rem; }
|
|
123
|
+
.answer-row input[type="text"] {
|
|
124
|
+
flex: 1 1 240px;
|
|
125
|
+
min-height: 44px;
|
|
126
|
+
padding: 8px;
|
|
127
|
+
font: inherit;
|
|
128
|
+
border: 2px solid var(--line);
|
|
129
|
+
background: var(--paper);
|
|
130
|
+
color: var(--ink);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.card-error { border: 2px solid var(--line); padding: 6px 8px; margin-top: 8px; font-size: 0.85rem; }
|
|
134
|
+
.card-error[hidden] { display: none; }
|
|
135
|
+
.card-error .tag { font-weight: bold; text-transform: uppercase; }
|
|
136
|
+
|
|
137
|
+
.recent .card { border-style: dashed; }
|
|
138
|
+
.empty { color: var(--muted); font-style: italic; }
|
|
139
|
+
|
|
140
|
+
.hint { color: var(--muted); font-size: 0.8rem; margin-top: 24px; border-top: 1px solid var(--line-soft); padding-top: 8px; }
|
|
141
|
+
kbd { border: 1px solid var(--line); padding: 0 4px; font: inherit; font-size: 0.8rem; }
|
|
142
|
+
|
|
143
|
+
/* No animation on the critical path. Respect reduced motion. */
|
|
144
|
+
@media (prefers-reduced-motion: reduce) {
|
|
145
|
+
* { animation: none !important; transition: none !important; }
|
|
146
|
+
}
|
|
147
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
148
|
+
.card.selected { transition: border-left-width 80ms linear; }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
@media print {
|
|
152
|
+
body { max-width: none; }
|
|
153
|
+
button { display: none; }
|
|
154
|
+
.card { break-inside: avoid; }
|
|
155
|
+
}
|
|
156
|
+
</style>
|
|
157
|
+
</head>
|
|
158
|
+
<body>
|
|
159
|
+
<header>
|
|
160
|
+
<h1>heads-up</h1>
|
|
161
|
+
<p class="sub">Drain the queue. <kbd>j</kbd>/<kbd>k</kbd> move · <kbd>1</kbd>-<kbd>5</kbd> pick · <kbd>Enter</kbd> answer · <kbd>u</kbd> undo</p>
|
|
162
|
+
<div id="status" class="banner" role="status" aria-live="polite">
|
|
163
|
+
<span class="tag">Status:</span> <span id="status-text">starting</span>
|
|
164
|
+
</div>
|
|
165
|
+
<div id="error" class="banner" role="alert" hidden>
|
|
166
|
+
<span class="tag">Error:</span> <span id="error-text"></span>
|
|
167
|
+
</div>
|
|
168
|
+
</header>
|
|
169
|
+
|
|
170
|
+
<section id="tokenbar" hidden aria-labelledby="token-label">
|
|
171
|
+
<label id="token-label" for="token-input">Access token (READYROOM_TOKEN)</label>
|
|
172
|
+
<div class="row">
|
|
173
|
+
<input id="token-input" type="password" autocomplete="off" spellcheck="false" aria-describedby="token-help">
|
|
174
|
+
<button id="token-save" type="button">Save token</button>
|
|
175
|
+
</div>
|
|
176
|
+
<p id="token-help" class="sub">Stored in this browser only. Sent as <code>Authorization: Bearer</code> on every request.</p>
|
|
177
|
+
</section>
|
|
178
|
+
|
|
179
|
+
<main id="app">
|
|
180
|
+
<section id="pending-section" aria-labelledby="pending-heading">
|
|
181
|
+
<h2 id="pending-heading">Pending</h2>
|
|
182
|
+
<div id="pending"></div>
|
|
183
|
+
</section>
|
|
184
|
+
|
|
185
|
+
<section id="recent-section" class="recent" aria-labelledby="recent-heading">
|
|
186
|
+
<h2 id="recent-heading">Recent (undoable for 5 minutes)</h2>
|
|
187
|
+
<div id="recent"></div>
|
|
188
|
+
</section>
|
|
189
|
+
</main>
|
|
190
|
+
|
|
191
|
+
<p class="hint">No markdown rendering. Context is shown as plain text with line breaks preserved.</p>
|
|
192
|
+
|
|
193
|
+
<noscript>
|
|
194
|
+
<div class="banner"><span class="tag">Error:</span> This page needs JavaScript to drain the queue.</div>
|
|
195
|
+
</noscript>
|
|
196
|
+
|
|
197
|
+
<script>
|
|
198
|
+
(function () {
|
|
199
|
+
"use strict";
|
|
200
|
+
|
|
201
|
+
var TOKEN_KEY = "readyroom_token";
|
|
202
|
+
var POLL_MS = 5000;
|
|
203
|
+
var UNDO_WINDOW_MS = 5 * 60 * 1000;
|
|
204
|
+
|
|
205
|
+
var state = {
|
|
206
|
+
tickets: [],
|
|
207
|
+
selectedId: null,
|
|
208
|
+
token: "",
|
|
209
|
+
abort: null,
|
|
210
|
+
pollTimer: null,
|
|
211
|
+
live: false,
|
|
212
|
+
error: "",
|
|
213
|
+
choices: {},
|
|
214
|
+
drafts: {}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
var els = {
|
|
218
|
+
status: document.getElementById("status"),
|
|
219
|
+
statusText: document.getElementById("status-text"),
|
|
220
|
+
error: document.getElementById("error"),
|
|
221
|
+
errorText: document.getElementById("error-text"),
|
|
222
|
+
tokenbar: document.getElementById("tokenbar"),
|
|
223
|
+
tokenInput: document.getElementById("token-input"),
|
|
224
|
+
tokenSave: document.getElementById("token-save"),
|
|
225
|
+
pending: document.getElementById("pending"),
|
|
226
|
+
recent: document.getElementById("recent")
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
/* ---------------- token handling (requirement 11) ---------------- */
|
|
230
|
+
|
|
231
|
+
function readToken() {
|
|
232
|
+
try { return localStorage.getItem(TOKEN_KEY) || ""; } catch (e) { return ""; }
|
|
233
|
+
}
|
|
234
|
+
function writeToken(t) {
|
|
235
|
+
try { localStorage.setItem(TOKEN_KEY, t); } catch (e) { /* storage may be blocked */ }
|
|
236
|
+
}
|
|
237
|
+
function dropToken() {
|
|
238
|
+
try { localStorage.removeItem(TOKEN_KEY); } catch (e) { /* ignore */ }
|
|
239
|
+
}
|
|
240
|
+
function showTokenBar(show) {
|
|
241
|
+
els.tokenbar.hidden = !show;
|
|
242
|
+
if (show) { try { els.tokenInput.focus(); } catch (e) {} }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/* ---------------- HTTP with Bearer on every request ---------------- */
|
|
246
|
+
|
|
247
|
+
function api(path, opts) {
|
|
248
|
+
opts = opts || {};
|
|
249
|
+
var headers = { "Authorization": "Bearer " + state.token };
|
|
250
|
+
if (opts.body) { headers["Content-Type"] = "application/json"; }
|
|
251
|
+
return fetch(path, {
|
|
252
|
+
method: opts.method || "GET",
|
|
253
|
+
headers: headers,
|
|
254
|
+
body: opts.body
|
|
255
|
+
}).then(function (res) {
|
|
256
|
+
if (res.status === 401) {
|
|
257
|
+
dropToken();
|
|
258
|
+
state.token = "";
|
|
259
|
+
state.error = "Token rejected (401). Enter it again.";
|
|
260
|
+
showTokenBar(true);
|
|
261
|
+
render();
|
|
262
|
+
var err = new Error("unauthorized");
|
|
263
|
+
err.unauthorized = true;
|
|
264
|
+
throw err;
|
|
265
|
+
}
|
|
266
|
+
return res;
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function fetchTickets() {
|
|
271
|
+
if (!state.token) { return Promise.resolve(); }
|
|
272
|
+
return api("/api/tickets").then(function (res) {
|
|
273
|
+
if (!res.ok) { throw new Error("tickets " + res.status); }
|
|
274
|
+
return res.json();
|
|
275
|
+
}).then(function (data) {
|
|
276
|
+
state.tickets = (data && data.tickets) || [];
|
|
277
|
+
state.error = "";
|
|
278
|
+
render();
|
|
279
|
+
}).catch(function (err) {
|
|
280
|
+
if (err && err.unauthorized) { return; }
|
|
281
|
+
state.error = "Could not load tickets: " + (err && err.message ? err.message : "network error");
|
|
282
|
+
render();
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/* ---------------- live updates (requirement 1) ---------------- */
|
|
287
|
+
|
|
288
|
+
function startPolling() {
|
|
289
|
+
if (state.pollTimer) { return; }
|
|
290
|
+
state.live = false;
|
|
291
|
+
fetchTickets();
|
|
292
|
+
state.pollTimer = setInterval(fetchTickets, POLL_MS);
|
|
293
|
+
render();
|
|
294
|
+
}
|
|
295
|
+
function stopPolling() {
|
|
296
|
+
if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/* Fetch-based SSE reader.
|
|
300
|
+
EventSource cannot attach an Authorization header, so a Bearer-protected
|
|
301
|
+
/api/events is unreachable through it. Instead we fetch the endpoint with the
|
|
302
|
+
token, read response.body through a ReadableStream reader, decode with a
|
|
303
|
+
TextDecoder, and parse text/event-stream frames: frames are separated by a blank
|
|
304
|
+
line, and the payload rides on `data:` lines. Any failure — a network error, a
|
|
305
|
+
non-200 status, or a Worker's 501 — falls back to polling /api/tickets every 5 s,
|
|
306
|
+
which is the Worker target's live-update path. */
|
|
307
|
+
function handleFrame(frame) {
|
|
308
|
+
var lines = frame.split("\n");
|
|
309
|
+
var sawData = false;
|
|
310
|
+
for (var i = 0; i < lines.length; i++) {
|
|
311
|
+
if (lines[i].indexOf("data:") === 0) { sawData = true; }
|
|
312
|
+
}
|
|
313
|
+
if (sawData) { fetchTickets(); }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function readSseStream(body) {
|
|
317
|
+
var reader = body.getReader();
|
|
318
|
+
var decoder = new TextDecoder();
|
|
319
|
+
var buffer = "";
|
|
320
|
+
function pump() {
|
|
321
|
+
return reader.read().then(function (chunk) {
|
|
322
|
+
if (chunk.done) { throw new Error("sse stream closed"); }
|
|
323
|
+
buffer += decoder.decode(chunk.value, { stream: true });
|
|
324
|
+
var idx;
|
|
325
|
+
while ((idx = buffer.indexOf("\n\n")) !== -1) {
|
|
326
|
+
var frame = buffer.slice(0, idx);
|
|
327
|
+
buffer = buffer.slice(idx + 2);
|
|
328
|
+
handleFrame(frame);
|
|
329
|
+
}
|
|
330
|
+
return pump();
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
return pump();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function startLive() {
|
|
337
|
+
stopLive();
|
|
338
|
+
if (!state.token) { return; }
|
|
339
|
+
if (typeof fetch === "undefined" || typeof TextDecoder === "undefined") { startPolling(); return; }
|
|
340
|
+
|
|
341
|
+
var controller = null;
|
|
342
|
+
try { controller = new AbortController(); } catch (e) { controller = null; }
|
|
343
|
+
if (controller) { state.abort = controller; }
|
|
344
|
+
|
|
345
|
+
var options = { headers: { "Authorization": "Bearer " + state.token } };
|
|
346
|
+
if (controller) { options.signal = controller.signal; }
|
|
347
|
+
|
|
348
|
+
fetch("/api/events", options).then(function (res) {
|
|
349
|
+
if (!res.ok || !res.body) {
|
|
350
|
+
/* 401, 404, a Worker's 501, or a body we cannot stream: poll instead. */
|
|
351
|
+
startPolling();
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
state.live = true;
|
|
355
|
+
stopPolling();
|
|
356
|
+
render();
|
|
357
|
+
return readSseStream(res.body);
|
|
358
|
+
}).catch(function () {
|
|
359
|
+
if (controller && controller.signal.aborted) { return; }
|
|
360
|
+
state.live = false;
|
|
361
|
+
startPolling();
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
function stopLive() {
|
|
365
|
+
if (state.abort) {
|
|
366
|
+
try { state.abort.abort(); } catch (e) { /* ignore */ }
|
|
367
|
+
state.abort = null;
|
|
368
|
+
}
|
|
369
|
+
state.live = false;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/* ---------------- derived data ---------------- */
|
|
373
|
+
|
|
374
|
+
function pendingTickets() {
|
|
375
|
+
return state.tickets.filter(function (t) { return t.status === "pending"; });
|
|
376
|
+
}
|
|
377
|
+
function recentTickets() {
|
|
378
|
+
var now = Date.now();
|
|
379
|
+
return state.tickets.filter(function (t) {
|
|
380
|
+
if (t.status !== "answered" || !t.answered_at) { return false; }
|
|
381
|
+
return (now - new Date(t.answered_at).getTime()) <= UNDO_WINDOW_MS;
|
|
382
|
+
}).sort(function (a, b) {
|
|
383
|
+
return new Date(b.answered_at).getTime() - new Date(a.answered_at).getTime();
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function groupByStream(tickets) {
|
|
388
|
+
var groups = {};
|
|
389
|
+
var order = [];
|
|
390
|
+
tickets.forEach(function (t) {
|
|
391
|
+
if (!groups[t.stream]) { groups[t.stream] = []; order.push(t.stream); }
|
|
392
|
+
groups[t.stream].push(t);
|
|
393
|
+
});
|
|
394
|
+
order.forEach(function (s) {
|
|
395
|
+
groups[s].sort(function (a, b) {
|
|
396
|
+
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
order.sort(function (a, b) {
|
|
400
|
+
return new Date(groups[b][0].created_at).getTime() - new Date(groups[a][0].created_at).getTime();
|
|
401
|
+
});
|
|
402
|
+
return order.map(function (s) { return { stream: s, tickets: groups[s] }; });
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function orderedPending() {
|
|
406
|
+
var out = [];
|
|
407
|
+
groupByStream(pendingTickets()).forEach(function (g) {
|
|
408
|
+
g.tickets.forEach(function (t) { out.push(t); });
|
|
409
|
+
});
|
|
410
|
+
return out;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function age(iso) {
|
|
414
|
+
var ms = Date.now() - new Date(iso).getTime();
|
|
415
|
+
if (!isFinite(ms) || ms < 0) { ms = 0; }
|
|
416
|
+
var s = Math.floor(ms / 1000);
|
|
417
|
+
if (s < 60) { return s + "s"; }
|
|
418
|
+
var m = Math.floor(s / 60);
|
|
419
|
+
if (m < 60) { return m + "m"; }
|
|
420
|
+
var h = Math.floor(m / 60);
|
|
421
|
+
if (h < 24) { return h + "h"; }
|
|
422
|
+
return Math.floor(h / 24) + "d";
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/* ---------------- actions ---------------- */
|
|
426
|
+
|
|
427
|
+
function setCardError(id, msg) {
|
|
428
|
+
var el = document.getElementById("err-" + id);
|
|
429
|
+
if (el) {
|
|
430
|
+
el.hidden = false;
|
|
431
|
+
var span = el.querySelector(".err-text");
|
|
432
|
+
if (span) { span.textContent = msg; }
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function submitAnswer(ticket) {
|
|
437
|
+
var input = document.getElementById("free-" + ticket.id);
|
|
438
|
+
var text = input ? input.value.trim() : (state.drafts[ticket.id] || "").trim();
|
|
439
|
+
var choice = state.choices[ticket.id] || "";
|
|
440
|
+
var answer = text || choice;
|
|
441
|
+
if (!answer) {
|
|
442
|
+
setCardError(ticket.id, "Type an answer or pick an option first.");
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
api("/api/answer", {
|
|
446
|
+
method: "POST",
|
|
447
|
+
body: JSON.stringify({ id: ticket.id, answer: answer, answered_by: "human" })
|
|
448
|
+
}).then(function (res) {
|
|
449
|
+
if (res.status === 404) { setCardError(ticket.id, "Ticket not found (404)."); return; }
|
|
450
|
+
if (res.status === 409) { setCardError(ticket.id, "Already answered (409)."); return; }
|
|
451
|
+
if (!res.ok) { setCardError(ticket.id, "Answer failed (" + res.status + ")."); return; }
|
|
452
|
+
delete state.choices[ticket.id];
|
|
453
|
+
delete state.drafts[ticket.id];
|
|
454
|
+
return fetchTickets();
|
|
455
|
+
}).catch(function (err) {
|
|
456
|
+
if (err && err.unauthorized) { return; }
|
|
457
|
+
setCardError(ticket.id, "Answer failed: " + (err && err.message ? err.message : "network error"));
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function undo(ticketId) {
|
|
462
|
+
api("/api/undo", { method: "POST", body: JSON.stringify({ id: ticketId }) })
|
|
463
|
+
.then(function (res) {
|
|
464
|
+
if (res.status === 409) { state.error = "Undo window has passed (409)."; render(); return; }
|
|
465
|
+
if (!res.ok) { state.error = "Undo failed (" + res.status + ")."; render(); return; }
|
|
466
|
+
return fetchTickets();
|
|
467
|
+
})
|
|
468
|
+
.catch(function (err) {
|
|
469
|
+
if (err && err.unauthorized) { return; }
|
|
470
|
+
state.error = "Undo failed: " + (err && err.message ? err.message : "network error");
|
|
471
|
+
render();
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function undoLast() {
|
|
476
|
+
var recent = recentTickets();
|
|
477
|
+
if (recent.length === 0) { return; }
|
|
478
|
+
undo(recent[0].id);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/* ---------------- keyboard (requirement 4) ---------------- */
|
|
482
|
+
|
|
483
|
+
function isTypingTarget(el) {
|
|
484
|
+
if (!el) { return false; }
|
|
485
|
+
var tag = el.tagName;
|
|
486
|
+
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable === true;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function moveSelection(delta) {
|
|
490
|
+
var list = orderedPending();
|
|
491
|
+
if (list.length === 0) { return; }
|
|
492
|
+
var idx = -1;
|
|
493
|
+
for (var i = 0; i < list.length; i++) { if (list[i].id === state.selectedId) { idx = i; break; } }
|
|
494
|
+
if (idx === -1) { idx = delta > 0 ? -1 : 0; }
|
|
495
|
+
var next = idx + delta;
|
|
496
|
+
if (next < 0) { next = 0; }
|
|
497
|
+
if (next > list.length - 1) { next = list.length - 1; }
|
|
498
|
+
state.selectedId = list[next].id;
|
|
499
|
+
render();
|
|
500
|
+
var card = document.getElementById("card-" + state.selectedId);
|
|
501
|
+
if (card && card.scrollIntoView) { card.scrollIntoView({ block: "nearest" }); }
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function pickOption(n) {
|
|
505
|
+
var list = orderedPending();
|
|
506
|
+
var ticket = null;
|
|
507
|
+
for (var i = 0; i < list.length; i++) { if (list[i].id === state.selectedId) { ticket = list[i]; break; } }
|
|
508
|
+
if (!ticket || !ticket.options || !ticket.options[n - 1]) { return; }
|
|
509
|
+
state.choices[ticket.id] = ticket.options[n - 1];
|
|
510
|
+
render();
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function submitSelected() {
|
|
514
|
+
var list = orderedPending();
|
|
515
|
+
for (var i = 0; i < list.length; i++) {
|
|
516
|
+
if (list[i].id === state.selectedId) { submitAnswer(list[i]); return; }
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
document.addEventListener("keydown", function (e) {
|
|
521
|
+
if (isTypingTarget(e.target)) { return; }
|
|
522
|
+
if (e.metaKey || e.ctrlKey || e.altKey) { return; }
|
|
523
|
+
if (e.key === "j") { e.preventDefault(); moveSelection(1); }
|
|
524
|
+
else if (e.key === "k") { e.preventDefault(); moveSelection(-1); }
|
|
525
|
+
else if (e.key >= "1" && e.key <= "5") { e.preventDefault(); pickOption(parseInt(e.key, 10)); }
|
|
526
|
+
else if (e.key === "Enter") { e.preventDefault(); submitSelected(); }
|
|
527
|
+
else if (e.key === "u") { e.preventDefault(); undoLast(); }
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
/* ---------------- rendering ---------------- */
|
|
531
|
+
|
|
532
|
+
function captureDrafts() {
|
|
533
|
+
var inputs = document.querySelectorAll("[data-draft]");
|
|
534
|
+
for (var i = 0; i < inputs.length; i++) {
|
|
535
|
+
state.drafts[inputs[i].getAttribute("data-draft")] = inputs[i].value;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function el(tag, cls, text) {
|
|
540
|
+
var node = document.createElement(tag);
|
|
541
|
+
if (cls) { node.className = cls; }
|
|
542
|
+
if (text !== undefined && text !== null) { node.textContent = text; }
|
|
543
|
+
return node;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function urgencyBadge(urgency) {
|
|
547
|
+
var b = el("span", "badge urgency-" + urgency, "urgency: " + urgency);
|
|
548
|
+
return b;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function buildCard(ticket, isRecent) {
|
|
552
|
+
var card = el("article", "card" + (ticket.id === state.selectedId ? " selected" : ""));
|
|
553
|
+
card.id = "card-" + ticket.id;
|
|
554
|
+
card.setAttribute("aria-label", "Ticket from " + ticket.stream + ": " + ticket.question);
|
|
555
|
+
|
|
556
|
+
var head = el("div", "card-head");
|
|
557
|
+
head.appendChild(el("span", "selmark", "selected"));
|
|
558
|
+
head.appendChild(el("span", "stream-name", ticket.stream));
|
|
559
|
+
head.appendChild(el("span", "age", age(ticket.created_at) + " old"));
|
|
560
|
+
head.appendChild(urgencyBadge(ticket.urgency));
|
|
561
|
+
if (isRecent) {
|
|
562
|
+
head.appendChild(el("span", "badge status-answered", "status: answered"));
|
|
563
|
+
if (ticket.answered_by) { head.appendChild(el("span", "age", "by " + ticket.answered_by)); }
|
|
564
|
+
}
|
|
565
|
+
card.appendChild(head);
|
|
566
|
+
|
|
567
|
+
card.appendChild(el("p", "question", ticket.question));
|
|
568
|
+
|
|
569
|
+
if (ticket.context) {
|
|
570
|
+
var ctx = el("div", "context", ticket.context);
|
|
571
|
+
card.appendChild(ctx);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (isRecent) {
|
|
575
|
+
var ans = el("p", "question", "Answer: " + (ticket.answer || "(none)"));
|
|
576
|
+
card.appendChild(ans);
|
|
577
|
+
var undoBtn = el("button", null, "Undo");
|
|
578
|
+
undoBtn.type = "button";
|
|
579
|
+
undoBtn.setAttribute("aria-label", "Undo answer for ticket from " + ticket.stream);
|
|
580
|
+
undoBtn.addEventListener("click", function () { undo(ticket.id); });
|
|
581
|
+
var row = el("div", "answer-row");
|
|
582
|
+
row.appendChild(undoBtn);
|
|
583
|
+
card.appendChild(row);
|
|
584
|
+
return card;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (ticket.options && ticket.options.length) {
|
|
588
|
+
var opts = el("div", "options");
|
|
589
|
+
opts.setAttribute("role", "group");
|
|
590
|
+
opts.setAttribute("aria-label", "Options for ticket from " + ticket.stream);
|
|
591
|
+
ticket.options.forEach(function (opt, i) {
|
|
592
|
+
var b = el("button", null, (i + 1) + ". " + opt);
|
|
593
|
+
b.type = "button";
|
|
594
|
+
b.setAttribute("aria-pressed", state.choices[ticket.id] === opt ? "true" : "false");
|
|
595
|
+
b.addEventListener("click", function () {
|
|
596
|
+
state.choices[ticket.id] = opt;
|
|
597
|
+
state.selectedId = ticket.id;
|
|
598
|
+
render();
|
|
599
|
+
});
|
|
600
|
+
opts.appendChild(b);
|
|
601
|
+
});
|
|
602
|
+
card.appendChild(opts);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
var form = el("form", "answer-row");
|
|
606
|
+
form.setAttribute("novalidate", "novalidate");
|
|
607
|
+
var label = el("label", null, "Answer");
|
|
608
|
+
label.setAttribute("for", "free-" + ticket.id);
|
|
609
|
+
form.appendChild(label);
|
|
610
|
+
var input = document.createElement("input");
|
|
611
|
+
input.type = "text";
|
|
612
|
+
input.id = "free-" + ticket.id;
|
|
613
|
+
input.setAttribute("data-draft", ticket.id);
|
|
614
|
+
input.setAttribute("autocomplete", "off");
|
|
615
|
+
input.value = state.drafts[ticket.id] || "";
|
|
616
|
+
input.addEventListener("input", function () { state.drafts[ticket.id] = input.value; });
|
|
617
|
+
input.addEventListener("focus", function () { state.selectedId = ticket.id; });
|
|
618
|
+
form.appendChild(input);
|
|
619
|
+
var submit = el("button", null, "Answer");
|
|
620
|
+
submit.type = "submit";
|
|
621
|
+
form.appendChild(submit);
|
|
622
|
+
form.addEventListener("submit", function (e) {
|
|
623
|
+
e.preventDefault();
|
|
624
|
+
submitAnswer(ticket);
|
|
625
|
+
});
|
|
626
|
+
card.appendChild(form);
|
|
627
|
+
|
|
628
|
+
var err = el("div", "card-error");
|
|
629
|
+
err.id = "err-" + ticket.id;
|
|
630
|
+
err.hidden = true;
|
|
631
|
+
err.setAttribute("role", "alert");
|
|
632
|
+
err.appendChild(el("span", "tag", "Error: "));
|
|
633
|
+
err.appendChild(el("span", "err-text", ""));
|
|
634
|
+
card.appendChild(err);
|
|
635
|
+
|
|
636
|
+
return card;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function render() {
|
|
640
|
+
captureDrafts();
|
|
641
|
+
|
|
642
|
+
var activeId = document.activeElement && document.activeElement.id ? document.activeElement.id : null;
|
|
643
|
+
var selStart = null, selEnd = null;
|
|
644
|
+
if (activeId && document.activeElement.setSelectionRange) {
|
|
645
|
+
try {
|
|
646
|
+
selStart = document.activeElement.selectionStart;
|
|
647
|
+
selEnd = document.activeElement.selectionEnd;
|
|
648
|
+
} catch (e) { /* not a text input */ }
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
var pending = pendingTickets();
|
|
652
|
+
var recent = recentTickets();
|
|
653
|
+
|
|
654
|
+
/* requirement 5: tab title carries the count */
|
|
655
|
+
document.title = pending.length > 0 ? "(" + pending.length + ") heads-up" : "heads-up";
|
|
656
|
+
|
|
657
|
+
/* status line */
|
|
658
|
+
var statusText = "idle";
|
|
659
|
+
if (state.live) { statusText = "live (server-sent events)"; }
|
|
660
|
+
else if (state.pollTimer) { statusText = "polling every 5s"; }
|
|
661
|
+
else if (!state.token) { statusText = "waiting for token"; }
|
|
662
|
+
els.statusText.textContent = statusText;
|
|
663
|
+
|
|
664
|
+
if (state.error) {
|
|
665
|
+
els.error.hidden = false;
|
|
666
|
+
els.errorText.textContent = state.error;
|
|
667
|
+
} else {
|
|
668
|
+
els.error.hidden = true;
|
|
669
|
+
els.errorText.textContent = "";
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/* pending, grouped by stream, newest stream first */
|
|
673
|
+
els.pending.textContent = "";
|
|
674
|
+
if (pending.length === 0) {
|
|
675
|
+
els.pending.appendChild(el("p", "empty", "No pending tickets."));
|
|
676
|
+
} else {
|
|
677
|
+
groupByStream(pending).forEach(function (group) {
|
|
678
|
+
var section = el("section", "stream-group");
|
|
679
|
+
section.setAttribute("aria-label", "Stream " + group.stream);
|
|
680
|
+
var head = el("div", "stream-head");
|
|
681
|
+
head.appendChild(el("h3", "stream-name", group.stream));
|
|
682
|
+
head.appendChild(el("span", "stream-count", group.tickets.length + " pending"));
|
|
683
|
+
section.appendChild(head);
|
|
684
|
+
group.tickets.forEach(function (t) { section.appendChild(buildCard(t, false)); });
|
|
685
|
+
els.pending.appendChild(section);
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/* recent, undoable for 5 minutes */
|
|
690
|
+
els.recent.textContent = "";
|
|
691
|
+
if (recent.length === 0) {
|
|
692
|
+
els.recent.appendChild(el("p", "empty", "Nothing answered in the last 5 minutes."));
|
|
693
|
+
} else {
|
|
694
|
+
recent.forEach(function (t) { els.recent.appendChild(buildCard(t, true)); });
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/* restore focus + caret so polling re-renders do not interrupt typing */
|
|
698
|
+
if (activeId) {
|
|
699
|
+
var again = document.getElementById(activeId);
|
|
700
|
+
if (again && again.focus) {
|
|
701
|
+
try {
|
|
702
|
+
again.focus();
|
|
703
|
+
if (selStart !== null && again.setSelectionRange) { again.setSelectionRange(selStart, selEnd); }
|
|
704
|
+
} catch (e) { /* ignore */ }
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/* ---------------- wiring ---------------- */
|
|
710
|
+
|
|
711
|
+
els.tokenSave.addEventListener("click", function () {
|
|
712
|
+
var t = els.tokenInput.value.trim();
|
|
713
|
+
if (!t) { return; }
|
|
714
|
+
state.token = t;
|
|
715
|
+
writeToken(t);
|
|
716
|
+
state.error = "";
|
|
717
|
+
showTokenBar(false);
|
|
718
|
+
startLive();
|
|
719
|
+
fetchTickets();
|
|
720
|
+
});
|
|
721
|
+
els.tokenInput.addEventListener("keydown", function (e) {
|
|
722
|
+
if (e.key === "Enter") { e.preventDefault(); els.tokenSave.click(); }
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
/* requirement 9: re-render on window load */
|
|
726
|
+
window.addEventListener("load", render);
|
|
727
|
+
|
|
728
|
+
/* boot */
|
|
729
|
+
state.token = readToken();
|
|
730
|
+
if (state.token) {
|
|
731
|
+
showTokenBar(false);
|
|
732
|
+
startLive();
|
|
733
|
+
fetchTickets();
|
|
734
|
+
} else {
|
|
735
|
+
showTokenBar(true);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/* requirement 9: first paint is synchronous, at the end of the script, not in rAF */
|
|
739
|
+
render();
|
|
740
|
+
})();
|
|
741
|
+
</script>
|
|
742
|
+
</body>
|
|
743
|
+
</html>
|