1/* ============================================================================ 2 The lab results dashboard (T343): one component, on the member's page 3 (app/analysis/lab-results.html) and on the expert's client page 4 (app/coach/client-results.html), drawn from the standard (BP_UI: the head, 5 the section, the card, the table, the pill, the band, the empty and error 6 states, and the trend the standard gained for it). 7 8 BP_LAB_DASH.mount(host, { 9 api(op, { params, method, body }) -> Promise the page's own door 10 viewer: "person" | "expert", readOnly: bool (an administrator's window) 11 buttons: "member" | "console" the layer's own form of the one primary act 12 embedded: bool a section of a page that has its own head (the 13 expert's client page), not a page of its own 14 onForm(panel | null) open the add or edit form (lab-result-form.js) 15 }) 16 17 What it draws, and the rules it keeps (analysis/lab-results-contract.md): 18 the latest reading per marker with its date, unit, range and author; the 19 change from the reading before it as a signed number, never a verdict; every 20 reading by the date the sample was taken, all of them kept on one date; the 21 organisation's range as the band and the only source of an in or out word, and 22 each report's own printed range at its own reading (hub ruling 7); readings 23 that cannot share the line kept as reported, apart; the markers in their 24 groups; each report's entry with its report, its change and its deletion. Every 25 word comes from the app's packs (BP_I18N.t, keys labres.*). Every user-entered 26 word is escaped. The unit a person reads a marker in is remembered in 27 localStorage (unit codes only); nothing a person types is. 28 ========================================================================== */ 29(function () { 30 "use strict"; 31 32 var PREF = "bp.labres.units.v1"; 33 function t(k, v) { return window.BP_I18N ? window.BP_I18N.t(k, v) : k; } 34 /* the standard's draw functions (design-system/js/ui.js, loaded before this file) */ 35 var UI = window.BP_UI; 36 function esc(s) { return UI.esc(s); } 37 function locale() { return window.BP_I18N && window.BP_I18N.locale ? window.BP_I18N.locale() : "en"; } 38 /* T536: a word of the content table (surface app, page lab-results), handed with the dashboard; the dashboard's own 39 check (WORDS) has refused to draw before a word is missing, so this never shows a key */ 40 function cword(d, key) { 41 var w = d && d.lab_settings && d.lab_settings.words, p = (w && w[locale()]) || {}; 42 return typeof p[key] === "string" ? p[key] : ""; 43 } 44 45 function day(iso) { 46 if (!iso) return ""; 47 var d = new Date(String(iso).length === 10 ? iso + "T12:00:00Z" : iso); 48 try { return d.toLocaleDateString(locale() === "nl" ? "nl-NL" : "en-GB", { day: "numeric", month: "short", year: "numeric" }); } 49 catch (e) { return String(iso).slice(0, 10); } 50 } 51 /* A figure keeps three significant digits wherever the marker's own precision 52 does not apply, and never rounds a measured value away (0.0001 reads 0.000100, 53 not 0.000): a value the precision would show as zero takes its digits instead. */ 54 function decimals(v, precision) { 55 if (precision != null) return precision; 56 var a = Math.abs(v); 57 if (a === 0) return 0; 58 return a >= 100 ? 0 : a >= 10 ? 1 : a >= 1 ? 2 : Math.max(3, Math.ceil(-Math.log10(a)) + 2); 59 } 60 function fmt(v, precision) { 61 if (v == null || !isFinite(v)) return ""; 62 var dp = decimals(v, precision); 63 if (v !== 0 && Number(Math.abs(v).toFixed(dp)) === 0) dp = decimals(v, null); 64 var s = Number(v).toFixed(Math.min(dp, 12)); 65 return locale() === "nl" ? s.replace(".", ",") : s; 66 } 67 /* a number as the report printed it: never rounded, only written in the page's decimal mark */ 68 function asPrinted(v) { 69 if (v == null || !isFinite(v)) return ""; 70 var s = String(Number(v)); 71 return locale() === "nl" ? s.replace(".", ",") : s; 72 } 73 /* T508 Addendum 3: a range's limit as the laboratory prints it. A strict laboratory limit ('< 0.5') is stored a 74 millionth below its number so the three laboratory states read it exactly; it is shown as the laboratory's own 75 number (to five decimals), never as 0.499999. A person's reported value is never passed through this. */
76 function asLimit(v) { 77 if (v == null || !isFinite(v)) return ""; 78 return asPrinted(Math.round(Number(v) * 1e5) / 1e5); 79 } 80 function readPref() { 81 try { return JSON.parse(localStorage.getItem(PREF) || "{}") || {}; } catch (e) { return {}; } 82 } 83 function writePref(p) { 84 try { localStorage.setItem(PREF, JSON.stringify(p)); } catch (e) { /* a private window keeps no preference */ } 85 } 86 function label(obj) { return obj ? (locale() === "nl" ? obj.nl || obj.en : obj.en || obj.nl) : ""; } 87 88 /* ---- the words of a reading ---------------------------------------------- */ 89 var SIGN = { lt: "< ", le: "<= ", gt: "> ", ge: ">= " }; 90 var QUAL = ["positive", "negative", "detected", "not_detected", "reactive", "non_reactive", "trace", "present", "absent"]; 91 function reportedWords(r, words) { 92 // a word a result may be is read in the page's language (T457: the copy table's word.<key>, the nine of 090 93 // falling back to their labres.q words); any other text (a laboratory's own comment on an ordered result) is 94 // shown as it came 95 if (r.text) { 96 var w = words && words["word." + r.text]; 97 if (w) return w; 98 return QUAL.indexOf(r.text) >= 0 ? t("labres.q." + r.text) : String(r.text); 99 } 100 var unit = r.reported_unit ? " " + r.reported_unit.symbol : ""; 101 return (SIGN[r.qualifier] || "") + asPrinted(r.reported) + unit; 102 } 103 /* a range's words: as printed where it is the report's own in its own unit, three 104 significant digits where it was converted */ 105 function rangeWords(low, high, unit, printed, houseLimit) { 106 var u = unit ? " " + unit : ""; 107 // a laboratory report's printed range stays exactly as printed; a house range's limit as the laboratory prints it 108 var w = printed ? (houseLimit ? asLimit : asPrinted) : function (v) { return fmt(v, null); }; 109 if (low != null && high != null) return t("labres.range.both", { low: w(low), high: w(high) }) + u; 110 if (low != null) return t("labres.range.from", { low: w(low) }) + u; 111 if (high != null) return t("labres.range.to", { high: w(high) }) + u; 112 return ""; 113 } 114 /* an error code the server answers, in the page's words, or the general sentence */ 115 function errText(code) { 116 var k = "labres.err." + (code || "generic"); 117 var v = t(k); 118 return v === k ? t("labres.err.generic") : v; 119 } 120 function reportRange(r) { return (r.ranges || []).filter(function (x) { return x.kind === "report"; })[0] || null; } 121 122 /* ========================================================================== 123 THE DASHBOARD (T385b, on T385a's design that Jesse approved on 2026-09-25; 124 T343b's one glance before it). Everything between this banner and the END 125 banner below is the dashboard's own drawing: the bar (the Status and Health 126 areas filters, the grid and list switch), the count, the grid of cards in 127 their groups or the list, and the popup a marker opens (its status, its tags, 128 its readings over its bands, the reference range, "What is X?", its reports). 129 130 DASH.check(d) throws when a word the dashboard reads is missing in 131 the page's language (the page then says it failed) 132 DASH.html(d, o) the markup (d: the dashboard answer, o: the mount options) 133 DASH.after(ctx) after a paint: the toolbar's state, the wiring (once per 134 host), a popup the address names 135 DASH.click(e, ctx) the dashboard's own clicks; true when it was one 136 ctx: { host, o, data, repaint(), said(text, bad) } 137 138 THE SERVER DECIDED EVERY STATUS. A series carries `settings` (the scheme: five 139 statuses, three lab states or none, its intervals, its reference range and 140 source), `tags` and `description` (approved texts only); a reading carries 141 `status`. This file draws them and never classifies. Words: the page's own new 142 words are the database's (`d.lab_settings.words`, content table, surface app, 143 page lab-results), the words the packs already held come through t(). 144 ========================================================================== */ 145 var DASH = (function () { 146 var STATUS = ["optimal", "healthy", "normal", "below_average", "risk"]; 147 var LABS = ["lab_below", "lab_within", "lab_above"]; 148 var VIEW_KEY = "bp.labres.view.v1"; 149 /* every word of the database this dashboard reads, named here, so a word missing in the page's language fails the 150 page before anything is drawn */ 151 var WORDS = ["kept.open", "corrected", "corrected.expert", "form.about", "form.source", "form.source.open", "form.labMeasured", 152 "form.orderedThrough", "form.orderedThrough.help", "form.requestedBy", "form.requestedBy.help", "form.more", "form.printedRef", 153 "form.printedRef.help", "form.context", "form.context.help", "form.method", "form.method.help", "err.invalid_meta", 154 "err.no_such_record", "err.partly_saved", "view.label", "view.grid", "view.list", "filter.label", "filter.status", "filter.areas", "filter.chosen", 155 "filter.status.legend", "filter.areas.legend", "filter.status.other", "filter.clear", "filter.done", "results.count", 156 "results.reset", "results.empty.title", "results.empty.says", "list.t
156itle", "list.col.marker", "list.col.result", 157 "list.col.status", "list.row.name", "card.open", "tags.label", "tags.more", "status.optimal", "status.healthy", 158 "status.normal", "status.below_average", "status.risk", "state.lab_below", "state.lab_within", "state.lab_above", 159 "state.none", "popup.kicker", "popup.close", "popup.result", "popup.date", "popup.tags", "popup.what", 160 "popup.what.unavailable", "popup.graph", "popup.lab", "popup.lab.female", "popup.lab.male", "popup.bands.approved", 161 "popup.bands.range", "popup.bands.unset", "popup.source", "popup.source.open", "popup.reports", "graph.summary", "graph.legend", 162 "graph.scale", "graph.status", "value.none"]; 163 var states = new WeakMap(), nextId = 0; 164 function state(o) { 165 if (!states.has(o)) states.set(o, { id: "labx-" + (++nextId), view: null, status: [], area: [], kind: [], open: null, panel: null, opener: null, read: false }); 166 return states.get(o); 167 } 168 169 /* ---- words -------------------------------------------------------------------- */ 170 function missing(what) { var e = new Error("missing " + what); e.labres = what; return e; } 171 function fill(s, vars) { 172 if (vars) Object.keys(vars).forEach(function (k) { s = s.split("{" + k + "}").join(String(vars[k])); }); 173 return s; 174 } 175 function pack(d) { var w = d && d.lab_settings && d.lab_settings.words; return (w && w[locale()]) || {}; } 176 function wx(d, key, vars) { 177 var s = pack(d)[key]; 178 if (typeof s !== "string" || !s) throw missing("word:" + key); 179 return fill(s, vars); 180 } 181 function check(d) { 182 if (!d || !d.lab_settings) throw missing("lab_settings"); 183 WORDS.forEach(function (k) { wx(d, k); }); 184 (d.lab_settings.health_areas || []).forEach(function (a) { if (!label(a.name)) throw missing("area:" + a.key); }); 185 } 186 function statusWord(d, key) { 187 if (key && STATUS.indexOf(key) >= 0) return wx(d, "status." + key); 188 if (key && LABS.indexOf(key) >= 0) return wx(d, "state." + key); 189 return wx(d, "state.none"); 190 } 191 function areaOf(d, key) { return (d.lab_settings.health_areas || []).filter(function (a) { return a.key === key; })[0] || null; } 192 function areasSorted(d) { 193 return (d.lab_settings.health_areas || []).slice().sort(function (a, b) { return label(a.name).localeCompare(label(b.name), locale()); }); 194 } 195 196 /* ---- T536: the kinds of test and the laboratories' references ---------------------------------------------------- 197 The server gives each series its kind and, where a laboratory prints one, its reference (in the series' canonical 198 unit). A reference is drawn as neutral bands with the laboratory's own words; it never colours a card and never 199 says a BioProfile word. The kinds' and the references' words are the content table's: until every one is there 200 the page draws as before (grouped by the twelve groups, the plain trend). */ 201 var KIND_WORDS = ["filter.kinds", "filter.kinds.legend", "kind.note.reference", "kind.note.reported", "ref.origin", "ref.origin.confirmed", 202 "ref.lab", "ref.printed", "ref.legend", "ref.below", "ref.within", "ref.above", "ref.limit", "words.title", "meta.mark", 203 "kind.note.mixed"]; 204 function kindsOn(d) { return !!(d && d.kinds && d.kinds.length) && hasAll(d, KIND_WORDS); } 205 function kindsOrder(d) { 206 var keys = (d.kinds || []).map(function (k) { return k.key; }); 207 if (keys.indexOf("blood") < 0) keys.unshift("blood"); 208 return keys; 209 } 210 function kindName(d, key) { 211 var k = (d.kinds || []).filter(function (y) { return y.key === key; })[0]; 212 return k ? label(k.name) : t("labres.group.other"); 213 } 214 function judged(x) { return x.kind === "five" || x.kind === "lab"; } 215 /* T508 Addendum 3: a value whose range is a laboratory's three states AND that has a laboratory's own reference is 216 drawn the laboratory's way (its classes, its words, its caution); the status comes from the set (Jesse 2026-10-06: 217 every range live, the laboratory's reference shown as it prints it) */ 218 function refLed(d, x) { 219 var st = x.s.settings || {}; 220 // only a house range made from a laboratory's own figure yields to the laboratory's drawing; any other scheme keeps its bands 221 if (st.kind !== "lab" || !st.source || st.source.kind !== "lab") return false;
222 // any laboratory reference on the series leads (two laboratories: one graph each, as T536 draws them) 223 return !!refBands(d, x) || (x.s.lab_references || []).some(function (r) { return !!refBands(d, x, r); }); 224 } 225 /* the reference's bands in the unit shown (a factor only; a unit reached by a line draws none) */ 226 function refOf(x, id) { 227 return (x.s.lab_references || []).filter(function (r) { return r.id === id; })[0] || (x.s.lab_reference && x.s.lab_reference.id === id ? x.s.lab_reference : null); 228 } 229 function refBands(d, x, which) { 230 var ref = which || x.s.lab_reference; 231 if (!ref || !x.unit || !kindsOn(d)) return null; 232 // the reference stands in the canonical unit; the unit shown is reached by its factor and addend (lin) 233 function f(v) { return v == null ? null : lin(v, x); } 234 var li = ref.low_inclusive !== false, hi = ref.high_inclusive !== false; 235 if (ref.shape === "range") { 236 return [{ status: "ref_a", lower: null, upper: f(ref.low), upper_inclusive: !li, label: wx(d, "ref.below") }, 237 { status: "ref_b", lower: f(ref.low), lower_inclusive: li, upper: f(ref.high), upper_inclusive: hi, label: wx(d, "ref.within") }, 238 { status: "ref_a", lower: f(ref.high), lower_inclusive: !hi, upper: null, label: wx(d, "ref.above") }]; 239 } 240 if (ref.shape === "upper") { 241 return [{ status: "ref_b", lower: null, upper: f(ref.high), upper_inclusive: hi, label: wx(d, "ref.within") }, 242 { status: "ref_a", lower: f(ref.high), lower_inclusive: !hi, upper: null, label: wx(d, "ref.above") }]; 243 } 244 if (ref.shape === "lower") { 245 return [{ status: "ref_a", lower: null, upper: f(ref.low), upper_inclusive: !li, label: wx(d, "ref.below") }, 246 { status: "ref_b", lower: f(ref.low), lower_inclusive: li, upper: null, label: wx(d, "ref.within") }]; 247 } 248 if (ref.shape === "tiers" && ref.tiers && ref.tiers.length > 1) { 249 return ref.tiers.map(function (tt, i) { 250 var prev = i ? ref.tiers[i - 1] : null; 251 // a band starts at its lower bound unless the band before holds that bound, or the laboratory printed "> lower" 252 return { status: i % 2 ? "ref_b" : "ref_a", lower: f(tt.lower), lower_inclusive: !(prev && prev.upper_inclusive) && !tt.lower_exclusive, 253 upper: f(tt.upper), upper_inclusive: !!tt.upper_inclusive, label: label(tt.label) }; 254 }); 255 } 256 return null; 257 } 258 /* the band a number sits in, by the bounds the laboratory printed (each band says whether its own bound is inside) */ 259 function inBand(b, y) { 260 return (b.lower == null || y > b.lower || (y === b.lower && b.lower_inclusive !== false)) && 261 (b.upper == null || y < b.upper || (y === b.upper && !!b.upper_inclusive)); 262 } 263 /* whether EVERY value a reading allows lies in the band: an exact number, or all below / above a printed limit 264 ("< 2" lies in a band only when that band is open below and reaches 2; "> 100" only when it is open above and 265 starts at or below 100, its own bound and inclusivity as printed) */ 266 function holds(b, y, qualifier) { 267 if (!qualifier || qualifier === "eq") return inBand(b, y); 268 if (qualifier === "lt" || qualifier === "le") { 269 if (b.lower != null) return false; 270 return b.upper == null || y < b.upper || (y === b.upper && (qualifier === "lt" || !!b.upper_inclusive)); 271 } 272 if (qualifier === "gt" || qualifier === "ge") { 273 if (b.upper != null) return false; 274 return b.lower == null || y > b.lower || (y === b.lower && (qualifier === "gt" || b.lower_inclusive !== false)); 275 } 276 return false; 277 } 278 function bandWord(bands, y, qualifier) { 279 if (!bands || y == null) return ""; 280 for (var i = 0; i < bands.length; i++) if (holds(bands[i], y, qualifier)) return bands[i].label || ""; 281 return ""; 282 } 283 function multiRef(x) { 284 var refs = x.s.lab_references || []; 285 return refs.length > 1 || (refs.length === 1 && x.s.readings.some(function (r) { return r.ref_id !== refs[0].id; })); 286 } 287 function refOrigin(d, x, which) { 288 var ref = which || x.s.lab_reference; 289 if (!ref || !kindsOn(d)) return ""; 290 return wx(d, ref.state === "confirmed" ? "ref.origin.confirmed" : "ref.origin") + " " + wx(d, "ref.lab", { laboratory: ref.laboratory }); 291 } 292 293 /* ---- one series, prepared for a paint -------------------------------------------- */ 294 function unitOf(series, pref) { 295 var want = (pref || {})[series.key]; 296 var list = series.units || []; 297 return list.filter(function (u) { return u.ucum === want; })[0] || list[0] || null; 298 } 299 /* The marker's precision is its canonical unit's. A value shown in another unit takes the digits its size needs. */ 300 function precisionIn(series, unit) { return unit && (unit.factor !== 1 || unit.addend) ? null : series.precision; } 301 /* T457: a unit is reached by a factor or by a line (HbA1c from mmol/mol to percent): value x factor + addend */ 302 function lin(v, x) { return v * x.f + (x.a || 0); } 303 function comparable(r) { return r.canonical != null && isFinite(r.canonical) && !r.text && (!r.qualifier || r.qualifier === "eq"); } 304 function prep(d, pref) { 305 return (d.series || []).map(function (s) { 306 var unit = unitOf(s, pref), f = unit ? unit.factor : 1, a = unit && unit.addend ? unit.addend : 0; 307 var latest = s.readings[s.readings.length - 1] || null; 308 var st = s.settings || { kind: "none" }; 309 return { s: s, unit: unit, f: f, a: a, sym: unit ? unit.symbol : "", prec: precisionIn(s, unit), latest: latest,
310 kind: st.kind || "none", cls: latest ? latest.status || null : null, 311 tags: (s.tags || []).filter(function (k) { return !!areaOf(d, k); }) }; 312 }); 313 } 314 function valueWords(d, x, r) { 315 if (!r) return wx(d, "value.none"); 316 if (x.unit && comparable(r)) { 317 return fmt(lin(r.canonical, x), x.prec) + (x.sym && x.sym !== "ratio" ? " " + x.sym : "") + (r.derived_after_review ? " (" + t("labres.derived") + ")" : ""); 318 } 319 var w = reportedWords(r, pack(d)); 320 return w && String(w).trim() ? w : wx(d, "value.none"); 321 } 322 /* T457: a calculated value is marked by the content table's mark (an asterisk) after its name, everywhere */ 323 function mark(d) { var w = pack(d)["calculated.mark"]; return w == null ? "*" : w; } 324 function name(x) { return label(x.s.label); } 325 function shown(d, x) { return name(x) + (x.s.calculated ? " " + mark(d) : ""); } 326 327 /* ---- the filters: OR within one, AND between the two, empty is all ---------------- */ 328 function matches(st, x) { 329 var s = !st.status.length || (x.kind === "five" && st.status.indexOf(x.cls) >= 0); 330 var a = !st.area.length || x.tags.some(function (k) { return st.area.indexOf(k) >= 0; }); 331 var k = !st.kind.length || st.kind.indexOf(x.s.kind || "blood") >= 0; 332 return s && a && k; 333 } 334 function byName(a, b) { return name(a).localeCompare(name(b), locale()); } 335 336 /* ---- the address and the preference (the member's page only; an embedded dashboard keeps its state in memory) -- */ 337 function readState(d, o, st) { 338 if (st.read) return; 339 st.read = true; 340 var q = o.embedded ? new URLSearchParams("") : new URLSearchParams(window.location.search); 341 function list(k, valid) { 342 return (q.get(k) || "").split(",").filter(function (v, i, all) { return valid.indexOf(v) >= 0 && all.indexOf(v) === i; }); 343 } 344 var pref = null; 345 try { pref = window.localStorage.getItem(VIEW_KEY); } catch (e) { pref = null; } 346 var v = q.get("view"); 347 st.view = v === "grid" || v === "list" ? v : pref === "grid" || pref === "list" ? pref : "grid"; 348 st.status = list("status", STATUS); 349 st.area = list("area", (d.lab_settings.health_areas || []).map(function (a) { return a.key; })); 350 st.kind = kindsOn(d) ? list("kind", kindsOrder(d)) : []; 351 var open = q.get("open"); 352 st.open = null; 353 st.pendingOpen = open && (d.series || []).some(function (s) { return s.key === open; }) ? open : null; 354 } 355 function writeState(o, st) { 356 if (o.embedded) return; 357 try { 358 var u = new URL(window.location.href); 359 // T566 Addendum 1: a view not yet chosen is no word in the address (the upload page wrote "view=null" after a save) 360 if (st.view) u.searchParams.set("view", st.view); else u.searchParams.delete("view"); 361 if (st.status.length) u.searchParams.set("status", st.status.join(",")); else u.searchParams.delete("status"); 362 if (st.area.length) u.searchParams.set("area", st.area.join(",")); else u.searchParams.delete("area"); 363 if (st.kind.length) u.searchParams.set("kind", st.kind.join(",")); else u.searchParams.delete("kind"); 364 if (st.open) u.searchParams.set("open", st.open); else u.searchParams.delete("open"); 365 window.history.replaceState(null, "", u.pathname + u.search + u.hash); 366 } catch (e) { /* the page still works */ } 367 } 368 function keepView(st) { 369 try { window.localStorage.setItem(VIEW_KEY, st.view); } catch (e) { /* a storage that refuses keeps no preference */ } 370 } 371 372 /* ---- the pieces -------------------------------------------------------------------- */ 373 function status(d, key, tag) { 374 tag = tag || "p"; 375 return "<" + tag + ' class="labx-status" data-cx-status="' + esc(key || "none") + '"><i aria-hidden="true"></i><span>' + 376 esc(statusWord(d, key)) + "</span></" + tag + ">"; 377 } 378 /* at most three tags and a "+N" on a card or a row, every tag in the popup; inside a row's button the tags are 379 phrasing content (spans), since a button may hold no list */ 380 function tags(d, x, all, inline) { 381 var list = x.tags, max = all ? list.length : 3, more = list.length - max; 382 // a card without tags keeps the row its tags would take, so the cards of a pair stay aligned 383 if (!list.length) return all || inline ? "" : '<div class="labx-tags" aria-hidden="true"></div>'; 384 var box = inline ? "span" : "ul", item = inline ? "span" : "li"; 385 return "<" + box + ' class="labx-tags"' + (inline ? "" : ' aria-label="' + esc(wx(d, "tags.label")) + '"') + ">" + 386 list.slice(0, max).map(function (k) { 387 return "<" + item + ' class="labx-tag" data-area="' + esc(k) + '">' + esc(label(areaOf(d, k).name)) + "</" + item + ">"; 388 }).join("") + 389 (more > 0 ? "<" + item + ' class="labx-tag labx-tag--more" data-more="' + more + '"><span aria-hidden="true">+' + more + 390 '</span><span class="cx-sr">' + esc(wx(d, "tags.more", { n: more })) + "</span></" + item + ">" : "") + 391 "</" + box + ">"; 392 } 393 function rangeOf(r) { 394 var rr = reportRange(r); 395 if (rr) return rangeWords(rr.low, rr.high, r.reported_unit ? r.reported_unit.symbol : "", true); 396 if (r.range_state === "absent") return t("labres.range.absent"); 397 return ""; 398 } 399 /* T536: a reading printed as a limit ("< 0.5", below the detection limit) is drawn AT its limit, as a ring, and said so; 400 its number is the reported one, brought to the unit shown by the series' own units */ 401 function limitY(x, r) { 402 if (!x.unit || ["lt", "le", "gt", "ge"].indexOf(r.qualifier) < 0 || r.reported == null || !isFinite(r.reported) || !r.reported_unit) return null; 403 var u = (x.s.units || []).filter(function (y) { return y.id === r.reported_unit.id; })[0]; 404 if (!u || !u.factor) return null; 405 return lin((r.reported - (u.addend || 0)) / u.factor, x); 406 } 407 function points(d, x, only) { 408 return x.s.readings.filter(function (r) { return !only || only(r); }).map(function (r) { 409 var bands = r.ref_id ? refBands(d, x, refOf(x, r.ref_id)) : null; 410 var exact = !!x.unit && comparable(r); 411 // a number with no unit at all (a ratio printed bare, an index) is drawn as printed, on its own scale; a reading 412 // printed WITH a unit the series cannot reach is never drawn among them (it stays in the list, in its own unit) 413 var unitless = !x.unit && kindsOn(d) && !r.reported_unit && r.reported != null && isFinite(r.reported) && !r.text; 414 var limited = ["lt", "le", "gt", "ge"].indexOf(r.qualifier) >= 0; 415 var lim = exact || !kindsOn(d) ? null : unitless && limited ? r.reported : limitY(x, r); 416 var raw = unitless && !limited ? r.reported : null; 417 // a reading resolved from a proposed marker keeps the name it was entered under 418 var reading = valueWords(d, x, r) + (r.reported_label ? " (" + t("labres.reportedAs", { label: r.reported_label }) + ")" : ""); 419 var word = statusWord(d, r.status || null); 420 var who = t("labres.author." + r.author) + (r.lab_name ? " · " + r.lab_name : ""); 421 var y = exact ? lin(r.canonical, x) : lim != null ? lim : raw; 422 // a series drawn on a laboratory's reference names the band a reading sits in, in the laboratory's words 423 if (bands && !r.status && y != null) word = bandWord(bands, y, lim != null ? r.qualifier : null) || word; 424 if (lim != null) reading = reading + " (" + wx(d, "ref.limit") + ")"; 425 // T536: the condition it was read under and the report's own mark (never BioProfile's), where the report gave them 426 var mt = kindsOn(d) && r.meta ? r.meta : null; 427 if (mt && mt.context) { 428 var ctx = ["time", "side", "condition", "note"].map(function (k) { return mt.context[k]; }
428).filter(Boolean).join(", "); 429 if (ctx) reading = reading + " (" + ctx + ")"; 430 } 431 if (mt && mt.report_mark) reading = reading + " · " + wx(d, "meta.mark", { mark: mt.report_mark, source: mt.report_mark_source || "" }); 432 // a line joins readings of one method and one condition only 433 var seg = mt ? [mt.method || "", mt.context ? JSON.stringify(mt.context) : ""].join("|") : ""; 434 return { date: day(r.sample_date), iso: String(r.sample_date).slice(0, 10), y: y, limit: lim != null, seg: seg, 435 reading: reading, value: reading + ", " + word, status: r.status || null, statusWord: word, 436 range: rangeOf(r) || (kindsOn(d) && r.meta && r.meta.printed_reference ? r.meta.printed_reference : ""), low: null, high: null, author: who, out: false }; 437 }); 438 } 439 function summary(d, x) { 440 var n = x.s.readings.length; 441 return wx(d, "graph.summary", { name: shown(d, x), count: n, first: n ? day(x.s.readings[0].sample_date) : "", 442 last: n ? day(x.latest.sample_date) : "", value: valueWords(d, x, x.latest), 443 status: statusWord(d, x.cls) }); 444 } 445 function compact(d, x) { 446 return UI.trend(points(d, x), { 447 id: x.s.key, compact: true, unit: x.sym, fmt: function (v) { return fmt(v, x.prec); }, summary: summary(d, x), 448 words: { point: t("labres.trend.point"), none: t("labres.dash.noPlot") } 449 }); 450 } 451 function card(d, x) { 452 return '<article class="cx-lab-summary" data-series="' + esc(x.s.key) + '" data-cx-status="' + esc(x.cls || "none") + '">' + 453 '<h3 class="cx-lab-summary__title">' + esc(shown(d, x)) + "</h3>" + tags(d, x, false) + compact(d, x) + 454 '<div class="cx-lab-summary__reading"><b>' + esc(valueWords(d, x, x.latest)) + "</b>" + 455 (x.latest ? '<time datetime="' + esc(String(x.latest.sample_date).slice(0, 10)) + '">' + esc(day(x.latest.sample_date)) + "</time>" : "") + "</div>" + 456 status(d, x.cls) + 457 UI.button(wx(d, "card.open", { name: name(x) }), "quiet", 'data-labx-open="' + esc(x.s.key) + '" aria-haspopup="dialog"') + 458 "</article>"; 459 } 460 function row(d, x) { 461 var result = valueWords(d, x, x.latest); 462 return '<li class="labx-row" data-series="' + esc(x.s.key) + '" data-cx-status="' + esc(x.cls || "none") + '">' + 463 '<button class="labx-row__btn" type="button" data-labx-open="' + esc(x.s.key) + '" aria-haspopup="dialog" aria-label="' + 464 esc(wx(d, "list.row.name", { name: shown(d, x), result: result, status: statusWord(d, x.cls) })) + '">' + 465 '<span class="labx-row__rail" aria-hidden="true"></span>' + 466 '<span class="labx-row__name"><b>' + esc(shown(d, x)) + "</b>" + tags(d, x, false, true) + "</span>" + 467 '<span class="labx-row__result"><b>' + esc(result) + "</b>" + 468 (x.latest ? '<time datetime="' + esc(String(x.latest.sample_date).slice(0, 10)) + '">' + esc(day(x.latest.sample_date)) + "</time>" : "") + "</span>" + 469 status(d, x.cls, "span") + "</button></li>"; 470 } 471 472 var CHEVRON = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg>'; 473 var GRID = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false"><rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/><rect x="4" y="13" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/></svg>'; 474 var LIST = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M9 6h11M9 12h11M9 18h11"/><path d="M4 6h1M4 12h1M4 18h1"/></svg>'; 475 var CROSS = '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false">
475<path d="M6 6l12 12M18 6 6 18"/></svg>'; 476 477 function filter(d, st, kind, title, legend, options, note) { 478 var id = st.id + "-f-" + kind; 479 return '<div class="labx-filter" data-labx-filter="' + kind + '">' + 480 '<button class="labx-filter__btn" type="button" aria-expanded="false" aria-controls="' + id + '" data-labx-toggle="' + kind + '">' + 481 "<span>" + esc(title) + '</span><span class="labx-filter__n" data-labx-n hidden></span>' + CHEVRON + "</button>" + 482 '<div class="labx-filter__panel" id="' + id + '" hidden>' + 483 '<fieldset class="labx-filter__set"><legend class="labx-filter__legend">' + esc(legend) + "</legend>" + options + "</fieldset>" + 484 (note ? '<p class="labx-filter__note">' + esc(note) + "</p>" : "") + 485 '<div class="labx-filter__foot">' + UI.button(wx(d, "filter.clear"), "secondary", 'data-labx-clear="' + kind + '"', true) + 486 UI.button(wx(d, "filter.done"), "primary", "data-labx-done", true) + "</div>" + 487 "</div></div>"; 488 } 489 function option(kind, key, text, dot) { 490 return '<label class="labx-opt"><input type="checkbox" class="bp-box" name="' + kind + '" value="' + esc(key) + '" data-labx-check="' + kind + '">' + 491 (dot ? '<i class="labx-swatch" data-cx-status="' + esc(key) + '" aria-hidden="true"></i>' : "") + "<span>" + esc(text) + "</span></label>"; 492 } 493 494 function html(d, o) { 495 var st = state(o); 496 readState(d, o, st); 497 var statusOptions = STATUS.map(function (k) { return option("status", k, statusWord(d, k), true); }).join(""); 498 var areaOptions = areasSorted(d).map(function (a) { return option("area", a.key, label(a.name), false); }).join(""); 499 // T536: the kinds of test present, in their order (only where there are two or more) 500 var present = {}; 501 (d.series || []).forEach(function (s) { present[s.kind || "blood"] = true; }); 502 var kindKeys = kindsOn(d) ? kindsOrder(d).filter(function (k) { return present[k]; }) : []; 503 var kindOptions = kindKeys.length > 1 ? kindKeys.map(function (k) { return option("kind", k, kindName(d, k), false); }).join("") : ""; 504 return '<div class="labx" data-labx>' + 505 '<div class="labx-bar">' + 506 '<div class="labx-filters" role="group" aria-label="' + esc(wx(d, "filter.label")) + '">' + 507 filter(d, st, "status", wx(d, "filter.status"), wx(d, "filter.status.legend"), statusOptions, wx(d, "filter.status.other")) + 508 filter(d, st, "area", wx(d, "filter.areas"), wx(d, "filter.areas.legend"), areaOptions, "") + 509 (kindOptions ? filter(d, st, "kind", wx(d, "filter.kinds"), wx(d, "filter.kinds.legend"), kindOptions, "") : "") + 510 "</div>" + 511 '<div class="labx-seg" role="group" aria-label="' + esc(wx(d, "view.label")) + '">' + 512 '<button type="button" class="labx-seg__btn" data-labx-view="grid" aria-pressed="false">' + GRID + "<span>" + esc(wx(d, "view.grid")) + "</span></button>" + 513 '<button type="button" class="labx-seg__btn" data-labx-view="list" aria-pressed="false">' + LIST + "<span>" + esc(wx(d, "view.list")) + "</span></button>" + 514 "</div>" + 515 "</div>" + 516 '<div class="labx-summary" data-labx-summary></div>' + 517 '<div data-labx-results></div></div>'; 518 } 519 520 /* the cards two by two */ 521 function pairs(d, items) { 522 var cards = ""; 523 items.forEach(function (x, i) { 524 if (i % 2 === 0) cards += '<div class="cx-lab-grid">'; 525 cards += card(d, x); 526 if (i % 2 === 1 || i === items.length - 1) cards += "</div>"; 527 }); 528 return cards; 529 } 530 /* T536: a result in words over time, drawn as the standard's word strip */ 531 function wordCard(d, x) { 532 return '<article class="cx-lab-summary labx-wordcard" data-series="' + esc(x.s.key) + '"><h3 class="cx-lab-summary__title">' + esc(shown(d, x)) + "</h3>" + 533 UI.wordStrip({ points: x.s.readings.map(function (r) { 534 return { iso: String(r.sample_date).slice(0, 10), date: day(r.sample_date), word: reportedWords(r, pack(d)) || wx(d, "value.none"), status: "none" }; 535 }), words: { summary: shown(d, x), table: t("labres.trend.table"), date: t("labres.trend.date"), value: t("labres.trend.value") } }) + "</article>"; 536 } 537 538 /* the results and the toolbar's state, drawn again on every filter or view change without touching the bar */ 539 function results(ctx) { 540 var d = ctx.data, o = ctx.o, st = state(o), host = ctx.host; 541 var root = host.querySelector("[data-labx]"); 542 if (!root) return; 543 var every = prep(d, readPref()); 544 // T457: a result that is a word (a blood group, a urine word) has no colour and n
544o range: it is listed on its 545 // own under the results, never among the coloured cards (while the copy table holds the list's title) 546 var otherTitle = pack(d)["other.title"]; 547 var others = otherTitle ? every.filter(function (x) { return x.latest && x.latest.text; }).sort(byName) : []; 548 var all = every.filter(function (x) { return others.indexOf(x) < 0; }); 549 var list = all.filter(function (x) { return matches(st, x); }); 550 // T536: the results in words count and filter like the others (an empty state only when both are empty) 551 var wordsShown = kindsOn(d) ? others.filter(function (x) { return matches(st, x); }) : (others.length ? others : []); 552 var body; 553 if (!list.length && !wordsShown.length) { 554 body = '<section class="cx-sec" data-sec="labx-empty">' + UI.empty(wx(d, "results.empty.title"), esc(wx(d, "results.empty.says")), 555 { act: UI.button(wx(d, "results.reset"), "secondary", "data-labx-reset") }) + "</section>"; 556 } else if (st.view === "list") { 557 body = UI.section("labx-all", wx(d, "list.title"), list.length, "", 558 '<div class="labx-list"><div class="labx-list__head" aria-hidden="true"><span>' + esc(wx(d, "list.col.marker")) + "</span><span>" + 559 esc(wx(d, "list.col.result")) + "</span><span>" + esc(wx(d, "list.col.status")) + "</span></div>" + 560 '<ul class="labx-list__rows">' + list.slice().sort(byName).map(function (x) { return row(d, x); }).join("") + "</ul></div>"); 561 } else if (kindsOn(d)) { 562 // T536: one section per kind of test, in the kinds' order; Blood values keep the twelve groups inside; the line 563 // that says how a kind is shown stands ABOVE its cards, the full width, at every width (Jesse 2026-10-05) 564 body = kindsOrder(d).map(function (k) { 565 var inKind = list.filter(function (x) { return (x.s.kind || "blood") === k; }); 566 if (!inKind.length) return ""; 567 var open = inKind.filter(function (x) { return !judged(x); }); 568 // a kind where some values carry BioProfile's status and others do not says so; never one sentence for both 569 var noteKey = open.length < inKind.length ? "kind.note.mixed" 570 : open.some(function (x) { return !!x.s.lab_reference; }) ? "kind.note.reference" : "kind.note.reported"; 571 var note = !open.length ? "" : '<p class="bp-note labx-kind-note" data-labx-kind-note="' + esc(k) + '">' + esc(wx(d, noteKey)) + "</p>"; 572 var inner; 573 if (k === "blood") { 574 inner = (d.groups || []).map(function (g) { 575 var inGroup = inKind.filter(function (x) { return (x.s.group || "other") === g; }).sort(byName); 576 if (!inGroup.length) return ""; 577 return '<h3 class="labx-group" data-labx-group="' + esc(g) + '">' + esc(t("labres.group." + g)) + "</h3>" + pairs(d, inGroup); 578 }).join(""); 579 } else { 580 inner = pairs(d, inKind.sort(byName)); 581 } 582 return UI.section("kind-" + k, kindName(d, k), inKind.length, "", note + '<div class="cx-lab-overview">' + inner + "</div>"); 583 }).join(""); 584 } else { 585 body = (d.groups || []).map(function (g) { 586 var inGroup = list.filter(function (x) { return (x.s.group || "other") === g; }).sort(byName); 587 if (!inGroup.length) return ""; 588 return UI.section("group-" + g, t("labres.group." + g), null, "", '<div class="cx-lab-overview">' + pairs(d, inGroup) + "</div>"); 589 }).join(""); 590 } 591 // T457: one legend line under the results when a calculated value is on the page (the content table's words) 592 var legend = pack(d)["calculated.legend"]; 593 if (legend && list.some(function (x) { return x.s.calculated; })) { 594 body += '<p class="bp-note labx-calculated-legend" data-labx-calculated-legend>' + esc(legend) + "</p>"; 595 } 596 if (others.length && kindsOn(d)) { 597 if (wordsShown.length) body += UI.section("labx-other", wx(d, "words.title"), wordsShown.length, "", 598 '<div class="cx-lab-overview" data-labx-other>' + wordsShown.map(function (x, i) { 599 return (i % 2 === 0 ? '<div class="cx-lab-grid">' : "") + wordCard(d, x) + (i % 2 === 1 || i === wordsShown.length - 1 ? "</div>" : ""); 600 }).join("") + "</div>"); 601 } else if (others.length) { 602 body += UI.section("labx-other", otherTitle, others.length, "", '<ul class="labx-other" data-labx-other>' + others.map(function (x) { 603 return "<li><span>" + esc(shown(d, x)) + "</span> <b>" + esc(reportedWords(x.latest, pack(d))) + "</b>" + 604 (x.latest.sample_date ? ' <span class="cx-table__sub">' + esc(day(x.latest.sample_date)) + "</span>" : "") + "</li>"; 605 }).join("") + "</ul>"); 606 } 607 var out = root.querySelector("[data-labx-results]"); 608 out.setAttribute("data-view", st.view); 609 out.innerHTML = body; 610 Array.prototype.forEach.call(root.querySelectorAll("[data-labx-view]"), function (b) { 611 b.setAttribute("aria-pressed", String(b.getAttribute("data-labx-view") === st.view)); 612 }); 613 Array.prototype.forEach.call(root.querySelectorAll("[data-labx-check]"), function (c) { 614 var ck = c.getAttribute("data-labx-check"); 615 c.checked = (ck === "status" ? st.status : ck === "kind" ? st.kind : st.area).indexOf(c.value) >= 0; 616 }); 617 [["status", st.status], ["area", st.area], ["kind", st.kind]].forEac
617h(function (pair) { 618 var f = root.querySelector('[data-labx-filter="' + pair[0] + '"]'); 619 if (!f) return; 620 var n = f.querySelector("[data-labx-n]"); 621 n.hidden = !pair[1].length; 622 n.innerHTML = pair[1].length ? '<span aria-hidden="true">' + pair[1].length + '</span><span class="cx-sr">' + esc(wx(d, "filter.chosen", { n: pair[1].length })) + "</span>" : ""; 623 f.classList.toggle("is-chosen", !!pair[1].length); 624 }); 625 // the count, and the reset only while a filter is on (a standard button is never hidden by this page's sheet) 626 root.querySelector("[data-labx-summary]").innerHTML = '<p role="status" aria-live="polite" data-labx-count>' + 627 esc(wx(d, "results.count", { shown: list.length + wordsShown.length, total: all.length + others.length })) + "</p>" + 628 (st.status.length || st.area.length || st.kind.length ? UI.button(wx(d, "results.reset"), "secondary", "data-labx-reset", true) : ""); 629 root.setAttribute("data-labx-shown", list.map(function (x) { return x.s.key; }).sort().join(",")); 630 UI.trendWire(host); 631 } 632 633 /* ---- the filter panels ------------------------------------------------------------ */ 634 function panelOf(host, kind) { return host.querySelector('[data-labx-filter="' + kind + '"]'); } 635 function openPanel(ctx, kind) { 636 var st = state(ctx.o); 637 if (st.panel && st.panel !== kind) closePanel(ctx, false); 638 var f = panelOf(ctx.host, kind); 639 if (!f) return; 640 f.querySelector(".labx-filter__panel").hidden = false; 641 f.querySelector("[data-labx-toggle]").setAttribute("aria-expanded", "true"); 642 f.classList.add("is-open"); 643 st.panel = kind; 644 } 645 function closePanel(ctx, focus) { 646 var st = state(ctx.o); 647 if (!st.panel) return; 648 var f = panelOf(ctx.host, st.panel); 649 st.panel = null; 650 if (!f) return; 651 f.querySelector(".labx-filter__panel").hidden = true; 652 f.classList.remove("is-open"); 653 var b = f.querySelector("[data-labx-toggle]"); 654 b.setAttribute("aria-expanded", "false"); 655 if (focus) b.focus(); 656 } 657 658 /* ---- the popup ---------------------------------------------------------------------- */ 659 var modalHost = null; 660 function modal() { 661 if (!modalHost || !modalHost.isConnected) { 662 modalHost = document.createElement("div"); 663 modalHost.className = "labx-modal"; 664 modalHost.setAttribute("data-labx-modal", ""); 665 modalHost.hidden = true; 666 document.body.appendChild(modalHost); 667 } 668 return modalHost; 669 } 670 function bandsNote(d, x) { 671 var st = x.s.settings || {}; 672 if (st.kind === "five") return wx(d, "popup.bands.approved"); 673 if (st.kind === "lab") return wx(d, "popup.bands.range"); 674 return wx(d, "popup.bands.unset"); 675 } 676 function labLine(d, x) { 677 var st = x.s.settings || {}, lr = st.lab_range; 678 if (!lr || (lr.low == null && lr.high == null) || !x.unit) return ""; 679 // in the marker's own unit the limits are printed as they were set (3 stays 3); in another unit, as converted 680 var words = rangeWords(lr.low == null ? null : lin(lr.low, x), lr.high == null ? null : lin(lr.high, x), x.sym, x.f === 1 && !x.a, true); 681 var key = st.population === "female" ? "popup.lab.female" : st.population === "male" ? "popup.lab.male" : "popup.lab"; 682 return wx(d, key, { range: words }); 683 } 684 function scaled(iv, x) { 685 return { status: iv.status, lower: iv.lower == null ? null : lin(iv.lower, x), upper: iv.upper == null ? null : lin(iv.upper, x), 686 lower_inclusive: iv.lower_inclusive, upper_inclusive: iv.upper_inclusive }; 687 } 688 /* a number outside the set's domain is not assessed: it stays in the table, never drawn on the bands (the 689 band scale would clamp it to an edge and draw a value it is not) */ 690 function inDomain(st, r) { 691 var dom = st.domain || {}; 692 return !((dom.min != null && r.canonical < dom.min) || (dom.max != null && r.canonical > dom.max)); 693 } 694 function graph(d, x) { 695 var st = x.s.settings || {}; 696 var pts = points(d, x); 697 var words = { summary: summary(d, x), point: t("labres.trend.point"), legend: wx(d, "graph.legend"), scale: wx(d, "graph.scale"), 698 table: t("labres.trend.table"), date: t("labres.trend.date"), value: t("labres.trend.value"), author: t("labres.trend.author"), 699 status: wx(d, "graph.status"), line: t("labres.trend.line"), range: t("labres.trend.range") }; 700 if ((st.kind === "five" || st.kind === "lab") && x.unit && !refLed(d, x)) { 701 // T457 Addendum 3: the legend names only the bands this value has (a set may hold any subset of the five) 702 var present = (st.intervals || []).map(function (iv) { return iv.status; }); 703 var legend = st.kind === "five" 704 ? STATUS.filter(function (k) { return present.indexOf(k) >= 0; }).map(function (k) { return { status: k, label: statusWord(d, k) }; }) 705 : (st.intervals || []).map(function (iv) { return { status: iv.status, label: statusWord(d, iv.status) }; }); 706 var dom = st.domain || {}; 707 pts = pts.map(function (p, i) { return inDomain(st, x.s.readings[i]) ? p : Object.assign({}, p, { y: null }); }); 708 return UI.bandTrend({ id: x.s.key, points: pts, intervals: (st.intervals || []).map(function (iv) { return scaled(iv, x); }), 709 domain: { min: dom.min == null ? null : lin(dom.min, x), max: dom.max == null ? null : lin(dom.max, x) }, legend: legend, 710 tickFmt: function (v) { return x.f === 1 && !x.a ? asLimit(v) : fmt(v, null); }, words: words }); 711 } 712 // T536: no signed range, a laboratory's reference: its bands, neutral, in the laboratory's own words. Readings from 713 // laboratories that print different references are never put on one laboratory's bands: one graph per reference, 714 // each with its own line above it, and the readings with no reference in a plain graph of their own 715 var refs = (x.s.lab_references || []).filter(function (r) { return refBands(d, x, r); }); 716 if (refs.length > 1 || (refs.length === 1 && x.s.readings.some(function (r) { return r.ref_id !== refs[0].id; }))) { 717 var parts = refs.map(function (rf) { 718 var pp = points(d, x, function (r) { return r.ref_id === rf.id; }); 719 return '<div class="labx-refgraph"><p class="bp-note labx-ref-origin">' + esc(refOrigin(d, x, rf)) + "</p>" + 720 UI.bandTrend({ id: x.s.key + "-" + rf.id, points: pp, intervals: refBands(d, x, rf), domain: { min: 0, max: null }, 721 legend: [{ status: "ref_b", label: wx(d, "ref.legend") }], 722 tickFmt: function (v) { return x.f === 1 && !x.a ? asLimit(v) : fmt(v, null); }, words: words }) + 723 '<p class="labx-dialog__note">' + esc(wx(d, "ref.printed", { printed: rf.printed })) + "</p>" + 724 (rf.caution ? '<p class="labx-dialog__note">' + esc(rf.caution) + "</p>" : "") + "</div>"; 725 }); 726 var rest = points(d, x, function (r) { return !r.ref_id || !refs.some(function (rf) { return rf.id === r.ref_id; }); }); 727 if (rest.length) { 728 parts.push('<div class="labx-refgraph"><p class="bp-note labx-ref-origin">' + esc(wx(d, "kind.note.reported")) + "</p>" + 729 UI.trend(rest, { id: x.s.key + "-none", unit: x.sym, fmt: function (v) { return fmt(v, x.prec); }
729, summary: words.summary, 730 words: { line: words.line, table: words.table, date: words.date, value: words.value, point: words.point, range: words.range, 731 author: t("labres.trend.author"), none: t("labres.trend.none"), lowest: t("labres.trend.lowest"), highest: t("labres.trend.highest") } }) + "</div>"); 732 } 733 return '<div data-labx-refgraphs>' + parts.join("") + "</div>"; 734 } 735 var rb = refs.length ? refBands(d, x, refs[0]) : refBands(d, x); 736 if (rb) { 737 return UI.bandTrend({ id: x.s.key, points: pts, intervals: rb, domain: { min: 0, max: null }, 738 legend: [{ status: "ref_b", label: wx(d, "ref.legend") }], 739 tickFmt: function (v) { return x.f === 1 && !x.a ? asLimit(v) : fmt(v, null); }, words: words }); 740 } 741 // no scheme: the readings over time, the standard's trend with no band 742 return UI.trend(pts, { id: x.s.key, unit: x.sym, fmt: function (v) { return fmt(v, x.prec); }, summary: words.summary, 743 words: { line: words.line, table: words.table, date: words.date, value: words.value, point: words.point, 744 range: words.range, author: t("labres.trend.author"), none: t("labres.trend.none"), 745 lowest: t("labres.trend.lowest"), highest: t("labres.trend.highest") } }); 746 } 747 function chips(x) { 748 var exact = x.s.readings.some(comparable); 749 return (x.s.units || []).length > 1 && exact 750 ? '<div class="cx-chips" role="group" aria-label="' + esc(t("labres.units.label") + " " + name(x)) + '">' + 751 (x.s.units || []).map(function (u) { 752 return UI.chip(u.symbol, x.unit && u.ucum === x.unit.ucum, ' data-unit="' + esc(u.ucum) + '" data-series="' + esc(x.s.key) + '"'); 753 }).join("") + "</div>" 754 : ""; 755 } 756 function reports(d, x, panels) { 757 var list = [], seen = {};
758 x.s.readings.forEach(function (r) { 759 if (!r.panel_id || seen[r.panel_id]) return; 760 seen[r.panel_id] = true; 761 if (!(panels || []).some(function (p) { return p.id === r.panel_id && p.report; })) return; 762 list.push("<li><b>" + esc(day(r.sample_date)) + "</b>" + (r.lab_name ? '<span class="cx-list__meta">' + esc(r.lab_name) + "</span>" : "") + 763 '<span class="cx-list__act">' + UI.button(t("labres.report.open"), "secondary", 764 'data-act="report" data-id="' + esc(r.panel_id) + '" aria-label="' + esc(t("labres.report.open") + ": " + day(r.sample_date) + (r.lab_name ? ", " + r.lab_name : "")) + '"') + 765 "</span></li>"); 766 }); 767 // a report that fails to open says so here, inside the popup, where the person who asked is 768 return list.length ? '<section class="labx-dialog__sec"><h3>' + esc(wx(d, "popup.reports")) + '</h3><ul class="cx-list">' + list.join("") + "</ul>" + 769 '<div class="labx-dialog__said" data-labx-said role="status" aria-live="polite"></div></section>' : ""; 770 } 771 /* ---- T472: the comparison with healthy people, in the popup (behind the server's BP_LAB_SCORE: a series carries 772 `comparison` on its readings only where the server compared it) ------------------------------------------------ */ 773 var CMP_WORDS = ["cmp.title", "cmp.in", "cmp.under", "cmp.over", "cmp.censored", "cmp.group.own.female", "cmp.group.own.male", 774 "cmp.group.range.female", "cmp.group.range.male", "cmp.also", "cmp.also.under", "cmp.also.over", "cmp.also.none", "cmp.level.low", 775 "cmp.level.mid", "cmp.level.high", "cmp.control", "cmp.control.own", "cmp.control.range", "cmp.bar.label", "cmp.bar.lower", 776 "cmp.bar.higher", "cmp.bar.under", "cmp.bar.over", "cmp.tie", "cmp.source", "cmp.sex.female", "cmp.sex.male", "cmp.rule.fasting", "cmp.rule.morning", "cmp.method", "cmp.review", 777 "cmp.markerage", "cmp.markerage.under", "cmp.markerage.over", "cmp.markerage.note", "cmp.none.title"]; 778 function hasAll(d, list) { return list.every(function (k) { var v = pack(d)[k]; return typeof v === "string" && !!v; }); } 779 function groupWords(d, sex, g, own) { 780 if (own && g.kind === "year") return wx(d, "cmp.group.own." + sex); 781 return wx(d, "cmp.group.range." + sex, { from: g.age_from, to: g.age_to }); 782 } 783 function levelOf(d, pct) { 784 var lv = (d.score && d.score.formula && d.score.formula.levels) || { low_below: 25, high_from: 75 }; 785 return wx(d, pct < lv.low_below ? "cmp.level.low" : pct >= lv.high_from ? "cmp.level.high" : "cmp.level.mid"); 786 } 787 function compareSec(d, o, x, st) { 788 var r = x.latest, c = r && r.comparison; 789 if (!c || !d.score || !hasAll(d, CMP_WORDS)) return ""; 790 if (c.reason) { 791 if (o.viewer !== "expert") return ""; 792 var why = pack(d)["cmp.none." + String(c.reason).replace(":", ".")]; 793 return why ? '<section class="labx-dialog__sec" data-labx-cmp-none><h3>' + esc(wx(d, "cmp.none.title")) + '</h3><p class="labx-dialog__note">' + esc(why) + "</p></section>" : ""; 794 } 795 var sex = d.score.sex, sel = st.cmp || "own"; 796 var g = sel === "own" ? c.own : (c.groups || [])[Number(sel)] || c.own; 797 var own = sel === "own" || !(c.groups || [])[Number(sel)]; 798 var who = groupWords(d, sex, g, own); 799 var lines = []; 800 if (own && (r.status === "optimal" || r.status === "healthy") && pack(d)["cmp.opening.good"]) lines.push(wx(d, "cmp.opening.good", { name: name(x) })); 801 var bar = ""; 802 if (g.state === "censored") lines.push(wx(d, "cmp.censored")); 803 else if (g.state === "none" || g.shown == null) lines.push(wx(d, "cmp.also.none", { group: who })); 804 else { 805 var pct = g.shown === "under" ? 1 : g.shown === "over" ? 99 : g.shown; 806 // a value shared by a run of percentiles: said as the run, never as "pct lower and the rest higher" 807 if (g.interval) lines.push(wx(d, "cmp.tie", { group: who, from: g.interval[0], to: g.interval[1] })); 808 else if (own) lines.push(g.state === "under" ? wx(d, "cmp.under", { group: who }) : g.state === "over" ? wx(d, "cmp.over", { group: who }) 809 : wx(d, "cmp.in", { pct: pct, group: who, below: pct, above: 100 - pct })); 810 else lines.push(g.state === "under" ? wx(d, "cmp.also.under", { group: who }) : g.state === "over" ? wx(d, "cmp.also.over", { group: who }) 811 : wx(d, "cmp.also", { group: who, level: levelOf(d, pct), pct: pct })); 812 bar = UI.pctbar({ pct: pct, tail: g.state !== "in", 813 label: g.state === "under" ? wx(d, "cmp.bar.under", { group: who }) : g.state === "over" ? wx(d, "cmp.bar.over", { group: who }) 814 : wx(d, "cmp.bar.label", { group: who, pct: pct }), 815 lower: wx(d, "cmp.bar.lower"), higher: wx(d, "cmp.bar.higher") }); 816 } 817 var age = c.age != null ? Math.floor(c.age) : ""; 818 var chips = '<div class="cx-chips" role="group" aria-label="' + esc(wx(d, "cmp.control")) + '">' + 819 UI.chip(wx(d, "cmp.control.own", { age: age }), sel === "own", 'data-labx-cmp="own" data-series="' + esc(x.s.key) + '"') + 820 (c.groups || []).map(function (gr, i) { 821 return UI.chip(wx(d, "cmp.control.range", { from: gr.age_from, to: gr.age_to }), sel === String(i), 'data-labx-cmp="' + i + '" data-series="' + esc(x.s.key) + '"'); 822 }).join("") + "</div>"; 823 var notes = []; 824 var ma = c.marker_age; 825 if (own && ma && (ma.state === "in" || ma.state === "under" || ma.state === "over")) { 826 var sexWord = wx(d, "cmp.sex." + sex); 827 notes.push(ma.state === "in" ? wx(d, "cmp.markerage", { sex: sexWord, age: Math.round(ma.age) }) 828 : wx(d, "cmp.markerage." + ma.state, { sex: sexWord, age: ma.age })); 829 notes.push(wx(d, "cmp.markerage.note")); 830 } 831 if (c.sample_rule === "fasting" || c.sample_rule === "morning") { if (c.sample_rule_met !== true) notes.push(wx(d, "cmp.rule." + c.sample_rule)); } 832 if (r.comparison.manual) notes.push(wx(d, "cmp.method")); 833 if (c.curve && c.curve.state !== "approved") notes.push(wx(d, "cmp.review")); 834 var pop = d.score.population; 835 // the comparison's own group: its country and years, read from the curve it used 836 var country = c.curve && c.curve.country ? c.curve.country : pop ? pop.country : null; 837 if (country) notes.push(wx(d, "cmp.source", { sex: wx(d, "cmp.sex." + sex), country: label(country), years: (c.curve && c.curve.years) || (pop && pop.years) || "" })); 838 return '<section class="labx-dialog__sec" aria-labelledby="labx-dlg-cmp" data-labx-cmp-sec><h3 id="labx-dlg-cmp">' + esc(wx(d, "cmp.title")) + "</h3>" +
839 chips + '<div data-labx-cmp-said role="status" aria-live="polite">' + lines.map(function (l) { return '<p class="labx-dialog__result">' + esc(l) + "</p>"; }).join("") + 840 bar + "</div>" + notes.map(function (l) { return '<p class="labx-dialog__note">' + esc(l) + "</p>"; }).join("") + "</section>"; 841 } 842 function dialog(d, o, x) { 843 var st = x.s.settings || {}, desc = (x.s.description || {})[locale()]; 844 var source = st.source && st.source.text ? wx(d, "popup.source", { source: st.source.text }) : ""; 845 return '<div class="labx-modal__scrim" data-labx-scrim></div>' + 846 '<div class="labx-dialog" role="dialog" aria-modal="true" aria-labelledby="labx-dlg-title" data-series="' + esc(x.s.key) + '">' + 847 '<div class="labx-dialog__top"><div class="labx-dialog__titles"><p class="labx-dialog__kicker">' + esc(wx(d, "popup.kicker")) + "</p>" + 848 '<h2 id="labx-dlg-title" tabindex="-1" data-labx-heading>' + esc(shown(d, x)) + "</h2></div>" + 849 '<button type="button" class="labx-close" data-labx-close aria-label="' + esc(wx(d, "popup.close")) + '">' + CROSS + "</button></div>" + 850 (x.s.proposed ? UI.pill("topic", t(o.viewer === "expert" ? "labres.proposed.expert" : "labres.proposed")) : "") + 851 '<div class="labx-dialog__meta" data-cx-status="' + esc(x.cls || "none") + '">' + status(d, x.cls) + 852 '<p class="labx-dialog__result">' + esc(wx(d, "popup.result", { value: valueWords(d, x, x.latest) })) + 853 (x.latest ? ' <time datetime="' + esc(String(x.latest.sample_date).slice(0, 10)) + '">' + esc(wx(d, "popup.date", { date: day(x.latest.sample_date) })) + "</time>" : "") + "</p></div>" + 854 (x.tags.length ? '<div class="labx-dialog__tags"><p class="cx-label">' + esc(wx(d, "popup.tags")) + "</p>" + tags(d, x, true) + "</div>" : "") + 855 // T472: the comparison with healthy people, right under the result it reads 856 compareSec(d, o, x, state(o)) + 857 '<section class="labx-dialog__sec" aria-labelledby="labx-dlg-graph"><h3 id="labx-dlg-graph">' + esc(wx(d, "popup.graph")) + "</h3>" + 858 (refBands(d, x) && (!judged(x) || refLed(d, x)) && !multiRef(x) ? '<p class="bp-note labx-ref-origin" data-labx-ref-origin>' + esc(refOrigin(d, x)) + "</p>" : "") + 859 chips(x) + graph(d, x) + 860 (labLine(d, x) && !refLed(d, x) ? '<p class="labx-dialog__note">' + esc(labLine(d, x)) + "</p>" : "") + 861 (refBands(d, x) && (!judged(x) || refLed(d, x)) && multiRef(x) ? "" 862 : refBands(d, x) && (!judged(x) || refLed(d, x)) 863 ? '<p class="labx-dialog__note" data-labx-ref-printed>' + esc(wx(d, "ref.printed", { printed: x.s.lab_reference.printed })) + "</p>" + 864 (x.s.lab_reference.caution ? '<p class="labx-dialog__note" data-labx-ref-caution>' + esc(x.s.lab_reference.caution) + "</p>" : "") 865 : '<p class="labx-dialog__note">' + esc(bandsNote(d, x)) + "</p>") + 866 (x.s.calculated && pack(d)["calculated.legend"] ? '<p class="labx-dialog__note" data-labx-calculated-note>' + esc(pack(d)["calculated.legend"]) + "</p>" : "") + 867 (source && !refLed(d, x) ? '<p class="labx-dialog__note">' + esc(source) + "</p>" : "") + 868 // the source's page is a control of its own, the house's target size, never a word inside small print 869 (source && st.source.url ? '<p class="labx-dialog__act"><a class="cx-btn cx-btn--secondary" href="' + esc(st.source.url) + 870 '" target="_blank" rel="noopener">' + esc(wx(d, "popup.source.open")) + "</a></p>" : "") + 871 "</section>" + 872 '<section class="labx-dialog__sec labx-dialog__what" aria-labelledby="labx-dlg-what"><h3 id="labx-dlg-what">' + esc(wx(d, "popup.what", { name: name(x) })) + "</h3>" + 873 (desc && desc.paragraphs && desc.paragraphs.length 874 ? desc.paragraphs.map(function (p) { return "<p>" + esc(p) + "</p>"; }).join("") 875 : '<p class="labx-dialog__note" data-labx-unavailable>' + esc(wx(d, "popup.what.unavailable")) + "</p>") + "</section>" + 876 reports(d, x, d.panels) + 877 "</div>"; 878 } 879 function focusables(root) { 880 return Array.prototype.filter.call(root.querySelectorAll("button, [href], summary, input, select, textarea, [tabindex]:not
880([tabindex='-1'])"), 881 function (el) { return !el.disabled && el.getClientRects().length; }); 882 } 883 /* while the popup is open, everything else on the page is inert: every element of the body but the popup's host */ 884 function lockPage(on) { 885 Array.prototype.forEach.call(document.body.children, function (el) { 886 if (el === modalHost || el.tagName === "SCRIPT") return; 887 if (on) { if (!el.hasAttribute("inert")) { el.setAttribute("inert", ""); el.setAttribute("data-labx-inert", ""); } } 888 else if (el.hasAttribute("data-labx-inert")) { el.removeAttribute("inert"); el.removeAttribute("data-labx-inert"); } 889 }); 890 document.documentElement.classList.toggle("labx-locked", !!on); 891 } 892 function draw(ctx, key) { 893 var d = ctx.data, pref = readPref(); 894 var x = prep(d, pref).filter(function (y) { return y.s.key === key; })[0]; 895 if (!x) return false; 896 var host = modal(); 897 host.innerHTML = dialog(d, ctx.o, x); 898 return true; 899 } 900 function openPopup(ctx, key, opener) { 901 var st = state(ctx.o); 902 closePanel(ctx, false); 903 st.cmp = "own"; 904 if (!draw(ctx, key)) return; 905 var host = modal(); 906 host.hidden = false; 907 st.open = key; 908 st.opener = opener || null; 909 st.ctx = ctx; 910 modalHost.__labx = ctx; 911 lockPage(true); 912 writeState(ctx.o, st); 913 UI.trendWire(host); 914 host.querySelector("[data-labx-heading]").focus(); 915 } 916 function closePopup(ctx) { 917 var st = state(ctx.o); 918 if (!st.open) return; 919 var key = st.open, host = modal(); 920 st.open = null; 921 host.hidden = true; 922 host.innerHTML = ""; 923 lockPage(false); 924 writeState(ctx.o, st); 925 var back = st.opener && st.opener.isConnected ? st.opener 926 : Array.prototype.filter.call(ctx.host.querySelectorAll("[data-labx-open]"), function (b) { return b.getAttribute("data-labx-open") === key; })[0]; 927 st.opener = null; 928 if (back) back.focus(); 929 } 930 931 /* ---- the wiring, once per host and once for the popup ------------------------------------ */ 932 function wire(ctx) { 933 var host = ctx.host; 934 if (host.hasAttribute("data-labx-wired")) { host.__labx = ctx; return; } 935 host.setAttribute("data-labx-wired", ""); 936 host.__labx = ctx; 937 host.addEventListener("change", function (e) { 938 var c = e.target.closest("[data-labx-check]"); 939 if (!c) return; 940 var cx = host.__labx, st = state(cx.o); 941 var kind = c.getAttribute("data-labx-check"), list = kind === "status" ? st.status : kind === "kind" ? st.kind : st.area; 942 var at = list.indexOf(c.value); 943 if (c.checked && at < 0) list.push(c.value); 944 if (!c.checked && at >= 0) list.splice(at, 1); 945 var order = kind === "status" ? STATUS : kind === "kind" ? kindsOrder(cx.data) : (cx.data.lab_settings.health_areas || []).map(function (a) { return a.key; }); 946 list.sort(function (a, b) { return order.indexOf(a) - order.indexOf(b); }); 947 writeState(cx.o, st); results(cx); 948 }); 949 host.addEventListener("keydown", function (e) { 950 var cx = host.__labx, st = state(cx.o); 951 if ((e.key === "Escape" || e.key === "Esc") && st.panel && !e.defaultPrevented) { e.preventDefault(); closePanel(cx, true); } 952 }); 953 document.addEventListener("click", function (e) { 954 var cx = host.__labx, st = state(cx.o); 955 if (st.panel && !(e.target.closest && e.target.closest("[data-labx-filter]"))) closePanel(cx, false); 956 }); 957 /* the keyboard leaving an open filter closes it, so the panel never covers the control the focus went to */ 958 host.addEventListener("focusout", function (e) { 959 var cx = host.__labx, st = state(cx.o); 960 if (!st.panel) return; 961 var f = panelOf(host, st.panel), to = e.relatedTarget; 962 if (to && f && !f.contains(to)) closePanel(cx, false); 963 }); 964 var m = modal(); 965 if (!m.hasAttribute("data-labx-wired")) { 966 m.setAttribute("data-labx-wired", ""); 967 UI.trendWire(m); 968 m.addEventListener("click", function (e) { 969 var cx = m.__labx; 970 if (!cx) return; 971 if (e.target.closest("[data-labx-close]") || e.target.hasAttribute("data-labx-scrim")) { closePopup(cx); return; } 972 // T472: the age group a comparison is read against: the popup is drawn again, the pressed chip keeps the focus 973 var cg = e.target.closest("[data-labx-cmp]"); 974 if (cg) { 975 var which = cg.getAttribute("data-labx-cmp"), skey = cg.getAttribute("data-series"); 976 state(cx.o).cmp = which; 977 draw(cx, skey); 978 UI.trendWire(m); 979 var back2 = m.querySelector('[data-labx-cmp="' + which + '"]'); 980 if (back2) back2.focus(); 981 return; 982 } 983 var u = e.target.closest("[data-unit]"); 984 if (u) { 985 var key = u.getAttribute("data-series"), code = u.getAttribute("data-unit"); 986 var pref = readPref(); 987 pref[key] = code; 988 writePref(pref); 989 draw(cx, key); 990 UI.trendWire(m); 991 var again = Array.prototype.filter.call(m.querySelectorAll("[data-unit]"), function (el) { return el.getAttribute("data-unit") === code; })[0]; 992 if (again) again.focus(); 993 results(cx); 994 return; 995 } 996 var b = e.target.closest("[data-act]"); 997 if (b && cx.onAct) cx.onAct(e, b); 998 }); 999 /* registered after the graph's wire: a shown reading takes the first Escape (T343f), the next one closes */ 1000 m.addEventListener("keydown", function (e) { 1001 var cx = m.__labx; 1002 if (!cx || !state(cx.o).open) return; 1003 if ((e.key === "Escape" || e.key === "Esc") && !e.defaultPrevented) { e.preventDefault(); closePopup(cx); return; } 1004 if (e.key === "Tab") { 1005 var dlg = m.querySelector(".labx-dialog"), list = focusables(dlg); 1006 if (!list.length) { e.preventDefault(); return; } 1007 var i = list.indexOf(document.activeElement); 1008 if (e.shiftKey && i <= 0) { e.preventDefault(); list[list.length - 1].focus(); } 1009 else if (!e.shiftKey && (i === list.length - 1 || (i < 0 && !dlg.contains(document.activeElement)))) { e.preventDefault(); list[0].focus(); } 1010 } 1011 }); 1012 document.addEventListener("focusin", function (e) { 1013 var cx = m.__labx; 1014 if (!cx || !state(cx.o).open) return; 1015 var dlg = m.querySelector(".labx-dialog"); 1016 if (dlg && !dlg.contains(e.target)) dlg.querySelector("[data-labx-
1016heading]").focus(); 1017 }); 1018 } 1019 } 1020 1021 function after(ctx) { 1022 var st = state(ctx.o); 1023 wire(ctx); 1024 results(ctx); 1025 writeState(ctx.o, st); 1026 if (st.open) { 1027 // a repaint while the popup is open (a unit changed, a reload after a save): draw it again with the new data 1028 if (!draw(ctx, st.open)) closePopup(ctx); 1029 else { modal().__labx = ctx; UI.trendWire(modal()); } 1030 } else if (st.pendingOpen) { 1031 var key = st.pendingOpen; 1032 st.pendingOpen = null; 1033 openPopup(ctx, key, Array.prototype.filter.call(ctx.host.querySelectorAll("[data-labx-open]"), function (b) { return b.getAttribute("data-labx-open") === key; })[0]); 1034 } 1035 } 1036 1037 function click(e, ctx) { 1038 var b = e.target.closest("button, input"); 1039 if (!b || !ctx.host.contains(b) || !b.closest("[data-labx]")) return false; 1040 var st = state(ctx.o); 1041 ctx.host.__labx = ctx; 1042 if (b.hasAttribute("data-labx-toggle")) { 1043 var kind = b.getAttribute("data-labx-toggle"); 1044 if (st.panel === kind) closePanel(ctx, false); else openPanel(ctx, kind); 1045 return true; 1046 } 1047 if (b.hasAttribute("data-labx-done")) { closePanel(ctx, true); return true; } 1048 if (b.hasAttribute("data-labx-clear")) { 1049 var which = b.getAttribute("data-labx-clear"); 1050 if (which === "status") st.status = []; else if (which === "kind") st.kind = []; else st.area = []; 1051 writeState(ctx.o, st); results(ctx); return true; 1052 } 1053 if (b.hasAttribute("data-labx-reset")) { 1054 var inEmpty = !!b.closest("[data-labx-results]"); 1055 st.status = []; st.area = []; st.kind = []; 1056 writeState(ctx.o, st); results(ctx); 1057 var c = ctx.host.querySelector("[data-labx-count]"); 1058 if (inEmpty && c) { c.setAttribute("tabindex", "-1"); c.focus(); } 1059 else { var tb = ctx.host.querySelector('[data-labx-toggle="status"]'); if (tb) tb.focus(); } 1060 return true; 1061 } 1062 if (b.hasAttribute("data-labx-view")) { 1063 var v = b.getAttribute("data-labx-view"); 1064 if (v !== st.view) { st.view = v; keepView(st); writeState(ctx.o, st); results(ctx); } 1065 return true; 1066 } 1067 if (b.hasAttribute("data-labx-open")) { openPopup(ctx, b.getAttribute("data-labx-open"), b); return true; } 1068 return b.hasAttribute("data-labx-check"); 1069 } 1070 1071 /* T536: one value of one report on its bands (a signed scheme's, else the laboratory's reference), for "Show values"; 1072 "" where the value has no bands or is a word */ 1073 function valueBar(d, markerKey, panelId) { 1074 if (!kindsOn(d) || !markerKey) return ""; 1075 var x = prep(d, readPref()).filter(function (y) { return y.s.marker_key === markerKey; })[0]; 1076 if (!x || !x.unit) return ""; 1077 var r = x.s.readings.filter(function (y) { return y.panel_id === panelId; })[0]; 1078 if (!r) return ""; 1079 var exact = comparable(r), v = exact ? lin(r.canonical, x) : limitY(x, r); 1080 var st = x.s.settings || {}, ivs = null, word = ""; 1081 if (judged(x) && !x.a) { 1082 ivs = (st.intervals || []).map(function (iv) { return scaled(iv, x); }); 1083 word = statusWord(d, r.status || null); 1084 } else { 1085 // the reading's own laboratory's reference, never another's 1086 ivs = r.ref_id ? refBands(d, x, refOf(x, r.ref_id)) : null; 1087 word = bandWord(ivs, v, exact ? null : r.qualifier); 1088 } 1089 if (!ivs || !ivs.length) return ""; 1090 return UI.rangeBar({ intervals: ivs, value: v, limit: !exact && v != null, domain: { min: st.domain && st.domain.min != null ? lin(st.domain.min, x) : 0, max: null }, 1091 label: shown(d, x) + ": " + valueWords(d, x, r) + (word ? ", " + word : "") }); 1092 } 1093 1094 /* the line above a report's value bars: whose bands they are and whether our doctors reviewed them (one line per 1095 laboratory reference state the report's bars use) */ 1096 function panelOrigin(d, p) { 1097 if (!kindsOn(d)) return ""; 1098 var seen = {}, out = []; 1099 prep(d, readPref()).forEach(function (x) { 1100 if (judged(x)) return;
1101 x.s.readings.forEach(function (r) { 1102 if (r.panel_id !== p.id || !r.ref_id) return; 1103 var rf = refOf(x, r.ref_id); 1104 if (!rf || seen[rf.laboratory + rf.state]) return; 1105 seen[rf.laboratory + rf.state] = true; 1106 out.push(refOrigin(d, x, rf)); 1107 }); 1108 }); 1109 return out.join(" "); 1110 } 1111 1112 /* the card of one series, for the unit tests (app/qa/t343/manual_lab_unit.js): the same drawing the grid uses */ 1113 function cardOf(d, key) { 1114 var x = prep(d, readPref()).filter(function (y) { return y.s.key === key; })[0]; 1115 return x ? card(d, x) : ""; 1116 } 1117 return { check: check, html: html, after: after, click: click, precisionIn: precisionIn, WORDS: WORDS, cardOf: cardOf, hasAll: hasAll, wx: wx, 1118 word: function (d, key) { return pack(d)[key] == null ? null : pack(d)[key]; }, 1119 state: function (o) { var s = state(o); return { view: s.view, status: s.status.slice(), area: s.area.slice(), kind: s.kind.slice(), open: s.open }; }, 1120 valueBar: valueBar, kindsOn: kindsOn, panelOrigin: panelOrigin, wire: wire }; 1121 })(); 1122 1123 /* ---- T573 Addendum 7: the example on the commercial pages -------------------------------------------------------- 1124 Jesse, 2026-10-09: "one design for how a biomarker's value is shown: the software's lab results page; what your 1125 results look like shows the actual page". The blood panels' pages hand an example answer (api/_lib/lab-example.js, 1126 the dashboard's own data shape with invented readings) to the dashboard's own drawing: example() draws the results 1127 as this page does (the count, the cards in their groups, the value's popup with its five bands), 1128 exampleCard() one card of it with its popup. Read only: nothing is saved, the address is not written. */ 1129 function exampleCtx(host, d) { 1130 DASH.check(d); 1131 var o = { embedded: true, readOnly: true, viewer: "person", example: true }; 1132 var ctx = { host: host, o: o, data: d, said: function () {}, onAct: null }; 1133 ctx.repaint = function () { DASH.after(ctx); }; 1134 host.addEventListener("click", function (e) { DASH.click(e, ctx); }); 1135 return ctx; 1136 } 1137 // the results as this page draws them (the count, the groups, the cards, the popup), without the filter bar and the 1138 // view switch: those are not on the design standard yet (T586's close, the sweep's line 7) and an example filters nothing 1139 function example(host, d) { 1140 var ctx = exampleCtx(host, d); 1141 host.innerHTML = '<div class="labx" data-labx><div class="labx-summary" data-labx-summary></div><div data-labx-results></div></div>'; 1142 DASH.after(ctx); 1143 return ctx; 1144 } 1145 function exampleCard(host, d, key) { 1146 var card = DASH.cardOf(d, key); 1147 if (!card) return null; 1148 var ctx = exampleCtx(host, d); 1149 host.innerHTML = '<div class="labx" data-labx>' + card + "</div>"; 1150 DASH.wire(ctx); 1151 UI.trendWire(host); 1152 return ctx; 1153 } 1154 /* ========================================================================== 1155 END OF THE DASHBOARD 1156 ========================================================================== */ 1157 1158 /* ---- the page --------------------------------------------------------------- */ 1159 function primary(o, label2, attrs) { 1160 return o.buttons === "console" 1161 ? UI.button(label2, "primary", attrs) 1162 : '<button class="btn btn--primary" type="button"' + (attrs ? " " + attrs : "") + ">" + esc(label2) + "</button>"; 1163 } 1164 1165 /* the page's head, or on a page that has one already, the section's */ 1166 function top(o, title, purpose, act) { 1167 return o.embedded ? UI.section("labres", title, null, purpose, "", act) : UI.head(title, purpose, "", act); 1168 } 1169 1170 /* The frame, drawn once: the head, the administrator's band, the entry (the report's upload, first; the values by 1171 hand, second; the review of a reading) in a host of its own that a reload of the data never redraws, so a review 1172 in progress is never lost, and the body the data fills. */ 1173 function frame(host, o) { 1174 if (o.member) return memberFrame(host, o); 1175 host.innerHTML = top(o, t(o.embedded ? "labres.title.expert" : "labres.title"), 1176 esc(t(o.viewer === "expert" ? "labres.purpose.expert" : "labres.purpose")), "") + 1177 (o.viewer === "person" && !o.embedded ? onThisPage() : "") + 1178 (o.readOnly ? UI.band("info", t("labres.expert.window.label"), esc(t("labres.expert.window"))) : "") +
1179 (o.readOnly ? "" : '<div data-lab-entry id="lab-add" tabindex="-1"></div>') + 1180 '<div data-lab-body></div>'; 1181 } 1182 1183 /* T566 (Jesse, 2026-10-06): the member's two pages. The results page shows the upload box big and first while the 1184 person has no lab data, and the biomarkers below the head once there is any, with the upload as the head's act 1185 (a labelled icon to the upload page); the upload page is the same box as a page of its own. The box itself is the 1186 page's (memberPage), mounted once in a host of its own that a reload of the data never redraws. T465 Addendum 6: 1187 with the box gone from a filled results page, a reading still open is said in a card above the results 1188 (readingCard), with its door to the upload page, where the box resumes it. */ 1189 var UPLOAD_PAGE = "/app/analysis/lab-upload.html"; 1190 var UP_ICON = '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" ' + 1191 'stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 15V4"/><path d="m7 9 5-5 5 5"/><path d="M5 15v3a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-3"/></svg>'; 1192 function memberTitle(o) { return t(o.member === "upload" ? "labres.up.page.title" : "labres.title"); } 1193 function memberPurpose(o) { return esc(t(o.member === "upload" ? "labres.up.page.purpose" : "labres.purpose")); } 1194 function memberBack(o) { 1195 return o.member === "upload" ? '<a class="labres-up__back" href="/app/analysis/lab-results.html"><span aria-hidden="true">←</span> ' + 1196 esc(t("labres.up.back")) + "</a>" : ""; 1197 } 1198 function memberFrame(host, o) { 1199 host.innerHTML = UI.head(memberTitle(o), memberPurpose(o), memberBack(o), '<span data-lab-headact></span>') + 1200 '<div data-said-slot role="status" aria-live="polite"></div><div data-lab-note></div>' + 1201 '<div data-lab-up id="lab-add" tabindex="-1" hidden></div><div data-lab-body></div>'; 1202 } 1203 function hasData(d) { return !!(d && ((d.series || []).length || (d.panels || []).length)); } 1204 function memberPaint(host, o, d) { 1205 var body = host.querySelector("[data-lab-body]"), up = host.querySelector("[data-lab-up]"), act = host.querySelector("[data-lab-headact]"); 1206 var filled = hasData(d), results = o.member === "results"; 1207 // T566, Astra's second read: a review or an import still under way keeps the box on the page, data or not 1208 var showUp = !results || !filled || !!(up.__mounted && o.keepUp && o.keepUp()); 1209 up.hidden = !showUp; 1210 if (showUp && o.mountUpload && !up.__mounted) { up.__mounted = true; o.mountUpload(up); } 1211 act.innerHTML = results && filled ? '<a class="btn btn--primary labres-upact" href="' + UPLOAD_PAGE + '" data-lab-upact aria-label="' + 1212 esc(t("labres.up.act.aria")) + '">' + UP_ICON + "<span>" + esc(t("labres.up.act")) + "</span></a>" : ""; 1213 var note = host.querySelector("[data-lab-note]"); 1214 if (!(results && filled)) note.innerHTML = ""; 1215 if (!results || !filled) { body.innerHTML = ""; return; } 1216 body.innerHTML = '<div data-lab-dashboard id="lab-values">' + DASH.html(d, o) + "</div>" + scoreSection(d, o) + reportsSection(o, d) + 1217 notCalculated(d, o) + refsSection(d); 1218 } 1219 1220 /* T519 (Jesse, 2026-10-02, the first customer: "it is unclear what can be done on that page"): what the page holds, 1221 in four short lines, each a link to its part of the page */ 1222 function onThisPage() { 1223 var items = [["lab-add", "labres.onpage.add"], ["lab-values", "labres.onpage.values"], ["lab-reports", "labres.onpage.reports"], 1224 ["medical-records", "labres.onpage.records"]]; 1225 return '<nav class="labres-onpage" aria-label="' + esc(t("labres.onpage.label")) + '" data-lab-onpage><span class="cx-label">' + 1226 esc(t("labres.onpage.label")) + '</span><ul class="labres-onpage__list">' + items.map(function (x) { 1227 return '<li><a class="labres-onpage__link" href="#' + x[0] + '">' + esc(t(x[1] + ".title")) + "</a><span>" + esc(t(x[1] + ".says")) + "</span></li>"; 1228 }).join("") + "</ul></nav>"; 1229 } 1230 1231 /* The body: the one-glance dashboard (DASH) and the reports by date. T519 (Jesse, 2026-10-02): nothing is sold inside
1232 the software; the card that announced lab testing through BioProfile is gone (the member's page ends with one quiet 1233 link to the lab tests page instead, drawn by memberPage). */ 1234 function paint(host, o, d) { 1235 if (o.member) return memberPaint(host, o, d); 1236 var body = host.querySelector("[data-lab-body]"); 1237 if (!d.series.length && !d.panels.length) { 1238 // T343c: the empty state is a section of its own, so its distance from the entry never depends on what the entry shows 1239 body.innerHTML = '<section class="cx-sec" data-sec="empty" id="lab-reports">' + UI.empty(t("labres.empty.title"), esc(t(o.viewer === "expert" ? "labres.empty.expert" : "labres.empty.says"))) + 1240 '</section><span id="lab-values"></span>'; 1241 return; 1242 } 1243 body.innerHTML = scoreSection(d, o) + '<div data-lab-dashboard id="lab-values">' + DASH.html(d, o) + "</div>" + reportsSection(o, d) + notCalculated(d, o) + refsSection(d); 1244 } 1245 /* the reports by date: one row a report, its values first and the rest behind More */ 1246 function reportsSection(o, d) { 1247 var feedback = window.BP_LAB_FEEDBACK && !o.readOnly; 1248 var rows = d.panels.map(function (p) { 1249 // T343c: one line of actions a row: the primary one as a button, the rest behind "More". T519: the primary one 1250 // opens the report's values; its file (while one is kept), the change and the deletion are behind "More" 1251 var rest = [], edit = p.can_edit && !o.readOnly; 1252 var first = UI.button(t("labres.values.open"), "secondary", 'data-act="values" data-id="' + esc(p.id) + '" aria-haspopup="dialog" aria-label="' + 1253 esc(t("labres.values.open.aria", { date: day(p.sample_date) })) + '"', true); 1254 if (p.report) rest.push(UI.button(t("labres.report.open"), "quiet", 'data-act="report" data-id="' + esc(p.id) + '"', true)); 1255 // T536: the kept report this result was typed from (a document the person chose to keep) 1256 (p.files || []).forEach(function (f) { 1257 rest.push(UI.button(cword(d, "kept.open"), "quiet", 'data-act="keptfile" data-id="' + esc(f.id) + '" aria-label="' + esc(cword(d, "kept.open") + ": " + f.title) + '"', true)); 1258 }); 1259 if (edit) rest.push(UI.button(t("labres.edit"), "quiet", 'data-act="edit" data-id="' + esc(p.id) + '"', true)); 1260 // T465 Addendum 5: Delete for every report the person may delete (the server's can_delete), also one that cannot 1261 // be edited (an unreadable row, a lost unit); never on an expert's report, which the server would refuse 1262 if (p.can_delete && !o.readOnly) 1263 rest.push(UI.button(t("labres.delete"), "quiet", 'data-act="delete" data-id="' + esc(p.id) + '" data-date="' + esc(day(p.sample_date)) + '"', true)); 1264 if (feedback && p.source === "manual") rest.push(window.BP_LAB_FEEDBACK.button({ kind: "result", id: p.id }, "result_set")); 1265 var acts = UI.actions(first, rest, { more: t("labres.more"), moreAria: t("labres.more.aria", { date: day(p.sample_date) }) }); 1266 var where = p.source === "ordered" ? t("labres.panels.ordered") 1267 : (p.lab_name || t(p.route === "report_reader" ? "labres.panels.fromReport" : "labres.panels.noLab")); 1268 return UI.row([ 1269 UI.td("", "<b>" + esc(day(p.sample_date)) + "</b>", { lead: true, raw: true }), 1270 UI.td(t("labres.panels.col.lab"), where), 1271 UI.td(t("labres.panels.col.count"), String(p.rows.length), { num: true }), 1272 UI.td(t("labres.panels.col.author"), UI.pill(p.source === "ordered" ? "brand" : "quiet", t("labres.author." + p.author)) + 1273 (p.corrected ? '<div class="labres-mark">' + UI.pill("info", cword(d, o.viewer === "expert" ? "corrected.expert" : "corrected")) + "</div>" : ""), { raw: true, cls: "cx-table__state" }), 1274 '<td class="cx-table__act' + (acts ? "" : " is-empty") + '" data-l="">' + acts + "</td>" 1275 ], ' data-panel="' + esc(p.id) + '"'); 1276 }); 1277 var another = o.readOnly ? "" : UI.button(t("labres.panels.another"), "secondary", 'data-act="another"'); 1278 return UI.section("entries", t("labres.panels.title"), d.panels.length, 1279 esc(t(o.viewer === "expert" ? "labres.panels.purpose.expert" : "labres.panels.purpose")), 1280 UI.table([t("labres.panels.col.date"), t("labres.panels.col.lab"), { label: t("labres.panels.col.count"), num: true }, 1281 t("labres.panels.col.author"), { act: true, label: t("labres.panels.col.act") }], rows, 1282 { tableClass: "cx-table--fixed cx-table--wide cx-table--baseline labres-entries", tableAttrs: 'style="--cx-tmin: 49.5rem; --cx-num-w: 6.25rem; --cx-act-w: 13rem"' }) + 1283 (o.member ? "" : '<div data-said-slot role="status" aria-l
1283ive="polite"></div>'), 1284 another, 'id="lab-reports"'); 1285 } 1286 1287 /* ========================================================================== 1288 T472: THE BIOPROFILE SCORE, THE BIOLOGICAL AGE AND THE SOURCES (behind the server's BP_LAB_SCORE: `d.score` is 1289 present only where the server computed it; the page is exactly as before without it). The server decided every 1290 number; this file words it from the content table and never computes a score. 1291 ========================================================================== */ 1292 var SCORE_WORDS = ["score.title", "score.purpose", "score.figure", "score.date", "score.not_validated", "score.review", "score.part.values", 1293 "score.part.values.says", "score.part.age", "score.part.age.says", "score.part.bioage", "score.part.weight", "score.part.none", 1294 "score.part.none.age", "score.part.none.bioage", "score.none.title", "score.none.minimum", "score.none.source", "score.none.act", 1295 "score.trend.title", "score.trend.summary", "score.trend.changed", "score.how", "score.how.values", "score.how.age", "score.how.bioage", 1296 "score.how.weights", "bioage.name", "bioage.calc", "bioage.figure", "bioage.under", "bioage.over", "bioage.same", "bioage.age", 1297 "bioage.context", "bioage.missing.title", "bioage.missing.latest", "bioage.missing.says", "bioage.missing.age", "score.part.held.bioage", "refs.title", "refs.bioage", "refs.population", 1298 "refs.open"]; 1299 function sx(d, key, vars) { return DASH.wx(d, key, vars); } 1300 function one(v) { return fmt(Math.round(Number(v) * 10) / 10, 1); } 1301 function scoreSection(d, o) { 1302 var S = d.score; 1303 if (!S || !DASH.hasAll(d, SCORE_WORDS)) return ""; 1304 var res = (S.results || []).filter(function (r) { return r.panel_id === S.latest; })[0] || null; 1305 var tiles = [], notes = []; 1306 if (res && S.formula) { 1307 var P = res.parts || {}; 1308 tiles.push(UI.stat(sx(d, "score.title"), sx(d, "score.figure", { score: res.score }), esc(sx(d, "score.date", { date: day(res.date) })), "is-marked", "data-score-total")); 1309 tiles.push(P.values ? UI.stat(sx(d, "score.part.values"), String(Math.round(P.values.points)), 1310 esc(sx(d, "score.part.weight", { weight: Math.round(P.values.effective_weight) }) + " · " + sx(d, "score.part.values.says", { n: P.values.n_values, areas: P.values.n_areas })), "", "data-score-part=\"values\"") 1311 : UI.stat(sx(d, "score.part.values"), sx(d, "score.part.none"), "", "is-waiting", "data-score-part=\"values\"")); 1312 tiles.push(P.age ? UI.stat(sx(d, "score.part.age"), String(Math.round(P.age.points)), 1313 esc(sx(d, "score.part.weight", { weight: Math.round(P.age.effective_weight) }) + " · " + sx(d, "score.part.age.says", { n: P.age.values.length })), "", "data-score-part=\"age\"") 1314 : UI.stat(sx(d, "score.part.age"), sx(d, "score.part.none"), esc(sx(d, "score.part.none.age")), "is-waiting", "data-score-part=\"age\"")); 1315 tiles.push(P.bioage ? UI.stat(sx(d, "score.part.bioage"), String(Math.round(P.bioage.points)), 1316 esc(sx(d, "score.part.weight", { weight: Math.round(P.bioage.effective_weight) })), "", "data-score-part=\"bioage\"") 1317 : UI.stat(sx(d, "score.part.bioage"), sx(d, "score.part.none"), esc(sx(d, P.bioage_held ? "score.part.held.bioage" : "score.part.none.bioage")), 1318 "is-waiting", "data-score-part=\"bioage\"")); 1319 notes.push(sx(d, "score.not_validated")); 1320 if (S.formula.review_mark !== "physician_signed") notes.push(sx(d, "score.review")); 1321 } 1322 var body = ""; 1323 if (tiles.length) { 1324 body += UI.stats(tiles); 1325 } else if (S.formula) { 1326 // no score: why, and the one next step (a report with more values) 1327 var last = (S.results || []).filter(function (r) { return r.panel_id === S.latest_result; })[0] || null; 1328 var says = !last ? "" : last.reason === "below_minimum" && last.parts && last.parts.values && S.formula 1329 ? sx(d, "score.none.minimum", { min_values: S.formula.minimum.values, min_areas: S.formula.minimum.areas, 1330 values: last.parts.values.n_values, areas: last.parts.values.n_areas }) 1331 : last.reason === "source_not_counted" ? sx(d, "score.none.source") : ""; 1332 var act = o.readOnly ? null : '<a class="cx-btn cx-btn--secondary" href="' + (o.member ? UPLOAD_PAGE : "#lab-add") + '" data-score-add>' + esc(sx(d, "score.none.act")) + "</a>"; 1333 body += UI.empty(sx(d, "score.none.title"), esc(says), act ? { act: act } : {}); 1334 } 1335 // the biological age: the latest result that has one, with the age and the difference in words 1336 var B = S.bioage; 1337 if (B) { 1338 var diff = Math.round(Number(B.years_under) * 10) / 10; 1339 var rel = diff > 0 ? sx(d, "bioage.under", { years: one(diff) }) : diff < 0 ? sx(d, "bioage.over", { years: one(-diff) }) : sx(d, "bioage.same"); 1340 body += UI.stats([UI.stat(sx(d, "bioage.name"), sx(d, "bioage.figure", { years: one(B.biological_age) }), 1341 esc(rel) + "<br>" + esc(sx(d, "bioage.age", { age: one(B.age) })) + "<br>" + esc(sx(d, "score.date", { date: day(B.date) })), "", "data-bioage")]); 1342 body += '<p class="bp-note" data-bioage-calc>' + esc(sx(d, "bioage.calc")) + " " + esc(sx(d, "bioage.context")) + "</p>"; 1343 } 1344 if (S.bioage_missing) { 1345 // the latest test's own state, also beside an earlier test's biological age: every missing value, or the 1346 // evaluator's own reason (a value in conflict, under a limit, in another unit, outside the formula's range) 1347 var M = S.bioage_missing, list = (M.inputs || []).map(function (i) { return label(i.name); }).join(", "); 1348 var why = M.state && M.state !== "input_missing" && pack(d)["bioage.why." + M.state] 1349 ? sx(d, "bioage.why." + M.state, { value: M.input ? label(M.input) : "" }) : ""; 1350 var lines = [list ? sx(d, "bioage.missing.says", { list: list }) : "", why, M.age_missing ? sx(d, "bioage.missing.age") : ""].filter(
1350Boolean).join(" "); 1351 if (lines) body += '<p class="bp-note" data-bioage-missing><b>' + esc(sx(d, B ? "bioage.missing.latest" : "bioage.missing.title", { date: day(M.date) })) + "</b> " + esc(lines) + "</p>"; 1352 } 1353 // the trend: the scores in date order; a marked point is not like the one before it 1354 var T = S.trend || []; 1355 if (T.length >= 2) { 1356 var pts = T.map(function (p) { return { date: day(p.date), iso: String(p.date).slice(0, 10), y: p.score, value: sx(d, "score.figure", { score: p.score }), 1357 range: "", author: "", out: false }; }); 1358 body += UI.section("score-trend", sx(d, "score.trend.title"), null, "", 1359 UI.trend(pts, { id: "score-trend", unit: "", fmt: function (v) { return String(Math.round(v)); }, 1360 summary: sx(d, "score.trend.summary", { count: T.length, first: day(T[0].date), last: day(T[T.length - 1].date) }), 1361 words: { line: t("labres.trend.line"), table: t("labres.trend.table"), date: t("labres.trend.date"), value: t("labres.trend.value"), 1362 point: t("labres.trend.point"), range: t("labres.trend.range"), author: t("labres.trend.author"), none: t("labres.trend.none"), 1363 lowest: t("labres.trend.lowest"), highest: t("labres.trend.highest") } }) + 1364 (T.some(function (p) { return p.changed; }) ? '<p class="bp-note" data-score-changed>' + esc(sx(d, "score.trend.changed", { 1365 dates: T.filter(function (p) { return p.changed; }).map(function (p) { return day(p.date); }).join(", ") })) + "</p>" : "")); 1366 } 1367 if (S.formula) { 1368 var how = []; 1369 if (S.formula.status_points) how.push(sx(d, "score.how.values", S.formula.status_points)); 1370 how.push(sx(d, "score.how.age")); 1371 if (S.formula.bioage) how.push(sx(d, "score.how.bioage", { base: S.formula.bioage.base, per_year: S.formula.bioage.per_year })); 1372 how.push(sx(d, "score.how.weights")); 1373 body += '<details class="cx-disclose" data-score-how><summary>' + esc(sx(d, "score.how")) + '</summary><div class="cx-disclose__body">' + 1374 how.map(function (h) { return "<p>" + esc(h) + "</p>"; }).join("") + "</div></details>"; 1375 } 1376 body += notes.map(function (n) { return '<p class="bp-note">' + esc(n) + "</p>"; }).join(""); 1377 // with no score formula in use the comparisons and the biological age still show (they do not depend on one): the 1378 // section is then the biological age's own, and it is absent when there is nothing to say 1379 if (!body) return ""; 1380 return S.formula ? UI.section("score", sx(d, "score.title"), null, esc(sx(d, "score.purpose")), body, "", 'id="lab-score"') 1381 : UI.section("score", sx(d, "bioage.name"), null, "", body, "", 'id="lab-score"'); 1382 } 1383 function refsSection(d) { 1384 var S = d.score; 1385 if (!S || !(S.references || []).length || !DASH.hasAll(d, SCORE_WORDS)) return ""; 1386 return UI.refs(sx(d, "refs.title"), S.references.map(function (r) { 1387 return { what: sx(d, r.kind === "bioage" ? "refs.bioage" : "refs.population"), text: r.text, url: r.url }; 1388 }), sx(d, "refs.open"), "data-lab-refs"); 1389 } 1390 1391 /* T457: for the expert, which calculated values a result could not give and why (the evaluator's own reasons, in the 1392 content table's words); a member sees only the values that were calculated */ 1393 function notCalculated(d, o) { 1394 if (o.viewer !== "expert") return ""; 1395 var list = (d.panels || []).filter(function (p) { return (p.calculated_empty || []).length; }); 1396 var title = DASH.word(d, "calculated.empty.title"); 1397 if (!list.length || !title) return ""; 1398 var items = list.map(function (p) { 1399 return "<li><b>" + esc(day(p.sample_date)) + "</b>: " + p.calculated_empty.map(function (e) { 1400 var why = DASH.word(d, "calculated.why." + e.state); 1401 return esc(label(e.label)) + (why ? " (" + esc(why) + ")" : ""); 1402 }).join("; ") + "</li>"; 1403 }).join(""); 1404 return UI.section("labres-not-calculated", title, null, "", '<ul class="labres-not-calculated" data-lab-not-calculated>' + items + "</ul>"); 1405 } 1406 1407 /* the report of one result set, opened: a 60 second link in place of the button, the button back before it lapses */ 1408 function openReport(o, b, id, said) { 1409 b.disabled = true; 1410 return o.api(o.viewer === "expert" ? "lab-manual-client-report" : "lab-manual-report", { params: { id: id } }) 1411 .then(function (r) { 1412 var a = document.createElement("a"); 1413 a.className = "cx-open"; 1414 a.href = r.url; a.target = "_blank"; a.rel = "noopener"; 1415 a.textContent = t("labres.report.open"); 1416 b.replaceWith(a); 1417 a.focus(); 1418 setTimeout(function () { 1419 if (!a.isConnected) return; 1420 var had = document.activeElement === a; 1421 b.disabled = false; a.replaceWith(b); 1422 if (had) b.focus(); 1423 }, Math.max(5, ((r.expires_in || 60) - 5)) * 1000); 1424 }) 1425 .catch(function () { b.disabled = false; said(t("labres.report.failed"), true); }); 1426 } 1427 1428 /* ---- T519: the values of one report, read only, in the page's dialog ---------------------------------------- */ 1429 function valuesDialog(o, d, p) { 1430 var unitById = {};
1431 (d.units || []).forEach(function (u) { unitById[u.id] = u; }); 1432 var lab = p.source === "ordered" ? t("labres.panels.ordered") : (p.lab_name || t(p.route === "report_reader" ? "labres.panels.fromReport" : "labres.panels.noLab")); 1433 var items = (p.rows || []).map(function (r) { 1434 var unit = r.unit_id && unitById[r.unit_id] ? " " + unitById[r.unit_id].symbol : ""; 1435 var value = String(r.value == null ? "" : r.value); 1436 if (locale() === "nl") value = value.replace(/(\d)\.(\d)/g, "$1,$2"); 1437 // T536: the value on its bands where it has any (a signed range or the laboratory's reference), the words beside it 1438 var bar = DASH.valueBar ? DASH.valueBar(d, r.marker_key, p.id) : ""; 1439 return "<li" + (bar ? ' class="has-bar"' : "") + "><span>" + esc(label(r.label)) + "</span><b>" + esc(value + unit) + "</b>" + 1440 (bar ? '<div class="labres-values__bar">' + bar + "</div>" : "") + "</li>"; 1441 }).join(""); 1442 var origin = DASH.panelOrigin ? DASH.panelOrigin(d, p) : ""; 1443 var body = '<p class="cx-small">' + esc(t("labres.values.meta", { lab: lab, count: (p.rows || []).length, author: t("labres.author." + p.author) })) + "</p>" + 1444 (origin ? '<p class="bp-note" data-lab-values-origin>' + esc(origin) + "</p>" : "") + 1445 (items ? '<ul class="labres-values" data-lab-values>' + items + "</ul>" : '<p class="cx-small">' + esc(t("labres.values.none")) + "</p>"); 1446 return confirmDialog(o, { title: t("labres.values.title", { date: day(p.sample_date) }), body: "", markup: body, keep: t("labres.values.close"), only: true }); 1447 } 1448 function label(obj) { return obj ? (locale() === "nl" ? obj.nl || obj.en : obj.en || obj.nl) : ""; } 1449 1450 /* ---- the dialog: focus in, a Tab loop, Escape, and focus back ---------------- */ 1451 function confirmDialog(o, spec) { 1452 return new Promise(function (resolve) { 1453 var opener = document.activeElement; 1454 var scrim = document.createElement("div"); 1455 scrim.className = "bp-scrim"; 1456 var keep = o.buttons === "console" ? UI.button(spec.keep, "secondary", "data-keep") : '<button class="btn btn--ghost" type="button" data-keep>' + esc(spec.keep) + "</button>"; 1457 var go = spec.only ? "" : o.buttons === "console" ? UI.button(spec.confirm, "danger", "data-go") : '<button class="btn btn--primary" type="button" data-go>' + esc(spec.confirm) + "</button>"; 1458 scrim.innerHTML = UI.dialog(spec.title, spec.body, spec.markup || "", keep + go); 1459 document.body.appendChild(scrim); 1460 var dlg = scrim.querySelector(".bp-dialog"); 1461 dlg.setAttribute("aria-labelledby", "lab-dlg-h"); 1462 dlg.querySelector("h2").id = "lab-dlg-h"; 1463 var buttons = Array.prototype.slice.call(dlg.querySelectorAll("button")); 1464 function done(v) { 1465 document.removeEventListener("keydown", onKey, true); 1466 scrim.remove(); 1467 if (opener && opener.focus && document.body.contains(opener)) opener.focus(); 1468 resolve(v); 1469 } 1470 function onKey(e) { 1471 if (e.key === "Escape") { e.preventDefault(); done(false); return; } 1472 if (e.key === "Tab") { 1473 var i = buttons.indexOf(document.activeElement); 1474 var n = e.shiftKey ? (i <= 0 ? buttons.length - 1 : i - 1) : (i === buttons.length - 1 ? 0 : i + 1); 1475 e.preventDefault(); 1476 buttons[n].focus(); 1477 } 1478 } 1479 document.addEventListener("keydown", onKey, true); 1480 scrim.addEventListener("click", function (e) { if (e.target === scrim) done(false); }); 1481 dlg.querySelector("[data-keep]").addEventListener("click", function () { done(false); }); 1482 if (!spec.only) dlg.querySelector("[data-go]").addEventListener("click", function () { done(true); }); 1483 dlg.querySelector("[data-keep]").focus(); 1484 }); 1485 } 1486 1487 /* ---- mount ------------------------------------------------------------------- */ 1488 function mount(host, o) { 1489 var state = { data: null, deleteKeys: {}, entry: null }; 1490 /* a state with no dashboard to draw (loading, off, refused, failed) still carries the title of what it is 1491 about: the section's on a page that has its own head, the page's own head otherwise */ 1492 function framed(inner) { 1493 if (o.member) return UI.head(memberTitle(o), memberPurpose(o
1493), memberBack(o), "") + inner; 1494 return o.embedded ? UI.section("labres", t("labres.title.expert"), null, "", inner) 1495 : UI.head(t("labres.title"), esc(t(o.viewer === "expert" ? "labres.purpose.expert" : "labres.purpose")), "", "") + inner; 1496 } 1497 function said(text, bad) { 1498 var slot = host.querySelector("[data-said-slot]"); 1499 if (slot) slot.innerHTML = text ? UI.said(text, bad ? "bad" : "") : ""; 1500 } 1501 function body() { return host.querySelector("[data-lab-body]"); } 1502 /* T457: a dashboard answer the page started before this mount (the expert's page starts it with the client's 1503 results), for this client only, taken once; a reload asks afresh */ 1504 var pre = o.prefetch || null; 1505 function ask() { 1506 var p = pre && pre.promise && (!o.client || pre.client === o.client) ? pre.promise : null; 1507 pre = null; 1508 return p || o.api(o.viewer === "expert" ? "lab-manual-client-dashboard" : "lab-manual-dashboard", {}); 1509 } 1510 function load() { 1511 if (!body()) host.innerHTML = framed('<span class="cx-sr">' + esc(t("labres.loading")) + "</span>" + UI.loading(2)); 1512 else body().innerHTML = '<span class="cx-sr">' + esc(t("labres.loading")) + "</span>" + UI.loading(2); 1513 host.setAttribute("aria-busy", "true"); 1514 return ask() 1515 .then(function (d) { 1516 host.removeAttribute("aria-busy"); 1517 state.data = d; 1518 DASH.check(d); // T385b: every word the dashboard reads, in the page's language, or the failure state below 1519 if (!body()) frame(host, o); 1520 paint(host, o, d); 1521 DASH.after(ctx()); 1522 // the entry: the report's upload first, the values by hand second, and the review of a reading 1523 var entry = o.member ? null : host.querySelector("[data-lab-entry]"); 1524 if (entry && window.BP_LAB_REVIEW) { 1525 if (!state.entry) state.entry = window.BP_LAB_REVIEW.mount(entry, { 1526 api: o.api, viewer: o.viewer, buttons: o.buttons, embedded: o.embedded, reader: o.reader !== false, client: o.client, 1527 data: function () { return state.data; }, confirm: function (spec) { return confirmDialog(o, spec); }, 1528 onManual: function (pre) { if (o.onForm) o.onForm(null, state.data, pre || null); }, 1529 onSaved: function (words) { return load().then(function () { said(words || t("labres.saved")); }); } 1530 }); 1531 else state.entry.data(d); 1532 } 1533 if (window.BP_LAB_FEEDBACK && !o.readOnly) window.BP_LAB_FEEDBACK.wire(host, { api: o.api, viewer: o.viewer, buttons: o.buttons }); 1534 UI.menuWire(host); 1535 if (o.onLoaded) o.onLoaded(d); 1536 }) 1537 .catch(function (e) { 1538 host.removeAttribute("aria-busy"); 1539 state.entry = null; 1540 // T566, Astra's second read: on the member's page a failed reload never takes the upload box (and a review in 1541 // progress) with it: the state is said in the body under the head 1542 if (o.member && body()) { 1543 var b = body(); 1544 if (e && e.status === 404 && (e.data || {}).error === "not_enabled") b.innerHTML = UI.empty(t("labres.off.title"), esc(t("labres.off.says"))); 1545 else if (e && (e.status === 401 || e.status === 403) && o.onRefused) return o.onRefused(e); 1546 else b.innerHTML = UI.error(t("labres.failed.title"), esc(t("labres.failed.says")), UI.button(t("labres.retry"), "secondary", 'data-act="retry"')); 1547 return; 1548 } 1549 if (e && e.status === 404 && (e.data || {}).error === "not_enabled") { 1550 host.innerHTML = framed(UI.empty(t("labres.off.title"), esc(t("labres.off.says")))); 1551 return; 1552 } 1553 if (e && e.status === 403 && (e.data || {}).error === "client_consent_missing") { 1554 host.innerHTML = framed(UI.empty(t("labres.expert.noConsent.title"), esc(t("labres.expert.noConsent.says")))); 1555 return; 1556 } 1557 if (e && e.status === 403 && (e.data || {}).error === "not_assigned") { 1558 host.innerHTML = framed(UI.empty(t("labres.expert.notAssigned.title"), esc(t("labres.expert.notAssigned.says")))); 1559 return; 1560 } 1561 if (e && (e.status === 401 || e.status === 403) && o.onRefused) return o.onRefused(e); 1562 host.innerHTML = framed(UI.error(t("labres.failed.title"), esc(t("labres.failed.says")), 1563 UI.button(t("labres.retry"), "secondary", 'data-act="retry"'))); 1564 }); 1565 } 1566 function ctx() { 1567 return { host: host, o: o, data: state.data, said: said, 1568 repaint: function () { paint(host, o, state.data); DASH.after(ctx()); }, 1569 onAct: function (e, b) { 1570 if (b.getAttribute("data-act") !== "report") return; 1571 var slot = b.closest(".labx-dialog") && b.closest(".labx-dialog").querySelector("[data-labx-said]"); 1572 openReport(o, b, b.getAttribute("data-id"), slot ? function (text, bad) { slot.innerHTML = text ? UI.said(text, bad ? "bad" : "") : ""; } : said); 1573 } }; 1574 } 1575 host.addEventListener("click", function (e) { 1576 if (state.data && DASH.click(e, ctx())) return; 1577 var b = e.target.closest("[data-act]"); 1578 if (!b || !host.contains(b)) return; 1579 if (b.closest("[data-lab-entry]") || b.closest("[data-feedback]")) return; // the entry's and the feedback's own 1580 var act = b.getAttribute("data-act"), id = b.getAttribute("data-id"); 1581 if (act === "retry") return load(); 1582 if (act === "add" && o.onForm) return o.onForm(null, state.data); 1583 if (act === "values") { 1584 var shown = state.data.panels.filter(function (p) { return p.id === id; })[0]; 1585 return shown ? valuesDialog(o, state.data, shown) : null; 1586 } 1587 if (act === "another" && o.member) { window.location.assign(UPLOAD_PAGE); return null; } 1588 if (act === "another") { 1589 // the entry is always on the page above: go there, its first control in focus 1590 var entry = host.querySelector("[data-lab-entry]"); 1591 if (!entry) return null; 1592 try { entry.scrollIntoView({ block: "start" }); } catch (err) { /* nothing to scroll */ } 1593 var go = entry.querySelector('[data-entry="choose"]:not([disabled]), [data-entry="manual"], button:not([disabled])'); 1594 (go || entry).focus(); 1595 return null; 1596 } 1597 if (act === "edit" && o.onForm) { 1598 return o.onForm(state.data.panels.filter(function (p) { return p.id === id; })[0] || null, state.data); 1599 } 1600 if (act === "report") return openReport(o, b, id, said); 1601 if (act === "keptfile") { 1602 var win = window.open("", "_blank"); 1603 return o.api(o.viewer === "expert" ? "medical-records-client-open" : "medical-records-open", { params: { id: id } }) 1604 .then(function (out) { if (win) { try { win.opener = null; } catch (x) { /* refused */ } win.location = out.url; } else window.location.assign(out.url); }) 1605 .catch(function (err) { if (win) win.close(); said(errText((err && err.data || {}).error), true); }); 1606 } 1607 if (act === "delete") { 1608 var panel = state.data.panels.filter(function (p) { return p.id === id; })[0]; 1609 return confirmDialog(o, { title: t("labres.delete.title"), body: t("labres.delete.body", { date: b.getAttribute("data-date") }), 1610 keep: t("labres.delete.keep"), confirm: t("labres.delete.confirm") }) 1611 .then(function (yes) { 1612 if (!yes) return; 1613 b.disabled = true; 1614 // one key per deletion of one panel at one revision, kept until the answer arrives: pressing 1615 // Delete again after a lost answer asks the same question and is answered again, never "gone" 1616 var slot = id + ":" + (panel ? panel.revision : ""); 1617 var delKey = state.deleteKeys[slot] || (state.deleteKeys[slot] = (window.crypto && window.crypto.randomUUID) ? window.crypto.randomUUID()
1618 : "d" + Date.now().toString(36) + Math.random().toString(36).slice(2, 12)); 1619 return o.api("lab-manual-delete", { method: "POST", body: { result_id: id, revision: panel ? panel.revision : null, idempotency_key: delKey } }) 1620 .then(function (out) { 1621 delete state.deleteKeys[slot]; 1622 var pending = out && out.report_cleanup && out.report_cleanup.failed > 0; 1623 return load().then(function () { 1624 said(t(pending ? "labres.deleted.reportPending" : "labres.deleted")); 1625 // the last report deleted leaves no list: the page's own heading takes the focus 1626 var h = host.querySelector('[data-sec="entries"] h2') || host.querySelector(".cx-head h1"); if (h) { h.tabIndex = -1; h.focus(); } 1627 }); 1628 }) 1629 .catch(function (err) { b.disabled = false; said(errText((err && err.data || {}).error), true); }); 1630 }); 1631 } 1632 }); 1633 load(); 1634 return { reload: load, data: function () { return state.data; }, said: said, confirm: function (spec) { return confirmDialog(o, spec); } }; 1635 } 1636 1637 /* ---- the member's page (app/analysis/lab-results.html) ------------------------ */ 1638 /* The member layer's own boot (app/lab/member.js: the locale, the shell, the 1639 signed-in person, the gate for a stranger), the capability (the reader's own 1640 switch decides whether the report comes first), then the dashboard; the 1641 form takes the dashboard's place while it is open and gives the focus back 1642 when it closes. */ 1643 /* T385a's language fix, kept (Astra's code review of T385a, run 1 row 5 and run 2 row 1): the platform's switch writes 1644 the choice to localStorage (bp.lang.v1) and drops ?lang from the reload; where that write fails (storage refused, 1645 or readable but not writable) the choice would be lost on the reload and an older stored choice would win. Only 1646 then does this page answer the press itself: it clears the older choice where it can and reloads with ?lang set, 1647 which the platform's order reads next. Where the write works the platform's switch runs untouched. */ 1648 function choiceKept() { 1649 try { 1650 window.localStorage.setItem("bp.labres.probe", "1"); 1651 window.localStorage.removeItem("bp.labres.probe"); 1652 return true; 1653 } catch (e) { return false; } 1654 } 1655 function keepLanguageChoice() { 1656 document.addEventListener("click", function (e) { 1657 var b = e.target && e.target.closest ? e.target.closest("[data-lang-switch] [data-lang]") : null; 1658 if (!b || choiceKept()) return; 1659 var l = b.getAttribute("data-lang"); 1660 if (l !== "en" && l !== "nl") return; 1661 e.preventDefault(); 1662 e.stopImmediatePropagation(); 1663 try { window.localStorage.removeItem("bp.lang.v1"); } catch (err) { /* nothing older can be cleared */ } 1664 var u = new URL(window.location.href); 1665 u.searchParams.set("lang", l); 1666 window.location.href = u.toString(); 1667 }, true); 1668 } 1669 1670 /* T566: the member's two pages, `view` "results" (lab-results.html) or "upload" (lab-upload.html). The medical records 1671 part left this page: every document, a kept lab report included, has one place, Medische documenten on the medical 1672 record page (T536). */ 1673 function memberPage(view) { 1674 var BPM = window.BPM; 1675 keepLanguageChoice(); 1676 BPM.boot(function (who, main) { 1677 var screen = main.querySelector("[data-screen]") || main; 1678 screen.classList.add("cx-page"); 1679 // T519: at the very end of the results page one quiet link to the lab tests page (the only commercial thing on 1680 // this page: a link to another page, said as one) 1681 screen.innerHTML = "<div data-lab-dash></div><div data-lab-formhost hidden></div><div data-lab-order></div>"; 1682 var dashEl = screen.querySelector("[data-lab-dash]"), formEl = screen.querySelector("[data-lab-formhost]"); 1683 var orderEl = screen.querySelector("[data-lab-order]"); 1684 var dash, imp = null, review = null, upEl = null, coreEl = null; 1685 /* a review read and not yet saved, or a file on its way: the reader's box is not at rest */ 1686 function reviewActive() { return !!review && !!coreEl && !coreEl.querySelector('[data-sec="entry"] .cx-drop:not(.is-busy)'); } 1687 function keepUp() { return !!(imp && imp.active()) || reviewActive(); } 1688 function upVisibility() { 1689 var d = dash && dash.data(); 1690 if (!upEl || !upEl.__mounted || view !== "results" || !d || !hasData(d)) return; 1691 upEl.hidden = !keepUp(); 1692 } 1693 if (view === "results" && ((who && who.permissions) || []).indexOf("lab_order.read_own") >= 0) { 1694 orderEl.innerHTML = '<section class="cx-sec labres-order" data-sec="order"><p class="labres-order__line">
1694' + 1695 '<a class="btn btn--ghost labres-order__link" href="/app/lab/panels.html" data-lab-order-link aria-describedby="lab-order-where">' + 1696 esc(t("labres.order.link")) + ' <span aria-hidden="true">→</span></a>' + 1697 '<span class="cx-small" id="lab-order-where">' + esc(t("labres.order.where")) + "</span></p></section>"; 1698 } 1699 function back(reload, words) { 1700 formEl.hidden = true; formEl.innerHTML = ""; dashEl.hidden = false; orderEl.hidden = false; 1701 return (reload ? dash.reload().then(function () { return imp ? imp.reload() : null; }) : Promise.resolve()).then(function () { 1702 var h = dashEl.querySelector(".cx-head h1"); 1703 if (words) dash.said(words); 1704 if (h) { h.tabIndex = -1; h.focus(); } 1705 }); 1706 } 1707 /* a report typed from a file of the import goes back to the sitting (its other files and the keep choice wait 1708 there): on the results page, whose box gives way to the results once there is data, by the upload page */ 1709 function toSitting() { 1710 var h = screen.querySelector("#lab-import h2, #lab-import .cx-sec__title"); 1711 if (h) { h.tabIndex = -1; h.focus(); try { h.scrollIntoView({ block: "start" }); } catch (e) { /* nothing to scroll */ } } 1712 } 1713 function openForm(panel, data, pre, source) { 1714 dashEl.hidden = true; formEl.hidden = false; orderEl.hidden = true; 1715 window.BP_LAB_FORM.open(formEl, { 1716 api: BPM.api, viewer: "person", buttons: "member", data: data, panel: panel, pre: pre || null, confirm: dash.confirm, 1717 source: source || null, 1718 onDone: function (out) { 1719 var words = t(out && out.report_cleanup && out.report_cleanup.failed > 0 ? "labres.saved.reportPending" : "labres.saved"); 1720 back(true, words).then(function () { if (source) toSitting(); }); 1721 }, 1722 onCancel: function () { back(false).then(function () { if (source) toSitting(); }); } 1723 }); 1724 try { window.scrollTo(0, 0); } catch (e) { /* nothing to scroll */ } 1725 } 1726 /* The one upload box (Jesse: "The core box is a function to upload a PDF that gets read automatically"), by the 1727 reader's switch, so a customer is never promised what the software does not do: the reader on, the report 1728 reader's box (one PDF, read, every value checked by the person before it is saved) and, small under it, the 1729 import for several files or other medical documents; the reader off (production until T465), the import's box 1730 (many files, each lab report's values typed with its file beside them). Under it, small, the values by hand, 1731 and where the kept files are. */ 1732 function mountUpload(up, reader) { 1733 var lead = { title: t("labres.up.title"), says: t(reader ? "labres.up.says.reader" : "labres.up.says.off"), 1734 failed: { title: t("labres.failed.title"), says: t("labres.failed.says"), retry: t("labres.retry") } }; 1735 up.innerHTML = '<div data-up-core></div>' + 1736 '<p class="labres-up__line">
1736' + esc(t("labres.up.manual")) + ' <button class="labres-up__link" type="button" data-up="manual">' + 1737 esc(t("labres.up.manual.link")) + "</button></p>" + 1738 (reader ? '<p class="labres-up__line">' + esc(t("labres.up.more")) + ' <button class="labres-up__link" type="button" data-up="more" ' + 1739 'aria-expanded="false" aria-controls="lab-up-more">' + esc(t("labres.up.more.link")) + '</button></p><div data-up-more id="lab-up-more" hidden></div>' : "") + 1740 '<p class="labres-up__line labres-up__where">' + esc(t("labres.up.where")) + ' <a href="/app/analysis/medical-record.html#hr-documents">' + 1741 esc(t("labres.up.where.link")) + "</a></p>"; 1742 var core = up.querySelector("[data-up-core]"), more = up.querySelector("[data-up-more]"); 1743 upEl = up; coreEl = core; 1744 function showMore(on) { 1745 if (!more) return; 1746 more.hidden = !on; 1747 var b = up.querySelector('[data-up="more"]'); 1748 if (b) b.setAttribute("aria-expanded", on ? "true" : "false"); 1749 } 1750 var impHost = reader ? more : core; 1751 var jumped = false; 1752 if (window.BP_LAB_IMPORT) imp = window.BP_LAB_IMPORT.mount(impHost, { 1753 api: BPM.api, confirm: dash.confirm, lead: reader ? null : lead, failed: lead.failed, 1754 panels: function () { var d = dash.data(); return d ? d.panels : []; }, 1755 // a sitting still open (or files chosen) is never hidden behind the small line 1756 onState: function (st) { 1757 if (reader && (st.open || st.chosen || st.pending)) showMore(true); 1758 upVisibility(); 1759 // arriving from a report typed on the results page: the sitting takes the focus once 1760 if (st.open && !jumped && view === "upload" && window.location.hash === "#lab-import") { jumped = true; setTimeout(toSitting, 0); } 1761 }, 1762 onType: function (src) { var d = dash.data(); if (d) openForm(null, d, null, src); }, 1763 onDone: function () { dash.reload(); } 1764 }); 1765 if (reader && window.BP_LAB_REVIEW) review = window.BP_LAB_REVIEW.mount(core, { 1766 api: BPM.api, viewer: "person", buttons: "member", reader: true, lead: lead, 1767 data: function () { return dash.data(); }, confirm: dash.confirm, 1768 onManual: function (pre) { openForm(null, dash.data(), pre || null); }, 1769 onSaved: function (words) { return dash.reload().then(function () { dash.said(words || t("labres.saved")); upVisibility(); }); } 1770 }); 1771 up.addEventListener("click", function (e) { 1772 var b = e.target.closest("[data-up]"); 1773 if (!b || !up.contains(b)) return; 1774 if (b.getAttribute("data-up") === "manual") { var d = dash.data(); if (d) openForm(null, d); return; } 1775 if (b.getAttribute("data-up") === "more") { 1776 showMore(more.hidden); 1777 if (!more.hidden) { var f = more.querySelector("button, [tabindex]"); if (f) f.focus(); } 1778 } 1779 }); 1780 } 1781 /* the page's title and its loading state while the capability is asked; a capability that never answers 1782 leaves the reader off instead of a blank page */ 1783 dashEl.innerHTML = UI.head(t(view === "upload" ? "labres.up.page.title" : "labres.title"), esc(t(view === "upload" ? "labres.up.page.purpose" : "labres.purpose")), "", "") + 1784 '<span class="cx-sr">' + esc(t("labres.loading")) + "</span>" + UI.loading(2); 1785 Promise.race([ 1786 BPM.api("lab-manual-capability", {}).catch(function () { return {}; }), 1787 new Promise(function (done) { setTimeout(function () { done({}); }, 6000); }) 1788 ]).then(function (cap) { 1789 var reader = !!(cap && cap.reader); 1790 dash = mount(dashEl, { 1791 api: BPM.api, viewer: "person", buttons: "member", reader: reader, member: view, 1792 mountUpload: function (up) { mountUpload(up, reader); }, 1793 keepUp: keepUp, 1794 onForm: openForm, 1795 onLoaded: function (d) { 1796 if (review) review.data(d); 1797 // a sitting of the import still open while the results show: one quiet line to continue it 1798 var note = dashEl.querySelector("[data-lab-note]"); 1799 if (view !== "results" || !hasData(d) || !note) return; 1800 if (upEl && upEl.__mounted && !upEl.hidden) { note.innerHTML = ""; return; } 1801 note.innerHTML = '<div data-lab-waiting-host></div><div data-lab-import-note></div>'; 1802 readingCard(note.querySelector("[data-lab-waiting-host]"), reader); 1803 BPM.api("lab-import-state", {}).then(function (st) { 1804 var pend = (st && st.pending_delete) || 0; 1805 var pw = st && st.words && st.words[locale()] && st.words[locale()]["recent.pen
1805ding"]; 1806 var slot = note.querySelector("[data-lab-import-note]"); 1807 if (!slot) return; 1808 slot.innerHTML = (st && st.import ? '<p class="cx-small labres-up__open">' + esc(t("labres.up.open")) + ' <a href="' + UPLOAD_PAGE + '">' + 1809 esc(t("labres.up.open.link")) + "</a></p>" : "") + 1810 (pend && pw ? '<p class="cx-small labres-up__open" data-lab-pending>' + esc(pw.split("{n}").join(String(pend))) + "</p>" : ""); 1811 }).catch(function () { /* the note is a convenience; the upload page shows the sitting itself */ }); 1812 } 1813 }); 1814 }); 1815 /* T465 Addendum 6: once the results fill the page, the upload box is no longer on it, so a reading the person 1816 left (the tab closed while it was read, or read and never checked) had no door (on 2026-10-09 a finished 1817 reading was nowhere to be found afterwards). The results page now asks for the account's own readings still 1818 open and says so above the results: one card per open reading, with its day and time and the number of rows 1819 read (never a value), a button to the upload page naming that reading (#reading=<job id>, whose box resumes 1820 it as the review), and Delete (the reading and its file gone, nothing saved; a file whose removal did not 1821 finish is said to be pending). While a report is still being read the card says that instead, with the same 1822 door, and the cards are asked for again every ten seconds while one is still being read and the page is 1823 visible, so a reading that finishes while the page is open gains its row count without a reload. */ 1824 function when(iso) { 1825 var d = new Date(iso); 1826 if (isNaN(d.getTime())) return ""; 1827 var time = ""; 1828 try { time = d.toLocaleTimeString(locale() === "nl" ? "nl-NL" : "en-GB", { hour: "2-digit", minute: "2-digit" }); } catch (e) { time = ""; } 1829 return day(iso) + (time ? ", " + time : ""); 1830 } 1831 var cardTimer = null; 1832 function readingCard(slot, reader) { 1833 if (!slot || !reader) return; 1834 if (cardTimer) { clearTimeout(cardTimer); cardTimer = null; } 1835 BPM.api("lab-manual-read-list", {}).then(function (r) { 1836 if (!slot.isConnected) return; 1837 // every reading still open, newest first (a person who uploaded twice sees both), at most five 1838 var open = ((r && r.readings) || []).filter(function (x) { return x.state === "read" || x.state === "reading" || x.state === "queued"; }).slice(0, 5); 1839 slot.innerHTML = open.map(function (x) { 1840 var waits = x.state === "read", n = x.rows == null ? null : Number(x.rows); 1841 // a count the server could not give is left unsaid, never shown as zero 1842 var says = waits ? t(n == null ? "labres.waiting.says.norows" : n === 1 ? "labres.waiting.says.one" : "labres.waiting.says", { when: when(x.created_at), n: n == null ? "" : n }) 1843 : t("labres.inprogress.says", { when: when(x.created_at) }); 1844 return '<section class="cx-sec labres-waiting" data-lab-waiting data-job="' + esc(x.job_id) + '" data-state="' + esc(x.state) + '">' + 1845 '<div class="cx-sec__head"><div class="cx-sec__text"><h2 class="cx-sec__title">' + esc(t(waits ? "labres.waiting.title" : "labres.inprogress.title")) + "</h2>" + 1846 '<p class="cx-sec__purpose">' + esc(says) + "</p></div></div>" + 1847 '<div class="cx-actions"><a class="btn ' + (waits ? "btn--primary" : "btn--ghost") + '" href="' + UPLOAD_PAGE + "#reading=" + esc(x.job_id) + '" data-lab-waiting-open>' + 1848 esc(t(waits ? "labres.waiting.open" : "labres.inprogress.open")) + "</a>" + 1849 UI.button(t("labres.waiting.delete"), "quiet", 'data-lab-waiting-delete') + "</div>" + 1850 '<div data-lab-waiting-said role="status" aria-live="polite"></div></section>'; 1851 }).join(""); 1852 // a reading still being read: asked for again in ten seconds while the page is visible (a hidden page waits) 1853 if (open.some(function (x) { return x.state !== "read"; })) { 1854 var again = function () { 1855 cardTimer = null; 1856 if (document.hidden) { cardTimer = setTimeout(again, 10000); return; } 1857 readingCard(slot, reader); 1858 }; 1859 cardTimer = setTimeout(again, 10000); 1860 } 1861 }).catch(function () { slot.innerHTML = ""; /* the card is a door; the upload page's box resumes the reading itself */ }); 1862 } 1863 dashEl.addEventListener("click", function (e) { 1864 var b = e.target.closest("[data-lab-waiting-delete]"); 1865 if (!b || !dashEl.contains(b)) return; 1866 var card = b.closest("[data-lab-waiting]"), job = card && card.getAttribute("data-job"); 1867 if (!job || !dash) return; 1868 dash.confirm({ title: t("labres.waiting.delete.title"), body: t("labres.waiting.delete.body"), 1869 keep: t("labres.waiting.delete.keep"), confirm: t("labres.waiting.delete.confirm") }).then(function (yes) { 1870 if (!yes) return; 1871 b.disabled = true; 1872 BPM.api("lab-manual-read-cancel", { method: "POST", body: { job_id: job } }).then(function (out) { 1873 card.remove(); 1874 // the file's removal that did not finish is said as pending, never as gone (the sweep finishes it) 1875 var pending = !!(out && out.report_cleanup && out.report_cleanup.failed > 0); 1876 dash.said(t(pending ? "labres.waiting.deleted.pending" : "labres.waiting.deleted")); 1877 var h = dashEl.querySelector(".cx-head h1"); 1878 if (h) { h.tabIndex = -1; h.focus(); } 1879 }).catch(function (err) { 1880 b.disabled = false;
1881 var code = ((err && err.data) || {}).error; 1882 var said = card.querySelector("[data-lab-waiting-said]"); 1883 // a reading saved or stopped elsewhere since the card was drawn is simply gone from here; the focus goes to the head 1884 if (code === "no_such_job" || code === "job_confirmed") { 1885 card.remove(); 1886 var h2 = dashEl.querySelector(".cx-head h1"); 1887 if (h2) { h2.tabIndex = -1; h2.focus(); } 1888 return; 1889 } 1890 if (said) said.innerHTML = UI.said(errText(code), "bad"); 1891 }); 1892 }); 1893 }); 1894 }); 1895 } 1896 var LAB_PAGE = document.body && document.body.getAttribute("data-lab-page"); 1897 if ((LAB_PAGE === "member" || LAB_PAGE === "upload") && window.BPM) memberPage(LAB_PAGE === "upload" ? "upload" : "results"); 1898 1899 window.BP_LAB_DASH = { mount: mount, day: day, fmt: fmt, asPrinted: asPrinted, precisionIn: DASH.precisionIn, errText: errText, 1900 words: DASH.WORDS, state: DASH.state, cardOf: DASH.cardOf, example: example, exampleCard: exampleCard }; 1901})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.