Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions backend/app/routers/observations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")

Expand All @@ -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 = []
Expand Down
20 changes: 20 additions & 0 deletions backend/tests/test_panel_trends.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]}
88 changes: 88 additions & 0 deletions frontend/src/screens/RiverView.css
Original file line number Diff line number Diff line change
@@ -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); }
176 changes: 176 additions & 0 deletions frontend/src/screens/RiverView.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<li className={`river__row${highlighted ? " river__row--hl" : ""}`}>
<div className="river__name">
{track.display ?? track.loinc_code}
<span className="river__unit">{track.unit ?? ""}</span>
</div>
<svg
className="river__plot"
viewBox={`0 0 ${W} ${ROW_H}`}
preserveAspectRatio="none"
role="img"
aria-label={`${track.display}: ${pts.length} results from ${pts[0].value} to ${last.value} ${track.unit ?? ""}, latest ${last.flag}`}
>
<defs>
<linearGradient id={gid} gradientUnits="userSpaceOnUse" x1={PAD.l} x2={W - PAD.r} y1="0" y2="0">
{stepStops(pts, frac).map((s, i) => (
<stop key={i} offset={s.offset} stopColor={colour(s.flag)} />
))}
</linearGradient>
</defs>
{bandable && (
<rect className="river__band" x={PAD.l} width={W - PAD.l - PAD.r}
y={sy(Math.min(track.ref_high, bounds.max))}
height={Math.max(sy(Math.max(track.ref_low, bounds.min)) - sy(Math.min(track.ref_high, bounds.max)), 0.5)} />
)}
<path className="river__ribbon" d={d} stroke={`url(#${gid})`} vectorEffect="non-scaling-stroke" />
{today && (
<line className="river__scrub" x1={sx(today)} x2={sx(today)} y1={0} y2={ROW_H}
vectorEffect="non-scaling-stroke" />
)}
</svg>
<div className={`river__value num flag--${(today ?? last).flag}`}>
{today ? today.value : scrubDay ? "—" : last.value}
</div>
</li>
);
}

export default function RiverView({ patientId }) {
const { data, isPending, error } = useQuery({
queryKey: ["panel-trends", patientId, "all"],
queryFn: async () =>
(await api.get(`/observations/${patientId}/panel-trends`, { params: { panel: "all" } })).data,
});
const [scrub, setScrub] = useState(null);
const [focus, setFocus] = useState(null);

const tracks = useMemo(
() => (data?.tracks ?? []).filter((t) => t.points.filter((p) => p.collected_at).length >= 2),
[data],
);
const days = useMemo(
() => [...new Set(tracks.flatMap((t) => t.points.filter((p) => p.collected_at)
.map((p) => p.collected_at.slice(0, 10))))].sort(),
[tracks],
);
const pairs = useMemo(() => movedTogether(tracks), [tracks]);

if (isPending) return <p className="muted">Loading every test…</p>;
if (error) return <p className="muted">Could not load the timeline.</p>;
if (!tracks.length) {
return <p className="muted">A test needs at least two dated results to appear on the timeline.</p>;
}

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 (
<div className="river">
<p className="river__note">
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.
</p>

<label className="river__scrubber">
<span className="river__scrubber-label">
{scrubDay ? `Draw of ${fmt(scrubDay)}` : "Latest results"}
</span>
<input
type="range" min={-1} max={days.length - 1} step={1}
value={scrub ?? -1}
onChange={(e) => setScrub(Number(e.target.value) < 0 ? null : Number(e.target.value))}
aria-valuetext={scrubDay ? `Draw of ${fmt(scrubDay)}` : "Latest results"}
/>
</label>

<ul className="river__rows">
{tracks.map((t, i) => (
<Ribbon key={t.loinc_code} track={t} x0={x0} span={span} scrubDay={scrubDay} highlighted={hl.has(i)} />
))}
</ul>
<div className="river__axis num" aria-hidden="true">
<span />
<div className="river__axis-dates">
<span>{fmt(days[0])}</span>
<span>{fmt(days[days.length - 1])}</span>
</div>
<span />
</div>

{pairs.length > 0 && (
<section className="river__pairs">
<h3 className="river__pairs-h">Moved together</h3>
<p className="river__pairs-note">
Rose and fell together across the draws they share. That is all it
says: not that one causes the other.
</p>
<ul>
{pairs.map((p, k) => (
<li key={`${p.i}-${p.j}`}>
<button
className="river__pair"
aria-pressed={focus === k}
onClick={() => setFocus(focus === k ? null : k)}
>
{tracks[p.i].display} {p.r > 0 ? "and" : "against"} {tracks[p.j].display}
<span className="river__pair-n num">
{p.n} shared draws · r = {p.r.toFixed(2)}
</span>
</button>
</li>
))}
</ul>
</section>
)}
</div>
);
}
13 changes: 13 additions & 0 deletions frontend/src/screens/Trends.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { usePatient } from "../patients/PatientContext";
import IntervalRail from "../components/IntervalRail";
import PanelTrends from "./PanelTrends";
import Provenance from "./Provenance";
import RiverView from "./RiverView";
import TrendChart from "./TrendChart";
import "./Trends.css";

Expand Down Expand Up @@ -283,6 +284,7 @@ export default function Trends() {
const [openCode, setOpenCode] = useState(null);
const [openPanel, setOpenPanel] = useState(null);
const [inspecting, setInspecting] = useState(null);
const [river, setRiver] = useState(false);
const { activeId } = usePatient();

const { data: panels, isPending, error } = useQuery({
Expand Down Expand Up @@ -341,6 +343,17 @@ export default function Trends() {
</dl>
</header>

<div className="river__toggle">
<button
className="pgroup__chart"
aria-expanded={river}
onClick={() => setRiver(!river)}
>
{river ? "Hide the timeline" : "Every test on one timeline"}
</button>
</div>
{river && <RiverView patientId={activeId} />}

<div className="panels__head" aria-hidden="true">
<span>Test</span>
<span className="panels__head-val">Latest</span>
Expand Down
Loading
Loading