@scoutmesh/viewer 0.2.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.
- package/README.md +534 -0
- package/dist/cli.js +430 -0
- package/dist/external-browser.js +62 -0
- package/dist/input-error.js +8 -0
- package/dist/launcher.js +39 -0
- package/dist/public/app.js +1157 -0
- package/dist/public/auth.js +55 -0
- package/dist/public/data.js +105 -0
- package/dist/public/index.html +153 -0
- package/dist/public/styles.css +619 -0
- package/dist/server.js +339 -0
- package/dist/state-file.js +48 -0
- package/dist/store.js +437 -0
- package/dist/update-manager.js +448 -0
- package/dist/version.js +1 -0
- package/package.json +20 -0
- package/skills/local-candidate-viewer/SKILL.md +200 -0
|
@@ -0,0 +1,1157 @@
|
|
|
1
|
+
import { unlockViewer } from "./auth.js";
|
|
2
|
+
import {
|
|
3
|
+
addedRecordIds,
|
|
4
|
+
pageOf,
|
|
5
|
+
isObject,
|
|
6
|
+
readView,
|
|
7
|
+
readSummaries,
|
|
8
|
+
} from "./data.js";
|
|
9
|
+
|
|
10
|
+
/** @typedef {import('../types.ts').View} View */
|
|
11
|
+
/** @typedef {import('../types.ts').Candidate} Candidate */
|
|
12
|
+
/** @typedef {import('../types.ts').Selection} Selection */
|
|
13
|
+
|
|
14
|
+
/** @template {keyof HTMLElementTagNameMap} K @param {K} tag - Input value. @param {string} [text] - Input value. @param {string} [className] - Input value. */
|
|
15
|
+
function element(tag, text = "", className = "") {
|
|
16
|
+
const node = document.createElement(tag);
|
|
17
|
+
node.textContent = text;
|
|
18
|
+
node.className = className;
|
|
19
|
+
return node;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @param {string} id - Input value. */
|
|
23
|
+
function byId(id) {
|
|
24
|
+
const node = document.querySelector(`#${id}`);
|
|
25
|
+
if (!(node instanceof HTMLElement)) {
|
|
26
|
+
throw new Error(`Missing viewer element: ${id}`);
|
|
27
|
+
}
|
|
28
|
+
return node;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getDialog() {
|
|
32
|
+
const node = byId("profile");
|
|
33
|
+
if (!(node instanceof HTMLDialogElement)) {
|
|
34
|
+
throw new Error("Invalid profile dialog.");
|
|
35
|
+
}
|
|
36
|
+
return node;
|
|
37
|
+
}
|
|
38
|
+
const dialog = getDialog();
|
|
39
|
+
const viewId = new URLSearchParams(location.search).get("view");
|
|
40
|
+
const base = `/api/views/${encodeURIComponent(viewId ?? "")}`;
|
|
41
|
+
/** @type {View | null} */
|
|
42
|
+
let view = null;
|
|
43
|
+
/** @type {Selection} */
|
|
44
|
+
let state = {
|
|
45
|
+
filter: "",
|
|
46
|
+
maybeIds: [],
|
|
47
|
+
removedIds: [],
|
|
48
|
+
selectedIds: [],
|
|
49
|
+
sortBy: "name",
|
|
50
|
+
sortDirection: "asc",
|
|
51
|
+
};
|
|
52
|
+
let focusedId = "";
|
|
53
|
+
let profileOpenerId = "";
|
|
54
|
+
let stateVersion = 0;
|
|
55
|
+
let savedStateVersion = 0;
|
|
56
|
+
let fetchSequence = 0;
|
|
57
|
+
let pendingSave = Promise.resolve();
|
|
58
|
+
let activeList = "unreviewed";
|
|
59
|
+
let profileTab = "overview";
|
|
60
|
+
/** @type {Map<string, number>} */
|
|
61
|
+
const pages = new Map();
|
|
62
|
+
/** @type {Set<string>} */
|
|
63
|
+
const pendingIds = new Set();
|
|
64
|
+
/** @type {Set<string>} */
|
|
65
|
+
let batchIds = new Set();
|
|
66
|
+
let returnScroll = 0;
|
|
67
|
+
/** @type {{id: string, name: string, shortlisted: boolean, maybe: boolean} | null} */
|
|
68
|
+
let lastRemoval = null;
|
|
69
|
+
const emailIds = new Set();
|
|
70
|
+
const sourceIds = new Set();
|
|
71
|
+
|
|
72
|
+
/** @param {string} path - Input value. @param {RequestInit} [options] - Input value. */
|
|
73
|
+
async function api(path, options = {}) {
|
|
74
|
+
const response = await fetch(path, {
|
|
75
|
+
...options,
|
|
76
|
+
headers: new Headers(
|
|
77
|
+
options.headers ?? { "Content-Type": "application/json" }
|
|
78
|
+
),
|
|
79
|
+
});
|
|
80
|
+
/** @type {unknown} */
|
|
81
|
+
const result = await response.json();
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
isObject(result) && typeof result.error === "string"
|
|
85
|
+
? result.error
|
|
86
|
+
: "The local viewer could not complete this request."
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** @param {unknown} error - Input value. */
|
|
93
|
+
function showError(error) {
|
|
94
|
+
byId("error").hidden = false;
|
|
95
|
+
byId("error").textContent =
|
|
96
|
+
error instanceof Error ? error.message : "An unexpected error occurred.";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function saveState() {
|
|
100
|
+
stateVersion += 1;
|
|
101
|
+
const version = stateVersion;
|
|
102
|
+
const snapshot = structuredClone(state);
|
|
103
|
+
const previous = pendingSave;
|
|
104
|
+
async function persist() {
|
|
105
|
+
try {
|
|
106
|
+
await previous;
|
|
107
|
+
if (version !== stateVersion) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
await api(`${base}/state`, {
|
|
111
|
+
body: JSON.stringify(snapshot),
|
|
112
|
+
method: "PUT",
|
|
113
|
+
});
|
|
114
|
+
savedStateVersion = version;
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (version === stateVersion) {
|
|
117
|
+
savedStateVersion = version;
|
|
118
|
+
await refresh();
|
|
119
|
+
}
|
|
120
|
+
showError(error);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
pendingSave = persist();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** @param {unknown} value - Field value to format. @returns {string} */
|
|
127
|
+
function display(value) {
|
|
128
|
+
if (value === null || value === undefined || value === "") {
|
|
129
|
+
return "—";
|
|
130
|
+
}
|
|
131
|
+
if (Array.isArray(value)) {
|
|
132
|
+
return value.map((item) => display(item)).join(", ");
|
|
133
|
+
}
|
|
134
|
+
return typeof value === "string" ? value : (JSON.stringify(value) ?? "—");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @param {string} key - Input value. */
|
|
138
|
+
function fieldName(key) {
|
|
139
|
+
return key
|
|
140
|
+
.replaceAll(/(?<lower>[a-z])(?<upper>[A-Z])/gu, "$<lower> $<upper>")
|
|
141
|
+
.replaceAll("_", " ")
|
|
142
|
+
.replace(/^./u, (letter) => letter.toUpperCase());
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** @param {unknown} value - Field value. */
|
|
146
|
+
function hasValue(value) {
|
|
147
|
+
return (
|
|
148
|
+
value !== null &&
|
|
149
|
+
value !== undefined &&
|
|
150
|
+
value !== "" &&
|
|
151
|
+
(!Array.isArray(value) || value.length > 0)
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** @param {unknown} value - Possible URL. */
|
|
156
|
+
function webUrl(value) {
|
|
157
|
+
if (typeof value !== "string") {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
const url = new URL(value);
|
|
162
|
+
return ["http:", "https:"].includes(url.protocol) ? url : null;
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** @param {string} url - Web link to open in the system browser. */
|
|
169
|
+
async function openExternalLink(url) {
|
|
170
|
+
try {
|
|
171
|
+
await api("/api/open-external", {
|
|
172
|
+
body: JSON.stringify({ url }),
|
|
173
|
+
method: "POST",
|
|
174
|
+
});
|
|
175
|
+
} catch (error) {
|
|
176
|
+
showError(error);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** @param {HTMLElement} target - Destination. @param {unknown} value - Field value. @param {string} [label] - Link label. */
|
|
181
|
+
function fillValue(target, value, label = "Source") {
|
|
182
|
+
if (Array.isArray(value)) {
|
|
183
|
+
const list = element("ul");
|
|
184
|
+
for (const item of value) {
|
|
185
|
+
const entry = element("li");
|
|
186
|
+
fillValue(entry, item, label);
|
|
187
|
+
list.append(entry);
|
|
188
|
+
}
|
|
189
|
+
target.append(list);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const text = display(value);
|
|
193
|
+
const url = webUrl(value);
|
|
194
|
+
if (url) {
|
|
195
|
+
const link = element("a", `${label} ↗`);
|
|
196
|
+
link.href = url.href;
|
|
197
|
+
link.target = "_blank";
|
|
198
|
+
link.rel = "external noopener noreferrer";
|
|
199
|
+
link.setAttribute("aria-label", `${label} (opens in external browser)`);
|
|
200
|
+
link.title = "Open in your default browser";
|
|
201
|
+
link.addEventListener("click", (event) => {
|
|
202
|
+
event.preventDefault();
|
|
203
|
+
void openExternalLink(url.href);
|
|
204
|
+
});
|
|
205
|
+
link.addEventListener("auxclick", (event) => {
|
|
206
|
+
if (event.button === 1) {
|
|
207
|
+
event.preventDefault();
|
|
208
|
+
void openExternalLink(url.href);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
target.append(link);
|
|
212
|
+
} else {
|
|
213
|
+
target.textContent = text;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** @param {Candidate} record - Candidate to review. @param {string} action - Review decision. */
|
|
218
|
+
function reviewCandidate(record, action) {
|
|
219
|
+
const shortlisted = state.selectedIds.includes(record.id);
|
|
220
|
+
const maybe = state.maybeIds.includes(record.id);
|
|
221
|
+
state.selectedIds = state.selectedIds.filter((id) => id !== record.id);
|
|
222
|
+
state.maybeIds = state.maybeIds.filter((id) => id !== record.id);
|
|
223
|
+
state.removedIds = state.removedIds.filter((id) => id !== record.id);
|
|
224
|
+
if (action === "shortlist" && !shortlisted) {
|
|
225
|
+
state.selectedIds.push(record.id);
|
|
226
|
+
} else if (action === "maybe" && !maybe) {
|
|
227
|
+
state.maybeIds.push(record.id);
|
|
228
|
+
} else if (action === "remove") {
|
|
229
|
+
lastRemoval = { id: record.id, maybe, name: record.name, shortlisted };
|
|
230
|
+
state.removedIds.push(record.id);
|
|
231
|
+
byId("removal-message").textContent = `${record.name} marked No.`;
|
|
232
|
+
byId("undo-removal").hidden = false;
|
|
233
|
+
} else if (action === "undo" && lastRemoval?.id === record.id) {
|
|
234
|
+
if (lastRemoval.shortlisted) {
|
|
235
|
+
state.selectedIds.push(record.id);
|
|
236
|
+
} else if (lastRemoval.maybe) {
|
|
237
|
+
state.maybeIds.push(record.id);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (action !== "remove" && lastRemoval?.id === record.id) {
|
|
241
|
+
lastRemoval = null;
|
|
242
|
+
byId("undo-removal").hidden = true;
|
|
243
|
+
}
|
|
244
|
+
saveState();
|
|
245
|
+
renderResults(true);
|
|
246
|
+
if (dialog.open) {
|
|
247
|
+
renderProfile();
|
|
248
|
+
if (document.activeElement === document.body) {
|
|
249
|
+
byId("close-profile").focus({ preventScroll: true });
|
|
250
|
+
}
|
|
251
|
+
} else if (document.activeElement === document.body) {
|
|
252
|
+
byId(`list-${activeList === "new" ? "candidates" : activeList}`).focus({
|
|
253
|
+
preventScroll: true,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** @param {Candidate} record - Candidate to review. @param {boolean} [profile] - Dialog action placement. */
|
|
259
|
+
function reviewActions(record, profile = false) {
|
|
260
|
+
const actions = element("div", "", "review-actions");
|
|
261
|
+
const removed = state.removedIds.includes(record.id);
|
|
262
|
+
const shortlisted = state.selectedIds.includes(record.id);
|
|
263
|
+
const maybe = state.maybeIds.includes(record.id);
|
|
264
|
+
/** @type {[string, string][]} */
|
|
265
|
+
const choices = removed
|
|
266
|
+
? [["restore", "Undo No"]]
|
|
267
|
+
: [
|
|
268
|
+
["shortlist", shortlisted ? "Yes ✓" : "Yes"],
|
|
269
|
+
["maybe", maybe ? "Maybe ✓" : "Maybe"],
|
|
270
|
+
["remove", "No"],
|
|
271
|
+
];
|
|
272
|
+
for (const [action, label] of choices) {
|
|
273
|
+
const button = element(
|
|
274
|
+
"button",
|
|
275
|
+
label,
|
|
276
|
+
action === "remove"
|
|
277
|
+
? "text-button remove-button"
|
|
278
|
+
: "button shortlist-button"
|
|
279
|
+
);
|
|
280
|
+
button.type = "button";
|
|
281
|
+
button.id = `${profile ? "dialog" : "card"}-${action}-${encodeURIComponent(record.id)}`;
|
|
282
|
+
button.setAttribute(
|
|
283
|
+
"aria-label",
|
|
284
|
+
`${action === "shortlist" && shortlisted ? "Undo Yes for" : label} ${record.name}`
|
|
285
|
+
);
|
|
286
|
+
if (action === "maybe") {
|
|
287
|
+
button.className = "button maybe-button";
|
|
288
|
+
button.setAttribute("aria-pressed", String(maybe));
|
|
289
|
+
button.setAttribute(
|
|
290
|
+
"aria-label",
|
|
291
|
+
`${maybe ? "Undo maybe for" : "Maybe"} ${record.name}`
|
|
292
|
+
);
|
|
293
|
+
button.title = maybe ? "Move to unreviewed" : "Keep for further review";
|
|
294
|
+
}
|
|
295
|
+
if (action === "shortlist") {
|
|
296
|
+
button.setAttribute("aria-pressed", String(shortlisted));
|
|
297
|
+
button.title = shortlisted ? "Move to Unreviewed" : "Mark Yes";
|
|
298
|
+
}
|
|
299
|
+
button.addEventListener("click", () => {
|
|
300
|
+
reviewCandidate(record, action);
|
|
301
|
+
});
|
|
302
|
+
actions.append(button);
|
|
303
|
+
}
|
|
304
|
+
return actions;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function renderReviewLists() {
|
|
308
|
+
const removed = state.removedIds.length;
|
|
309
|
+
for (const [list, label, count] of [
|
|
310
|
+
["candidates", "All", view?.records.length ?? 0],
|
|
311
|
+
[
|
|
312
|
+
"unreviewed",
|
|
313
|
+
"Unreviewed",
|
|
314
|
+
(view?.records.length ?? 0) -
|
|
315
|
+
removed -
|
|
316
|
+
state.selectedIds.length -
|
|
317
|
+
state.maybeIds.length,
|
|
318
|
+
],
|
|
319
|
+
["shortlist", "Yes", state.selectedIds.length],
|
|
320
|
+
["maybe", "Maybe", state.maybeIds.length],
|
|
321
|
+
["removed", "No", removed],
|
|
322
|
+
]) {
|
|
323
|
+
const button = byId(`list-${list}`);
|
|
324
|
+
button.textContent = `${label} (${count})`;
|
|
325
|
+
button.setAttribute("aria-pressed", String(activeList === list));
|
|
326
|
+
}
|
|
327
|
+
byId("review-feedback").textContent =
|
|
328
|
+
state.selectedIds.length > 0
|
|
329
|
+
? `${state.selectedIds.length} marked Yes · Ask the agent to work with these candidates.`
|
|
330
|
+
: "Yes for strong matches. Maybe if unsure. No to set aside.";
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** @param {Candidate} record - Candidate. @param {string[]} keys - Fields. @param {string} title - Section heading. */
|
|
334
|
+
function detailSection(record, keys, title) {
|
|
335
|
+
const fields = keys.filter((key) => hasValue(record[key]));
|
|
336
|
+
if (fields.length === 0) {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
const section = element("section", "", "detail-section");
|
|
340
|
+
section.append(element("h3", title));
|
|
341
|
+
const list = element("dl");
|
|
342
|
+
for (const key of fields) {
|
|
343
|
+
const item = element("div", "", "detail");
|
|
344
|
+
const value = element("dd");
|
|
345
|
+
fillValue(value, record[key], fieldName(key));
|
|
346
|
+
if (fields.length > 1) {
|
|
347
|
+
item.append(element("dt", fieldName(key)));
|
|
348
|
+
}
|
|
349
|
+
item.append(value);
|
|
350
|
+
list.append(item);
|
|
351
|
+
}
|
|
352
|
+
section.append(list);
|
|
353
|
+
return section;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** @param {Candidate} record - Supplied fit assessment. */
|
|
357
|
+
function candidateOverview(record) {
|
|
358
|
+
const overview = element("dl", "", "candidate-overview");
|
|
359
|
+
const fit = [
|
|
360
|
+
record.fitSummary,
|
|
361
|
+
record.justification,
|
|
362
|
+
record.summary,
|
|
363
|
+
record.evidence,
|
|
364
|
+
record.fit,
|
|
365
|
+
];
|
|
366
|
+
const unknowns = [record.unconfirmed, record.gaps];
|
|
367
|
+
const hasAssessment =
|
|
368
|
+
hasValue(record.fitSummary) || hasValue(record.justification);
|
|
369
|
+
for (const [label, value, className] of [
|
|
370
|
+
[
|
|
371
|
+
hasAssessment ? "Why they may fit" : "Profile summary",
|
|
372
|
+
fit.find(hasValue),
|
|
373
|
+
"fit-overview",
|
|
374
|
+
],
|
|
375
|
+
[
|
|
376
|
+
"Needs confirmation",
|
|
377
|
+
unknowns.find(hasValue) ?? "Not assessed yet.",
|
|
378
|
+
"unknown-overview",
|
|
379
|
+
],
|
|
380
|
+
]) {
|
|
381
|
+
if (!hasValue(value)) {
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
const row = element("div", "", display(className));
|
|
385
|
+
row.append(element("dt", display(label)), element("dd", display(value)));
|
|
386
|
+
overview.append(row);
|
|
387
|
+
}
|
|
388
|
+
return overview;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** @param {URL} url - Source to label with its actual identity. */
|
|
392
|
+
function sourceLabel(url) {
|
|
393
|
+
const host = url.hostname.replace(/^www\./u, "");
|
|
394
|
+
const path = url.pathname.split("/").filter(Boolean);
|
|
395
|
+
if (host === "linkedin.com") {
|
|
396
|
+
return "LinkedIn profile";
|
|
397
|
+
}
|
|
398
|
+
if (host === "github.com") {
|
|
399
|
+
return path.length > 1
|
|
400
|
+
? `Code: ${path.slice(0, 2).join("/")}`
|
|
401
|
+
: "GitHub profile";
|
|
402
|
+
}
|
|
403
|
+
return host;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** @param {Candidate} record - Candidate source fields. @param {string[]} keys - Source URL fields. */
|
|
407
|
+
function sourceLinks(record, keys) {
|
|
408
|
+
const links = element("div", "", "source-links");
|
|
409
|
+
const seen = new Set();
|
|
410
|
+
for (const key of keys) {
|
|
411
|
+
const url = webUrl(record[key]);
|
|
412
|
+
if (!url || seen.has(url.href.replace(/\/$/u, ""))) {
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
seen.add(url.href.replace(/\/$/u, ""));
|
|
416
|
+
const link = element("span");
|
|
417
|
+
fillValue(link, url.href, sourceLabel(url));
|
|
418
|
+
links.append(link);
|
|
419
|
+
}
|
|
420
|
+
return links;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const careerFields = [
|
|
424
|
+
"workHistory",
|
|
425
|
+
"experience",
|
|
426
|
+
"education",
|
|
427
|
+
"skills",
|
|
428
|
+
"technicalEvidence",
|
|
429
|
+
"leadershipEvidence",
|
|
430
|
+
];
|
|
431
|
+
const contactFields = [
|
|
432
|
+
"email",
|
|
433
|
+
"emailStatus",
|
|
434
|
+
"emailSource",
|
|
435
|
+
"phone",
|
|
436
|
+
"phoneNumber",
|
|
437
|
+
"linkedin",
|
|
438
|
+
"github",
|
|
439
|
+
"website",
|
|
440
|
+
"draftSubject",
|
|
441
|
+
"draftEmail",
|
|
442
|
+
];
|
|
443
|
+
|
|
444
|
+
/** @param {Candidate} record - Candidate to inspect. */
|
|
445
|
+
function profileTabs(record) {
|
|
446
|
+
return [
|
|
447
|
+
{ id: "overview", label: "Overview", visible: true },
|
|
448
|
+
{
|
|
449
|
+
id: "career",
|
|
450
|
+
label: "Career",
|
|
451
|
+
visible: careerFields.some((key) => hasValue(record[key])),
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
id: "contact",
|
|
455
|
+
label: "Contact",
|
|
456
|
+
visible: contactFields.some((key) => hasValue(record[key])),
|
|
457
|
+
},
|
|
458
|
+
].filter((tab) => tab.visible);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** @param {string} id - Profile section. */
|
|
462
|
+
function switchProfileTab(id) {
|
|
463
|
+
profileTab = id;
|
|
464
|
+
renderProfile();
|
|
465
|
+
byId("profile-content").scrollTop = 0;
|
|
466
|
+
byId(`profile-tab-${id}`).focus({ preventScroll: true });
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** @param {Candidate} record - Candidate with available sections. */
|
|
470
|
+
function renderProfileTabs(record) {
|
|
471
|
+
const tabs = profileTabs(record);
|
|
472
|
+
if (!tabs.some((tab) => tab.id === profileTab)) {
|
|
473
|
+
profileTab = "overview";
|
|
474
|
+
}
|
|
475
|
+
const navigation = byId("profile-tabs");
|
|
476
|
+
navigation.replaceChildren();
|
|
477
|
+
for (const [index, tab] of tabs.entries()) {
|
|
478
|
+
const button = element("button", tab.label);
|
|
479
|
+
button.id = `profile-tab-${tab.id}`;
|
|
480
|
+
button.type = "button";
|
|
481
|
+
button.setAttribute("role", "tab");
|
|
482
|
+
button.setAttribute("aria-selected", String(tab.id === profileTab));
|
|
483
|
+
button.setAttribute("aria-controls", "profile-content");
|
|
484
|
+
button.tabIndex = tab.id === profileTab ? 0 : -1;
|
|
485
|
+
button.addEventListener("click", () => {
|
|
486
|
+
switchProfileTab(tab.id);
|
|
487
|
+
});
|
|
488
|
+
button.addEventListener("keydown", (event) => {
|
|
489
|
+
const offsets = new Map([
|
|
490
|
+
["ArrowRight", 1],
|
|
491
|
+
["ArrowLeft", -1],
|
|
492
|
+
["Home", -index],
|
|
493
|
+
["End", tabs.length - 1 - index],
|
|
494
|
+
]);
|
|
495
|
+
const offset = offsets.get(event.key);
|
|
496
|
+
if (offset === undefined) {
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
event.preventDefault();
|
|
500
|
+
const target = tabs[(index + offset + tabs.length) % tabs.length];
|
|
501
|
+
if (target) {
|
|
502
|
+
switchProfileTab(target.id);
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
navigation.append(button);
|
|
506
|
+
}
|
|
507
|
+
byId("profile-content").setAttribute(
|
|
508
|
+
"aria-labelledby",
|
|
509
|
+
`profile-tab-${profileTab}`
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** @param {Candidate} record - Candidate to inspect. */
|
|
514
|
+
function detailsContent(record) {
|
|
515
|
+
const content = element("div");
|
|
516
|
+
if (profileTab !== "overview") {
|
|
517
|
+
const section = detailSection(
|
|
518
|
+
record,
|
|
519
|
+
profileTab === "career" ? careerFields : contactFields,
|
|
520
|
+
profileTab === "career" ? "Career" : "Contact and drafts"
|
|
521
|
+
);
|
|
522
|
+
if (section) {
|
|
523
|
+
content.append(section);
|
|
524
|
+
}
|
|
525
|
+
return content;
|
|
526
|
+
}
|
|
527
|
+
content.append(candidateOverview(record));
|
|
528
|
+
const assessment = detailSection(
|
|
529
|
+
record,
|
|
530
|
+
["justification", "gaps", "evidence"].filter(
|
|
531
|
+
(key) =>
|
|
532
|
+
record[key] !== record.fitSummary && record[key] !== record.unconfirmed
|
|
533
|
+
),
|
|
534
|
+
"Supporting details"
|
|
535
|
+
);
|
|
536
|
+
if (assessment) {
|
|
537
|
+
const disclosure = element("details", "", "more-information");
|
|
538
|
+
disclosure.append(element("summary", "Read full assessment"), assessment);
|
|
539
|
+
content.append(disclosure);
|
|
540
|
+
}
|
|
541
|
+
const links = sourceLinks(record, [
|
|
542
|
+
"linkedin",
|
|
543
|
+
"github",
|
|
544
|
+
"repository",
|
|
545
|
+
"additionalRepository",
|
|
546
|
+
"evidenceLink",
|
|
547
|
+
"website",
|
|
548
|
+
]);
|
|
549
|
+
if (links.childElementCount > 0) {
|
|
550
|
+
const section = element("section", "", "detail-section");
|
|
551
|
+
section.append(element("h3", "Sources"), links);
|
|
552
|
+
content.append(section);
|
|
553
|
+
}
|
|
554
|
+
const used = new Set([
|
|
555
|
+
"id",
|
|
556
|
+
"name",
|
|
557
|
+
"role",
|
|
558
|
+
"company",
|
|
559
|
+
"location",
|
|
560
|
+
"photoUrl",
|
|
561
|
+
"summary",
|
|
562
|
+
"fit",
|
|
563
|
+
"fitSummary",
|
|
564
|
+
"justification",
|
|
565
|
+
"evidence",
|
|
566
|
+
"gaps",
|
|
567
|
+
"unconfirmed",
|
|
568
|
+
"repository",
|
|
569
|
+
"additionalRepository",
|
|
570
|
+
"evidenceLink",
|
|
571
|
+
"batchId",
|
|
572
|
+
...careerFields,
|
|
573
|
+
...contactFields,
|
|
574
|
+
]);
|
|
575
|
+
const extra = detailSection(
|
|
576
|
+
record,
|
|
577
|
+
Object.keys(record).filter((key) => !used.has(key)),
|
|
578
|
+
"More information"
|
|
579
|
+
);
|
|
580
|
+
if (extra) {
|
|
581
|
+
const disclosure = element("details", "", "more-information");
|
|
582
|
+
disclosure.open = sourceIds.has(record.id);
|
|
583
|
+
disclosure.append(element("summary", "More information"));
|
|
584
|
+
extra.querySelector("h3")?.remove();
|
|
585
|
+
disclosure.append(extra);
|
|
586
|
+
disclosure.addEventListener("toggle", () => {
|
|
587
|
+
if (disclosure.open) {
|
|
588
|
+
sourceIds.add(record.id);
|
|
589
|
+
} else {
|
|
590
|
+
sourceIds.delete(record.id);
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
content.append(disclosure);
|
|
594
|
+
}
|
|
595
|
+
return content;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** @param {Candidate} record - Candidate identity. */
|
|
599
|
+
function identity(record) {
|
|
600
|
+
return [record.role, record.company, record.location]
|
|
601
|
+
.filter(hasValue)
|
|
602
|
+
.map(display)
|
|
603
|
+
.join(" · ");
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** @param {Candidate} record - Candidate contact. */
|
|
607
|
+
function emailPanel(record) {
|
|
608
|
+
const address = record.email;
|
|
609
|
+
if (
|
|
610
|
+
typeof address !== "string" ||
|
|
611
|
+
!/^[^\s@<>?]+@[^\s@<>?]+\.[^\s@<>?]+$/u.test(address)
|
|
612
|
+
) {
|
|
613
|
+
return null;
|
|
614
|
+
}
|
|
615
|
+
const panel = element("div", "", "email-panel");
|
|
616
|
+
panel.id = `email-${encodeURIComponent(record.id)}`;
|
|
617
|
+
panel.hidden = !emailIds.has(record.id);
|
|
618
|
+
const top = element("div", "", "email-top");
|
|
619
|
+
const link = element("a", address);
|
|
620
|
+
link.href = `mailto:${encodeURIComponent(address)}`;
|
|
621
|
+
const copy = element("button", "Copy email", "text-button");
|
|
622
|
+
copy.type = "button";
|
|
623
|
+
copy.id = `copy-email-${encodeURIComponent(record.id)}`;
|
|
624
|
+
const feedback = element("p", "", "muted");
|
|
625
|
+
feedback.setAttribute("role", "status");
|
|
626
|
+
const email = address;
|
|
627
|
+
async function copyAddress() {
|
|
628
|
+
try {
|
|
629
|
+
await navigator.clipboard.writeText(email);
|
|
630
|
+
feedback.textContent = "Email copied.";
|
|
631
|
+
} catch {
|
|
632
|
+
feedback.textContent =
|
|
633
|
+
"Could not copy. Select and copy the address above.";
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
copy.addEventListener("click", () => {
|
|
637
|
+
void copyAddress();
|
|
638
|
+
});
|
|
639
|
+
top.append(link, copy);
|
|
640
|
+
panel.append(top);
|
|
641
|
+
if (hasValue(record.emailStatus)) {
|
|
642
|
+
panel.append(element("p", display(record.emailStatus), "muted"));
|
|
643
|
+
}
|
|
644
|
+
panel.append(feedback);
|
|
645
|
+
return panel;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** @param {Candidate} record - Candidate whose supplied photo or initials to show. */
|
|
649
|
+
function candidateAvatar(record) {
|
|
650
|
+
const avatar = element("span", "", "candidate-avatar");
|
|
651
|
+
const parts = record.name
|
|
652
|
+
.replaceAll(/\([^)]*\)/gu, "")
|
|
653
|
+
.trim()
|
|
654
|
+
.split(/\s+/u);
|
|
655
|
+
const initials = [parts[0], ...(parts.length > 1 ? [parts.at(-1)] : [])]
|
|
656
|
+
.map((part) => part?.match(/^./u)?.[0] ?? "")
|
|
657
|
+
.join("")
|
|
658
|
+
.toLocaleUpperCase();
|
|
659
|
+
avatar.append(element("span", initials));
|
|
660
|
+
avatar.setAttribute("aria-hidden", "true");
|
|
661
|
+
const url = webUrl(record.photoUrl);
|
|
662
|
+
if (url?.protocol === "https:" && !url.username && !url.password) {
|
|
663
|
+
const photo = element("img");
|
|
664
|
+
photo.alt = "";
|
|
665
|
+
photo.width = 64;
|
|
666
|
+
photo.height = 64;
|
|
667
|
+
photo.loading = "lazy";
|
|
668
|
+
photo.decoding = "async";
|
|
669
|
+
photo.referrerPolicy = "no-referrer";
|
|
670
|
+
photo.addEventListener(
|
|
671
|
+
"error",
|
|
672
|
+
() => {
|
|
673
|
+
photo.remove();
|
|
674
|
+
},
|
|
675
|
+
{ once: true }
|
|
676
|
+
);
|
|
677
|
+
photo.src = url.href;
|
|
678
|
+
avatar.append(photo);
|
|
679
|
+
}
|
|
680
|
+
return avatar;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** @param {Candidate} record - Candidate identity and image. @param {boolean} [profile] - Whether this is the open profile. */
|
|
684
|
+
function candidateHeading(record, profile = false) {
|
|
685
|
+
const heading = element("div", "", "candidate-heading");
|
|
686
|
+
const text = element("div", "", "candidate-heading-text");
|
|
687
|
+
const name = element("h2", record.name);
|
|
688
|
+
if (profile) {
|
|
689
|
+
name.id = "profile-name";
|
|
690
|
+
}
|
|
691
|
+
text.append(name, element("p", identity(record), "identity"));
|
|
692
|
+
heading.append(candidateAvatar(record), text);
|
|
693
|
+
return heading;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** @param {Candidate} record - Candidate card. */
|
|
697
|
+
function candidateCard(record) {
|
|
698
|
+
const card = element("article", "", "candidate-card");
|
|
699
|
+
card.dataset.candidateId = record.id;
|
|
700
|
+
card.classList.toggle("selected", state.selectedIds.includes(record.id));
|
|
701
|
+
card.classList.toggle("maybe", state.maybeIds.includes(record.id));
|
|
702
|
+
card.classList.toggle("removed", state.removedIds.includes(record.id));
|
|
703
|
+
card.append(candidateHeading(record));
|
|
704
|
+
card.append(candidateOverview(record));
|
|
705
|
+
const actions = element("div", "", "card-actions");
|
|
706
|
+
actions.append(profileButton(record, "View details", "details-button"));
|
|
707
|
+
if (webUrl(record.linkedin)) {
|
|
708
|
+
const link = element("span");
|
|
709
|
+
fillValue(link, record.linkedin, "LinkedIn");
|
|
710
|
+
actions.append(link);
|
|
711
|
+
}
|
|
712
|
+
const contact = emailPanel(record);
|
|
713
|
+
if (contact) {
|
|
714
|
+
const button = element("button", "Email", "text-button");
|
|
715
|
+
button.id = `email-toggle-${encodeURIComponent(record.id)}`;
|
|
716
|
+
button.type = "button";
|
|
717
|
+
button.setAttribute("aria-label", `Email for ${record.name}`);
|
|
718
|
+
button.setAttribute("aria-controls", contact.id);
|
|
719
|
+
button.setAttribute("aria-expanded", String(!contact.hidden));
|
|
720
|
+
button.addEventListener("click", () => {
|
|
721
|
+
contact.hidden = !contact.hidden;
|
|
722
|
+
if (contact.hidden) {
|
|
723
|
+
emailIds.delete(record.id);
|
|
724
|
+
} else {
|
|
725
|
+
emailIds.add(record.id);
|
|
726
|
+
}
|
|
727
|
+
button.setAttribute("aria-expanded", String(!contact.hidden));
|
|
728
|
+
});
|
|
729
|
+
actions.append(button);
|
|
730
|
+
}
|
|
731
|
+
actions.append(reviewActions(record));
|
|
732
|
+
card.append(actions);
|
|
733
|
+
if (contact) {
|
|
734
|
+
card.append(contact);
|
|
735
|
+
}
|
|
736
|
+
return card;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** @param {Candidate} record - Candidate to open. @param {string} [label] - Button text. @param {string} [className] - Button style. */
|
|
740
|
+
function profileButton(
|
|
741
|
+
record,
|
|
742
|
+
label = record.name,
|
|
743
|
+
className = "profile-link"
|
|
744
|
+
) {
|
|
745
|
+
const button = element("button", label, className);
|
|
746
|
+
button.id = `profile-${encodeURIComponent(record.id)}`;
|
|
747
|
+
button.setAttribute("aria-label", `View details for ${record.name}`);
|
|
748
|
+
button.setAttribute("aria-haspopup", "dialog");
|
|
749
|
+
button.setAttribute("aria-controls", "profile");
|
|
750
|
+
button.type = "button";
|
|
751
|
+
button.addEventListener("click", () => {
|
|
752
|
+
focusedId = record.id;
|
|
753
|
+
profileOpenerId = record.id;
|
|
754
|
+
profileTab = "overview";
|
|
755
|
+
renderProfile();
|
|
756
|
+
dialog.showModal();
|
|
757
|
+
byId("profile-content").scrollTop = 0;
|
|
758
|
+
byId("close-profile").focus({ preventScroll: true });
|
|
759
|
+
});
|
|
760
|
+
return button;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** @param {boolean} [includeFocused] - Keep the open profile navigable after a decision changes its list membership. */
|
|
764
|
+
function reviewRecords(includeFocused = false) {
|
|
765
|
+
return (view?.records ?? []).filter((record) => {
|
|
766
|
+
if (includeFocused && record.id === focusedId) {
|
|
767
|
+
return true;
|
|
768
|
+
}
|
|
769
|
+
const removed = state.removedIds.includes(record.id);
|
|
770
|
+
if (activeList === "candidates") {
|
|
771
|
+
return true;
|
|
772
|
+
}
|
|
773
|
+
if (activeList === "unreviewed") {
|
|
774
|
+
return (
|
|
775
|
+
!removed &&
|
|
776
|
+
!state.selectedIds.includes(record.id) &&
|
|
777
|
+
!state.maybeIds.includes(record.id)
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
if (activeList === "removed") {
|
|
781
|
+
return removed;
|
|
782
|
+
}
|
|
783
|
+
return (
|
|
784
|
+
!removed &&
|
|
785
|
+
(activeList !== "shortlist" || state.selectedIds.includes(record.id)) &&
|
|
786
|
+
(activeList !== "maybe" || state.maybeIds.includes(record.id)) &&
|
|
787
|
+
(activeList !== "new" || batchIds.has(record.id))
|
|
788
|
+
);
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function renderProfileNavigation() {
|
|
793
|
+
const records = reviewRecords(true);
|
|
794
|
+
const index = records.findIndex((record) => record.id === focusedId);
|
|
795
|
+
byId("profile-position").textContent = `${index + 1} of ${records.length}`;
|
|
796
|
+
const previous = byId("profile-previous");
|
|
797
|
+
const next = byId("profile-next");
|
|
798
|
+
if (
|
|
799
|
+
previous instanceof HTMLButtonElement &&
|
|
800
|
+
next instanceof HTMLButtonElement
|
|
801
|
+
) {
|
|
802
|
+
previous.disabled = index <= 0;
|
|
803
|
+
next.disabled = index === -1 || index === records.length - 1;
|
|
804
|
+
if (
|
|
805
|
+
document.activeElement === previous &&
|
|
806
|
+
previous.disabled &&
|
|
807
|
+
!next.disabled
|
|
808
|
+
) {
|
|
809
|
+
next.focus({ preventScroll: true });
|
|
810
|
+
} else if (
|
|
811
|
+
document.activeElement === next &&
|
|
812
|
+
next.disabled &&
|
|
813
|
+
!previous.disabled
|
|
814
|
+
) {
|
|
815
|
+
previous.focus({ preventScroll: true });
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** @param {number} offset - Direction within the current review list. */
|
|
821
|
+
function moveProfile(offset) {
|
|
822
|
+
const records = reviewRecords(true);
|
|
823
|
+
const index = records.findIndex((record) => record.id === focusedId);
|
|
824
|
+
const record = index === -1 ? undefined : records[index + offset];
|
|
825
|
+
if (!record) {
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
focusedId = record.id;
|
|
829
|
+
profileTab = "overview";
|
|
830
|
+
renderProfile();
|
|
831
|
+
byId("profile-content").scrollTop = 0;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function renderProfile() {
|
|
835
|
+
const record = view?.records.find((record) => record.id === focusedId);
|
|
836
|
+
if (!record) {
|
|
837
|
+
dialog.close();
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
renderProfileNavigation();
|
|
841
|
+
const content = byId("profile-content");
|
|
842
|
+
const { scrollTop } = content;
|
|
843
|
+
const focusedAction = dialog.contains(document.activeElement)
|
|
844
|
+
? document.activeElement?.id
|
|
845
|
+
: "";
|
|
846
|
+
byId("profile-identity").replaceChildren(candidateHeading(record, true));
|
|
847
|
+
renderProfileTabs(record);
|
|
848
|
+
content.replaceChildren(detailsContent(record));
|
|
849
|
+
let status = "Unreviewed";
|
|
850
|
+
if (state.removedIds.includes(record.id)) {
|
|
851
|
+
status = "No";
|
|
852
|
+
} else if (state.selectedIds.includes(record.id)) {
|
|
853
|
+
status = "Yes";
|
|
854
|
+
} else if (state.maybeIds.includes(record.id)) {
|
|
855
|
+
status = "Maybe";
|
|
856
|
+
}
|
|
857
|
+
const decision = element("span", status, "decision-status");
|
|
858
|
+
decision.setAttribute("role", "status");
|
|
859
|
+
byId("profile-actions").replaceChildren(
|
|
860
|
+
decision,
|
|
861
|
+
reviewActions(record, true)
|
|
862
|
+
);
|
|
863
|
+
if (focusedAction) {
|
|
864
|
+
const action = document.querySelector(`#${CSS.escape(focusedAction)}`);
|
|
865
|
+
if (action instanceof HTMLElement) {
|
|
866
|
+
action.focus({ preventScroll: true });
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
content.scrollTop = scrollTop;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** @param {HTMLElement} container - Results container. */
|
|
873
|
+
function readingPosition(container) {
|
|
874
|
+
const focused = document.activeElement;
|
|
875
|
+
const focusId =
|
|
876
|
+
focused instanceof HTMLElement && container.contains(focused)
|
|
877
|
+
? focused.id
|
|
878
|
+
: "";
|
|
879
|
+
const anchor = [
|
|
880
|
+
...container.querySelectorAll("article[data-candidate-id]"),
|
|
881
|
+
].find((card) => card.getBoundingClientRect().bottom > 0);
|
|
882
|
+
return {
|
|
883
|
+
anchorId:
|
|
884
|
+
anchor instanceof HTMLElement ? anchor.dataset.candidateId : undefined,
|
|
885
|
+
anchorTop: anchor?.getBoundingClientRect().top,
|
|
886
|
+
focusId,
|
|
887
|
+
scroll: container.scrollLeft,
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/** @param {HTMLElement} container - Results container. @param {ReturnType<typeof readingPosition>} position - Previous position. @param {boolean} preservePosition - Whether results changed externally. */
|
|
892
|
+
function restorePosition(container, position, preservePosition) {
|
|
893
|
+
const { anchorId, anchorTop, focusId, scroll } = position;
|
|
894
|
+
container.scrollLeft = scroll;
|
|
895
|
+
if (preservePosition && anchorId && anchorTop !== undefined) {
|
|
896
|
+
const nextAnchor = [
|
|
897
|
+
...container.querySelectorAll("article[data-candidate-id]"),
|
|
898
|
+
].find(
|
|
899
|
+
(card) =>
|
|
900
|
+
card instanceof HTMLElement && card.dataset.candidateId === anchorId
|
|
901
|
+
);
|
|
902
|
+
if (nextAnchor) {
|
|
903
|
+
window.scrollBy(0, nextAnchor.getBoundingClientRect().top - anchorTop);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
if (focusId) {
|
|
907
|
+
const focused = document.querySelector(`#${CSS.escape(focusId)}`);
|
|
908
|
+
if (focused instanceof HTMLElement) {
|
|
909
|
+
focused.focus({ preventScroll: true });
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function renderBatchNotice() {
|
|
915
|
+
const available = [...pendingIds].filter(
|
|
916
|
+
(id) => !state.removedIds.includes(id)
|
|
917
|
+
);
|
|
918
|
+
byId("new-batch").hidden = available.length === 0;
|
|
919
|
+
byId("new-batch-count").textContent =
|
|
920
|
+
`${available.length} new candidates added`;
|
|
921
|
+
byId("batch-context").hidden = activeList !== "new";
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/** @param {ReturnType<typeof pageOf<Candidate>>} page - Current page. */
|
|
925
|
+
function renderPagination(page) {
|
|
926
|
+
pages.set(activeList, page.page);
|
|
927
|
+
for (const position of ["top", "bottom"]) {
|
|
928
|
+
byId(`pagination-${position}`).hidden = page.total <= 20;
|
|
929
|
+
byId(`page-range-${position}`).textContent =
|
|
930
|
+
`${page.start}–${page.end} of ${page.total}`;
|
|
931
|
+
const previous = byId(`previous-${position}`);
|
|
932
|
+
const next = byId(`next-${position}`);
|
|
933
|
+
if (
|
|
934
|
+
previous instanceof HTMLButtonElement &&
|
|
935
|
+
next instanceof HTMLButtonElement
|
|
936
|
+
) {
|
|
937
|
+
previous.disabled = page.page === 0;
|
|
938
|
+
next.disabled = page.page === page.pages - 1;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/** @param {boolean} [preservePosition] - Keep the reading position on live updates. */
|
|
944
|
+
function renderResults(preservePosition = false) {
|
|
945
|
+
if (!view) {
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
const records = reviewRecords();
|
|
949
|
+
const page = pageOf(records, pages.get(activeList) ?? 0);
|
|
950
|
+
const container = byId("results");
|
|
951
|
+
const position = readingPosition(container);
|
|
952
|
+
container.replaceChildren();
|
|
953
|
+
renderReviewLists();
|
|
954
|
+
renderPagination(page);
|
|
955
|
+
renderBatchNotice();
|
|
956
|
+
if (records.length === 0) {
|
|
957
|
+
const messages = new Map([
|
|
958
|
+
[
|
|
959
|
+
"unreviewed",
|
|
960
|
+
"You have reviewed every candidate. Check Yes or Maybe, or ask your agent for more.",
|
|
961
|
+
],
|
|
962
|
+
[
|
|
963
|
+
"maybe",
|
|
964
|
+
"No candidates marked Maybe. Use Maybe when you need more information.",
|
|
965
|
+
],
|
|
966
|
+
[
|
|
967
|
+
"shortlist",
|
|
968
|
+
"No candidates marked Yes yet. Choose Yes on a candidate card.",
|
|
969
|
+
],
|
|
970
|
+
["removed", "No candidates marked No. You can undo a No decision here."],
|
|
971
|
+
]);
|
|
972
|
+
const message =
|
|
973
|
+
messages.get(activeList) ??
|
|
974
|
+
"No candidates to review. Your agent can send another batch.";
|
|
975
|
+
container.append(element("p", message, "empty"));
|
|
976
|
+
} else {
|
|
977
|
+
const grid = element("div", "", "cards");
|
|
978
|
+
for (const record of page.records) {
|
|
979
|
+
grid.append(candidateCard(record));
|
|
980
|
+
}
|
|
981
|
+
container.append(grid);
|
|
982
|
+
}
|
|
983
|
+
restorePosition(container, position, preservePosition);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
async function refresh() {
|
|
987
|
+
fetchSequence += 1;
|
|
988
|
+
const sequence = fetchSequence;
|
|
989
|
+
try {
|
|
990
|
+
const next = readView(await api(base));
|
|
991
|
+
if (sequence !== fetchSequence) {
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
if (view) {
|
|
995
|
+
for (const id of addedRecordIds(view.records, next.records)) {
|
|
996
|
+
pendingIds.add(id);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
const previousRevision = view?.revision;
|
|
1000
|
+
const previousState = JSON.stringify(state);
|
|
1001
|
+
if (!view || savedStateVersion === stateVersion) {
|
|
1002
|
+
({ state } = next);
|
|
1003
|
+
}
|
|
1004
|
+
const ids = new Set(next.records.map((record) => record.id));
|
|
1005
|
+
for (const id of pendingIds) {
|
|
1006
|
+
if (!ids.has(id)) {
|
|
1007
|
+
pendingIds.delete(id);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
state.selectedIds = state.selectedIds.filter((id) => ids.has(id));
|
|
1011
|
+
state.removedIds = state.removedIds.filter((id) => ids.has(id));
|
|
1012
|
+
state.maybeIds = state.maybeIds.filter((id) => ids.has(id));
|
|
1013
|
+
view = next;
|
|
1014
|
+
byId("welcome").hidden = true;
|
|
1015
|
+
byId("workspace").hidden = false;
|
|
1016
|
+
byId("error").hidden = true;
|
|
1017
|
+
byId("view-title").textContent = view.title;
|
|
1018
|
+
document.title = `${view.title} · Scout Mesh`;
|
|
1019
|
+
if (
|
|
1020
|
+
previousRevision !== view.revision ||
|
|
1021
|
+
previousState !== JSON.stringify(state)
|
|
1022
|
+
) {
|
|
1023
|
+
renderResults(true);
|
|
1024
|
+
}
|
|
1025
|
+
if (
|
|
1026
|
+
dialog.open &&
|
|
1027
|
+
(previousRevision !== view.revision ||
|
|
1028
|
+
previousState !== JSON.stringify(state))
|
|
1029
|
+
) {
|
|
1030
|
+
renderProfile();
|
|
1031
|
+
}
|
|
1032
|
+
} catch (error) {
|
|
1033
|
+
showError(error);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/** @param {string} position - Pagination bar location. @param {string} direction - Page direction. @param {number} offset - Page increment. */
|
|
1038
|
+
function bindPageButton(position, direction, offset) {
|
|
1039
|
+
byId(`${direction}-${position}`).addEventListener("click", () => {
|
|
1040
|
+
pages.set(activeList, (pages.get(activeList) ?? 0) + offset);
|
|
1041
|
+
renderResults();
|
|
1042
|
+
byId("results").scrollIntoView({ block: "start" });
|
|
1043
|
+
byId(`page-range-top`).focus({ preventScroll: true });
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
for (const position of ["top", "bottom"]) {
|
|
1047
|
+
bindPageButton(position, "previous", -1);
|
|
1048
|
+
bindPageButton(position, "next", 1);
|
|
1049
|
+
}
|
|
1050
|
+
byId("view-new").addEventListener("click", () => {
|
|
1051
|
+
returnScroll = window.scrollY;
|
|
1052
|
+
batchIds = new Set(pendingIds);
|
|
1053
|
+
pendingIds.clear();
|
|
1054
|
+
activeList = "new";
|
|
1055
|
+
pages.set("new", 0);
|
|
1056
|
+
renderResults();
|
|
1057
|
+
byId("batch-context").scrollIntoView({ block: "start" });
|
|
1058
|
+
byId("back-to-candidates").focus({ preventScroll: true });
|
|
1059
|
+
});
|
|
1060
|
+
byId("back-to-candidates").addEventListener("click", () => {
|
|
1061
|
+
activeList = "candidates";
|
|
1062
|
+
renderResults();
|
|
1063
|
+
window.scrollTo(0, returnScroll);
|
|
1064
|
+
byId("list-candidates").focus({ preventScroll: true });
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
/** @param {string} list - Candidate list to show. */
|
|
1068
|
+
function bindReviewList(list) {
|
|
1069
|
+
byId(`list-${list}`).addEventListener("click", () => {
|
|
1070
|
+
activeList = list;
|
|
1071
|
+
renderResults();
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
for (const list of [
|
|
1075
|
+
"candidates",
|
|
1076
|
+
"unreviewed",
|
|
1077
|
+
"shortlist",
|
|
1078
|
+
"maybe",
|
|
1079
|
+
"removed",
|
|
1080
|
+
]) {
|
|
1081
|
+
bindReviewList(list);
|
|
1082
|
+
}
|
|
1083
|
+
byId("undo-button").addEventListener("click", () => {
|
|
1084
|
+
if (!lastRemoval) {
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
const record = view?.records.find((record) => record.id === lastRemoval?.id);
|
|
1088
|
+
if (record) {
|
|
1089
|
+
reviewCandidate(record, "undo");
|
|
1090
|
+
}
|
|
1091
|
+
lastRemoval = null;
|
|
1092
|
+
byId("undo-removal").hidden = true;
|
|
1093
|
+
});
|
|
1094
|
+
byId("profile-previous").addEventListener("click", () => {
|
|
1095
|
+
moveProfile(-1);
|
|
1096
|
+
});
|
|
1097
|
+
byId("profile-next").addEventListener("click", () => {
|
|
1098
|
+
moveProfile(1);
|
|
1099
|
+
});
|
|
1100
|
+
byId("close-profile").addEventListener("click", () => {
|
|
1101
|
+
dialog.close();
|
|
1102
|
+
});
|
|
1103
|
+
dialog.addEventListener("close", () => {
|
|
1104
|
+
const opener = document.querySelector(
|
|
1105
|
+
`#${CSS.escape(`profile-${encodeURIComponent(profileOpenerId)}`)}`
|
|
1106
|
+
);
|
|
1107
|
+
if (opener instanceof HTMLElement) {
|
|
1108
|
+
opener.focus({ preventScroll: true });
|
|
1109
|
+
} else {
|
|
1110
|
+
byId(`list-${activeList === "new" ? "candidates" : activeList}`).focus({
|
|
1111
|
+
preventScroll: true,
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
});
|
|
1115
|
+
async function refreshViews() {
|
|
1116
|
+
try {
|
|
1117
|
+
const views = readSummaries(await api("/api/views"));
|
|
1118
|
+
byId("views").replaceChildren(
|
|
1119
|
+
...views.map((view) => {
|
|
1120
|
+
const link = element("a", "", "view-link");
|
|
1121
|
+
link.href = `/?view=${encodeURIComponent(view.id)}`;
|
|
1122
|
+
link.append(
|
|
1123
|
+
element("strong", view.title),
|
|
1124
|
+
element("span", `${view.count} candidates →`)
|
|
1125
|
+
);
|
|
1126
|
+
return link;
|
|
1127
|
+
})
|
|
1128
|
+
);
|
|
1129
|
+
byId("error").hidden = true;
|
|
1130
|
+
} catch (error) {
|
|
1131
|
+
showError(error);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
await unlockViewer();
|
|
1136
|
+
|
|
1137
|
+
if (viewId) {
|
|
1138
|
+
await refresh();
|
|
1139
|
+
const events = new EventSource(`${base}/events`);
|
|
1140
|
+
events.addEventListener("open", () => {
|
|
1141
|
+
byId("connection-warning").hidden = true;
|
|
1142
|
+
});
|
|
1143
|
+
events.addEventListener("message", () => {
|
|
1144
|
+
void refresh();
|
|
1145
|
+
});
|
|
1146
|
+
events.addEventListener("error", () => {
|
|
1147
|
+
byId("connection-warning").hidden = false;
|
|
1148
|
+
});
|
|
1149
|
+
window.addEventListener("pagehide", () => {
|
|
1150
|
+
events.close();
|
|
1151
|
+
});
|
|
1152
|
+
} else {
|
|
1153
|
+
await refreshViews();
|
|
1154
|
+
setInterval(() => {
|
|
1155
|
+
void refreshViews();
|
|
1156
|
+
}, 3000);
|
|
1157
|
+
}
|