import type { DoseCounts, SharedDoses, SharedLab, Tier } from "../api/types"; import { formatDate } from "../flow"; import { tiers } from "./format"; // ---------------------------------------------------------------- lab ranges and values /** A range on the record as numbers. Either end may be open. */ export type LabRange = { low: number | null; high: number | null }; const NUM = String.raw`-?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?|-?\.\d+`; // A unit or a note may follow ("mmol/L", "(adult)"); another number or a slash may not. const TAIL = String.raw`(?:\s*[a-zµμ%(].*)?`; const re = (body: string) => new RegExp(`^${body}${TAIL}$`); const BETWEEN = re(`(${NUM})\\s*(?:-|to)\\s*(${NUM})`); const UPPER = re(`(?:<=?|less than|lower than|below|under|up to|max(?:imum)?:?)\\s*(${NUM})`); const UPPER_AFTER = re(`(${NUM})\\s*(?:or|and) (?:less|lower|below|under)`); const LOWER = re(`(?:>=?|more than|greater than|above|over|at least|min(?:imum)?:?)\\s*(${NUM})`); const LOWER_AFTER = re(`(${NUM})\\s*(?:\\+|(?:or|and) (?:more|higher|above|over|greater))`); // The backend writes "3.5 to" or "to 5.0" when the record has one end only. const HALF_LOW = new RegExp(`^(${NUM})\\s*to$`); const HALF_HIGH = new RegExp(`^to\\s*(${NUM})$`); const VALUE = new RegExp(`^(${NUM})(?:\\s*[a-zµμ%].*)?$`); const normalise = (text: string) => text.trim().toLowerCase().replace(/[−–—‒]/g, "-").replace(/≤|==").replace(/\s+/g, " "); const toNumber = (s: string) => { const n = Number(s.replace(/,/g, "")); return Number.isFinite(n) ? n : null; }; /** "3.5-5.0", "3.5 – 5.0", "< 5.7", ">60", "60+", "Above 60" as numbers; null for anything else. */ export function parseRange(text: string | null | undefined): LabRange | null { const t = normalise(text ?? ""); if (!t) return null; const m = BETWEEN.exec(t); if (m) { const [low, high] = [toNumber(m[1]), toNumber(m[2])]; return low !== null && high !== null && low <= high ? { low, high } : null; } const one = (pattern: RegExp, end: "low" | "high"): LabRange | null => { const hit = pattern.exec(t); const n = hit ? toNumber(hit[1]) : null; return n === null ? null : end === "low" ? { low: n, high: null } : { low: null, high: n }; }; return one(UPPER, "high") ?? one(UPPER_AFTER, "high") ?? one(HALF_HIGH, "high") ?? one(LOWER, "low") ?? one(LOWER_AFTER, "low") ?? one(HALF_LOW, "low"); } /** The range as the record words it, with the backend's one-ended "60 to" in plain words. */ export function rangeLabel(text: string): string { const t = text.trim(); const low = HALF_LOW.exec(normalise(t)); if (low) return `${low[1]} or above`; const high = HALF_HIGH.exec(normalise(t)); return high ? `Up to ${high[1]}` : t; } /** A result's value as a number ("38", "7.9", "1,200"); "<0.5", "Positive" and "148/92" are not. */ export function parseLabValue(value: string | null | undefined): number | null { const m = VALUE.exec(normalise(value ?? "")); return m ? toNumber(m[1]) : null; } // ---------------------------------------------------------------- lab series export type LabPoint = { lab: SharedLab; value: number; t: number }; export type LabSeries = { key: string; name: string; unit: string | null; latest: SharedLab; /** Every result for this test, newest first. */ results: SharedLab[]; /** Numbers in the latest result's unit, oldest first, the newest one per day. */ points: LabPoint[]; range: LabRange | null; /** Two or more days with a number, the latest result among them. */ trend: boolean; }; const dayMs = (iso: string) => { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso); return m ? Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])) : NaN; }; const nameKey = (name: string) => name.trim().toLowerCase().replace(/\s+/g, " "); const unitKey = (unit: string | null) => (unit ?? "").trim().toLowerCase().replace(/\s+/g, ""); const newestFirst = (a: SharedLab, b: SharedLab) => b.date.localeCompare(a.date) || b.addedAt.localeCompare(a.addedAt); /** One series per test name, newest test first. */ export function labSeries(labs: SharedLab[]): LabSeries[] { const groups = new Map(); for (const lab of labs) groups.set(nameKey(lab.name), [...(groups.get(nameKey(lab.name)) ?? []), lab]); const out = [...groups].map(([key, list]): LabSeries => { const results = [...list].sort(newestFirst); const latest = results[0]; const byDay = new Map(); for (const lab of results) { const value = parseLabValue(lab.value); const t = dayMs(lab.date); if (value === null || Number.isNaN(t) || unitKey(lab.unit) !== unitKey(latest.unit) || byDay.has(lab.date)) continue; byDay.set(lab.date, { lab, value, t }); } const points = [...byDay.values()].sort((a, b) => a.t - b.t); const trend = points.length >= 2 && points[points.length - 1].lab.id === latest.id; return { key, name: latest.name.trim(), unit: latest.unit?.trim() || null, latest, results, points, range: parseRange(latest.range), trend }; }); return out.sort((a, b) => b.latest.date.localeCompare(a.latest.date)); } const isFlagged = (lab: SharedLab) => lab.flag === "high" || lab.flag === "low"; const valueText = (lab: SharedLab) => lab.value.trim(); const decimals = (...texts: string[]) => Math.max(0, ...texts.map((t) => /\.(\d+)/.exec(t)?.[1].length ?? 0)); /** "3 Apr", with the year when it differs from the year of `other`. */ export function shortDate(iso: string, other?: string): string { const full = formatDate(iso.slice(0, 10)); return other && other.slice(0, 4) !== iso.slice(0, 4) ? full : full.replace(/ \d{4}$/, ""); } /** The answer line for the shared labs: what the record flags on each test's latest result. */ export function labsAnswer(series: LabSeries[]): string { if (!series.length) return "No lab results shared."; const flagged = series.filter((s) => isFlagged(s.latest)); const tests = `${series.length} ${series.length === 1 ? "test" : "tests"}`; if (!flagged.length) return `${tests}, none flagged high or low on the latest result.`; return `${flagged.length} of ${tests} flagged on the latest result: ${flagged.map((s) => `${s.name} ${s.latest.flag}`).join(", ")}.`; } /** The change from the result before the latest: "Down 9 since 14 Aug". */ export function labChange(s: LabSeries): { direction: "up" | "down" | "same"; words: string } | null { if (!s.trend) return null; const [before, last] = s.points.slice(-2); const diff = last.value - before.value; const since = `since ${shortDate(before.lab.date, last.lab.date)}`; const amount = Math.abs(diff).toFixed(decimals(valueText(before.lab), valueText(last.lab))); if (Number(amount) === 0) return { direction: "same", words: `No change ${since}` }; return { direction: diff > 0 ? "up" : "down", words: `${diff > 0 ? "Up" : "Down"} ${amount} ${since}` }; } /** The trend in words, for the chart's label. Flags are what the record says, nothing more. */ export function trendWords(s: LabSeries): string { const pts = s.points; const unit = s.unit ? ` ${s.unit}` : ""; if (pts.length < 2) return `${s.name}: ${valueText(s.latest)}${unit} on ${formatDate(s.latest.date)}.`; const [first, last] = [pts[0], pts[pts.length - 1]]; const low = pts.reduce((a, b) => (b.value < a.value ? b : a)); const high = pts.reduce((a, b) => (b.value > a.value ? b : a)); const move = last.value > first.value ? `rose from ${valueText(first.lab)} to ${valueText(last.lab)}${unit}` : last.value < first.value ? `fell from ${valueText(first.lab)} to ${valueText(last.lab)}${unit}` : `was ${valueText(last.lab)}${unit} at the first and the latest result`; const parts = [ `${s.name}, ${pts.length} results from ${formatDate(first.lab.date)} to ${formatDate(last.lab.date)}: it ${move}.`, `Lowest ${valueText(low.lab)} on ${formatDate(low.lab.date)}, highest ${valueText(high.lab)} on ${formatDate(high.lab.date)}.`, ]; if (s.latest.range) parts.push(`Range on the record: ${rangeLabel(s.latest.range)}.`); const flagged = pts.filter((p) => isFlagged(p.lab)).length; if (flagged) parts.push(`${flagged} of ${pts.length} flagged high or low on the record${isFlagged(last.lab) ? `, the latest ${last.lab.flag}` : ""}.`); return parts.join(" "); } // ---------------------------------------------------------------- chart geometry (a 0 to 100 box) export type PlotPoint = LabPoint & { x: number; y: number }; export type LabPlot = { points: PlotPoint[]; line: string; area: string; /** The range band, top and bottom in the box; open ends run to the edge. */ band: { top: number; bottom: number } | null; ticks: { value: number; label: string; y: number }[]; }; const r2 = (n: number) => Math.round(n * 100) / 100; const clamp = (n: number) => Math.min(100, Math.max(0, n)); /** "Nice" steps (1, 2, 2.5, 5 times a power of ten) between lo and hi. */ export function niceTicks(lo: number, hi: number, count = 4): number[] { if (!(hi > lo)) return [lo]; const raw = (hi - lo) / count; const mag = 10 ** Math.floor(Math.log10(raw)); const n = raw / mag; const step = (n <= 1 ? 1 : n <= 2 ? 2 : n <= 2.5 ? 2.5 : n <= 5 ? 5 : 10) * mag; const out: number[] = []; for (let v = Math.ceil(lo / step) * step; v <= hi + step * 1e-9; v += step) out.push(Number(v.toFixed(10))); return out; } /** A smooth line that never overshoots its points (monotone cubic), as an SVG path. */ export function smoothPath(pts: { x: number; y: number }[]): string { if (pts.length < 2) return ""; const n = pts.length; const m = pts.slice(0, -1).map((p, i) => (pts[i + 1].y - p.y) / (pts[i + 1].x - p.x || 1)); const t = pts.map((_, i) => (i === 0 ? m[0] : i === n - 1 ? m[n - 2] : m[i - 1] * m[i] <= 0 ? 0 : (m[i - 1] + m[i]) / 2)); for (let i = 0; i < n - 1; i++) { if (m[i] === 0) { t[i] = t[i + 1] = 0; continue; } const [a, b] = [t[i] / m[i], t[i + 1] / m[i]]; const s = a * a + b * b; if (s > 9) [t[i], t[i + 1]] = [(3 / Math.sqrt(s)) * a * m[i], (3 / Math.sqrt(s)) * b * m[i]]; } let d = `M${r2(pts[0].x)} ${r2(pts[0].y)}`; for (let i = 0; i < n - 1; i++) { const [p, q] = [pts[i], pts[i + 1]]; const dx = (q.x - p.x) / 3; d += ` C${r2(p.x + dx)} ${r2(p.y + t[i] * dx)} ${r2(q.x - dx)} ${r2(q.y - t[i + 1] * dx)} ${r2(q.x)} ${r2(q.y)}`; } return d; } /** Place a series in a 0 to 100 box: x by date, y by value, with the range's ends inside the scale. */ export function plotLab(s: Pick, insetX = 3, pad = 0.14): LabPlot { const pts = s.points; if (!pts.length) return { points: [], line: "", area: "", band: null, ticks: [] }; const ends = [s.range?.low, s.range?.high].filter((v): v is number => v !== null && v !== undefined); const values = [...pts.map((p) => p.value), ...ends]; let [lo, hi] = [Math.min(...values), Math.max(...values)]; if (lo === hi) [lo, hi] = [lo - (Math.abs(lo) * 0.1 || 1), hi + (Math.abs(hi) * 0.1 || 1)]; [lo, hi] = [lo - (hi - lo) * pad, hi + (hi - lo) * pad]; const [t0, t1] = [pts[0].t, pts[pts.length - 1].t]; const x = (t: number) => (t1 === t0 ? 50 : insetX + ((t - t0) / (t1 - t0)) * (100 - 2 * insetX)); const y = (v: number) => ((hi - v) / (hi - lo)) * 100; const points = pts.map((p) => ({ ...p, x: r2(x(p.t)), y: r2(y(p.value)) })); const line = smoothPath(points); const area = line ? `${line} L${points[points.length - 1].x} 100 L${points[0].x} 100 Z` : ""; const band = s.range ? { top: r2(clamp(y(s.range.high ?? hi))), bottom: r2(clamp(y(s.range.low ?? lo))) } : null; const ticks = niceTicks(lo, hi, 3).map((v) => ({ value: v, label: String(Number(v.toFixed(6))), y: r2(y(v)) })); return { points, line, area, band, ticks }; } // ---------------------------------------------------------------- doses export type DoseKind = "onTime" | "late" | "skipped" | "missed" | "rest"; export const doseKinds: { key: DoseKind; label: string }[] = [ { key: "onTime", label: "On time" }, { key: "late", label: "Late" }, { key: "skipped", label: "Skipped" }, { key: "missed", label: "Missed" }, { key: "rest", label: "Not yet due or not logged" }, ]; export type DoseStack = { segments: { key: DoseKind; count: number; share: number }[]; expected: number; logged: number; taken: number; /** More doses logged than were due: the bar is out of what was logged. */ over: boolean; }; const count = (n: number) => (Number.isFinite(n) && n > 0 ? Math.floor(n) : 0); /** The stacked bar for one medicine: logged doses, then what is not yet due or not logged, out of what was due. */ export function doseStack(c: DoseCounts): DoseStack { const parts: [DoseKind, number][] = [ ["onTime", count(c.onTime)], ["late", count(c.late)], ["skipped", count(c.skipped)], ["missed", count(c.missed)], ]; const logged = parts.reduce((sum, [, n]) => sum + n, 0); const expected = count(c.expected); const total = Math.max(expected, logged); const segments = [...parts, ["rest", Math.max(0, expected - logged)] as [DoseKind, number]] .filter(([, n]) => n > 0) .map(([key, n]) => ({ key, count: n, share: n / total })); return { segments, expected, logged, taken: parts[0][1] + parts[1][1], over: logged > expected }; } export const sumDoses = (rows: DoseCounts[]): DoseCounts => rows.reduce((a, r) => ({ onTime: a.onTime + count(r.onTime), late: a.late + count(r.late), skipped: a.skipped + count(r.skipped), missed: a.missed + count(r.missed), expected: a.expected + count(r.expected) }), { onTime: 0, late: 0, skipped: 0, missed: 0, expected: 0, }); /** Whole per cent taken of due, rounded down so 100 means every dose; null when none were due. */ export function dosePercent(taken: number, expected: number): number | null { return expected > 0 ? Math.min(100, Math.floor((count(taken) / expected) * 100)) : null; } export type DosePeriod = "week" | "last30"; /** The figure and sentence for a period: this week from the backend's total, 30 days from the medicines. */ export function doseHeadline(d: Pick, period: DosePeriod) { const all = sumDoses(d.perMedicine.map((m) => m.last30)); const { taken, expected, onTime } = period === "week" ? d.week : { taken: all.onTime + all.late, expected: all.expected, onTime: all.onTime }; const when = period === "week" ? "this week" : "in the last 30 days"; const percent = dosePercent(taken, expected); const sentence = !expected ? `No doses were due ${when}.` : `${taken} of ${expected} doses taken ${when}${taken ? (onTime === taken ? ", all on time" : `, ${onTime} on time`) : ""}.`; return { percent, taken, expected, when, sentence }; } /** "12 on time · 0 late · 2 skipped · 0 missed, of 14": the bar in words. */ export const doseLine = (c: DoseCounts) => `${c.onTime} on time · ${c.late} late · ${c.skipped} skipped · ${c.missed} missed, of ${c.expected}`; // ---------------------------------------------------------------- findings mix export const tierOrder: Tier[] = ["get_help_now", "today", "this_week", "good_to_know"]; const tierKey = (t: string): Tier => ((tierOrder as string[]).includes(t) ? (t as Tier) : "good_to_know"); /** How a check's findings split across the tiers, most pressing first. Every tier is listed, with its count. */ export function findingsMix(findings: { tier: string }[]) { const total = findings.length; return { total, tiers: tierOrder.map((tier) => { const n = findings.filter((f) => tierKey(f.tier) === tier).length; return { tier, label: tiers[tier].label, count: n, share: total ? n / total : 0 }; }), }; }