diff --git a/backend/app/routers/observations.py b/backend/app/routers/observations.py index 1e4d2f9..f46170b 100644 --- a/backend/app/routers/observations.py +++ b/backend/app/routers/observations.py @@ -523,6 +523,9 @@ def _critical_out(loinc: str, o: Observation) -> CriticalOut | None: return CriticalOut(**vars(crit)) if crit else None +ALL_KEY = "all" + + @router.get("/{patient_id}/panel-trends", response_model=PanelTrends) async def panel_trends( patient_id: str, @@ -551,10 +554,15 @@ async def panel_trends( that happened to convert is the silent-omission failure the whole pipeline is built to avoid. """ - key, label = next( - ((k, lbl) for k, lbl, _ in PANELS if k == panel), - (OTHER_KEY, OTHER_LABEL) if panel == OTHER_KEY else (None, None), - ) + if panel == ALL_KEY: + # Every analyte on the one time axis -- the river view. Same tracks, + # same `_charted` rules, only the panel filter is dropped. + key, label = ALL_KEY, "All tests" + else: + key, label = next( + ((k, lbl) for k, lbl, _ in PANELS if k == panel), + (OTHER_KEY, OTHER_LABEL) if panel == OTHER_KEY else (None, None), + ) if key is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "No such panel") @@ -565,7 +573,7 @@ async def panel_trends( by_code: dict[str, list[Observation]] = {} for o in rows: - if panel_for(o.loinc_code)[0] == key: + if key == ALL_KEY or panel_for(o.loinc_code)[0] == key: by_code.setdefault(o.loinc_code, []).append(o) tracks = [] diff --git a/backend/tests/test_panel_trends.py b/backend/tests/test_panel_trends.py index 04f0310..f15b8e5 100644 --- a/backend/tests/test_panel_trends.py +++ b/backend/tests/test_panel_trends.py @@ -126,3 +126,23 @@ async def test_panel_trends_needs_a_grant(client, account): r = await client.get(f"/api/observations/{pid}/panel-trends?panel=cbc", headers=h2) assert r.status_code == 404 # 404, never 403 — see access.py + + +async def test_all_returns_every_analyte_across_panels(client, account): + """The river view: every analyte on one axis, through the same `_charted` + rules as each panel, so the two can't disagree about a point.""" + h, pid = await loaded(client, account) + everything = (await client.get(f"/api/observations/{pid}/panel-trends?panel=all", + headers=h)).json() + assert everything["panel"] == "all" + assert everything["panel_label"] == "All tests" + + by_panel = set() + for panel in ("metabolic", "cbc", "lipids"): + d = (await client.get(f"/api/observations/{pid}/panel-trends?panel={panel}", + headers=h)).json() + by_panel |= {t["loinc_code"] for t in d["tracks"]} + for t in d["tracks"]: + same = next(x for x in everything["tracks"] if x["loinc_code"] == t["loinc_code"]) + assert same["points"] == t["points"] + assert by_panel <= {t["loinc_code"] for t in everything["tracks"]} diff --git a/frontend/src/screens/RiverView.css b/frontend/src/screens/RiverView.css new file mode 100644 index 0000000..7037386 --- /dev/null +++ b/frontend/src/screens/RiverView.css @@ -0,0 +1,88 @@ +/* Every test on one timeline. Same grammar as the panel small multiples + (PanelTrends.css): one x scale, nothing shared between rows but time. */ + +.river { padding: var(--s2) 0 var(--s5); } + +.river__note, +.river__pairs-note { + font-size: var(--text-caption); + color: var(--ink-faint); + max-width: 62ch; + margin-bottom: var(--s4); +} + +.river__scrubber { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(120px, 2fr) 5rem; + gap: var(--s4); + align-items: center; + margin-bottom: var(--s3); +} +.river__scrubber-label { font-size: var(--text-small); color: var(--ink-muted); } +.river__scrubber input { width: 100%; accent-color: var(--ink); } + +.river__rows { list-style: none; margin: 0; padding: 0; } + +.river__row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(120px, 2fr) 5rem; + align-items: center; + gap: var(--s4); + padding-block: var(--s1); + border-bottom: 1px solid var(--rule); + transition: background 120ms ease; +} +.river__row--hl { background: color-mix(in srgb, var(--ink) 6%, transparent); } + +.river__name { + font-size: var(--text-small); + color: var(--ink); + min-width: 0; + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--s2); +} +.river__unit { font-family: var(--font-mono); font-size: var(--text-caption); color: var(--ink-faint); } + +.river__plot { width: 100%; height: 54px; display: block; overflow: visible; } +.river__band { fill: color-mix(in srgb, var(--ink) 7%, transparent); } +.river__ribbon { fill: none; stroke-width: 5; stroke-linecap: round; stroke-linejoin: round; } +.river__scrub { stroke: var(--ink); stroke-width: 1; stroke-dasharray: 2 2; } + +.river__value { text-align: right; font-size: var(--text-small); white-space: nowrap; } + +.river__axis { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(120px, 2fr) 5rem; + gap: var(--s4); + font-size: var(--text-caption); + color: var(--ink-faint); + padding-top: var(--s2); +} +.river__axis-dates { display: flex; justify-content: space-between; } + +.river__pairs { margin-top: var(--s5); } +.river__pairs-h { font-size: var(--text-small); margin-bottom: var(--s2); } +.river__pairs ul { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--s2); } +.river__pair { + display: flex; + flex-wrap: wrap; + gap: var(--s2) var(--s3); + align-items: baseline; + width: 100%; + text-align: left; + padding: var(--s2) var(--s3); + border: 1px solid var(--rule); + border-radius: 6px; + background: none; + color: var(--ink); + font: inherit; + font-size: var(--text-small); + cursor: pointer; +} +.river__pair[aria-pressed="true"] { border-color: var(--ink); } +.river__pair:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; } +.river__pair-n { color: var(--ink-faint); font-size: var(--text-caption); } + +.river__toggle { margin: var(--s3) 0 var(--s2); } diff --git a/frontend/src/screens/RiverView.jsx b/frontend/src/screens/RiverView.jsx new file mode 100644 index 0000000..ea7661c --- /dev/null +++ b/frontend/src/screens/RiverView.jsx @@ -0,0 +1,176 @@ +import { useQuery } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { api } from "../api/client"; +import { niceBounds } from "./TrendChart"; +import { movedTogether, stepStops } from "./river.js"; +import "./RiverView.css"; + +/** + * Every test on one timeline -- the river. (It replaces vitaline, the + * standalone app this view came from.) + * + * The same rule as a panel's small multiples: the only thing the rows share + * is when the samples were drawn. Each test keeps its own scale, unit and + * reference band. Two differences from the panel view: + * + * - Each ribbon takes its colour from the reading it starts at, and changes + * sharply at the next one -- so a stretch out of range reads as a stretch + * of colour. Segments are straight: a smoothed curve between two draws + * would draw values nobody measured. + * - Pairs of tests that rose and fell together over at least four shared + * draws are named, with how many draws that rests on. That is arithmetic + * on these numbers, not a claim that one causes the other. + */ + +const W = 100; +const ROW_H = 54; +const PAD = { l: 2, r: 2, t: 10, b: 10 }; +const FLAG_VAR = { + high: "var(--flag-high)", low: "var(--flag-low)", abnormal: "var(--flag-high)", +}; +const colour = (flag) => FLAG_VAR[flag] ?? "var(--flag-normal)"; + +function Ribbon({ track, x0, span, scrubDay, highlighted }) { + const pts = track.points.filter((p) => p.collected_at); + const bounds = niceBounds(pts.map((p) => p.value), track.ref_low, track.ref_high); + const frac = (p) => (new Date(p.collected_at).getTime() - x0) / span; + const sx = (p) => PAD.l + frac(p) * (W - PAD.l - PAD.r); + const sy = (v) => ROW_H - PAD.b - ((v - bounds.min) / (bounds.max - bounds.min || 1)) * (ROW_H - PAD.t - PAD.b); + const d = pts.map((p, i) => `${i ? "L" : "M"}${sx(p)} ${sy(p.value)}`).join(" "); + const gid = `river-${track.loinc_code.replace(/[^A-Za-z0-9]/g, "")}`; + const bandable = track.ref_low != null && track.ref_high != null; + const today = scrubDay && pts.find((p) => p.collected_at.slice(0, 10) === scrubDay); + const last = pts[pts.length - 1]; + + return ( +
Loading every test…
; + if (error) returnCould not load the timeline.
; + if (!tracks.length) { + returnA test needs at least two dated results to appear on the timeline.
; + } + + const x0 = new Date(data.first_at).getTime(); + const span = Math.max(new Date(data.last_at).getTime() - x0, 1); + const scrubDay = scrub == null ? null : days[scrub]; + const hl = focus == null ? new Set() : new Set([pairs[focus].i, pairs[focus].j]); + const fmt = (day) => new Date(`${day}T12:00:00`).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }); + + return ( ++ Every test with two or more results, on one shared time axis. Each keeps + its own scale and reference range; colour shows whether each result was + in range, low or high. +
+ + + ++ Rose and fell together across the draws they share. That is all it + says: not that one causes the other. +
+