PageSourceSearch

https://app.bioprofile.com/app/js/lab-results.js

js bioprofile.com collected 2026-10-10 00:47:06 UTC 128,736 bytes, 1,901 lines download raw bytes

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">&larr;</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">&rarr;</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.