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(/≤|=</g, "<=").replace(/≥|=>/g, ">=").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<string, SharedLab[]>();
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<string, LabPoint>();
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<LabSeries, "points" | "range">, 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<SharedDoses, "week" | "perMedicine">, 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 };
}),
};
}