Merge branch 'main' into fix/wipe-geo-truths

This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-08 03:29:51 +02:00
commit cbf949d43a
93 files changed
+5417 -236

No files matched your search

+3
View File
@@ -194,6 +194,9 @@ function catalogueLabel(
if (catalogue === "illnessSymptom") {
return t("admin.section.backups.restoreSkippedIllnessSymptom");
}
if (catalogue === "moodTag") {
return t("admin.section.backups.restoreSkippedMoodTag");
}
return t("admin.section.backups.restoreSkippedMoodFactor");
}
@@ -64,6 +64,13 @@ function reading(id: string, value: number, takenAt: string): LabResultDto {
unit: "mg/dL",
referenceLow: 0,
referenceHigh: 100,
catalogReferenceLow: 0,
catalogReferenceHigh: 100,
sourceReferenceLow: null,
sourceReferenceHigh: null,
sourceReferenceText: null,
referenceOrigin: "catalog",
referenceDivergesFromCatalog: false,
takenAt,
source: "MANUAL",
hasNote: false,
+128 -1
View File
@@ -17,6 +17,7 @@ import { Button } from "@/components/ui/button";
import { CHART_HEIGHT_PX } from "@/lib/charts/constants";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { formatLabValue } from "@/lib/labs/format-value";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip";
@@ -32,6 +33,15 @@ import type { LabResultDto } from "./types";
* MUTED primary fill — never a danger colour — honouring the no-alarming-
* colour rule. Every reading carries the same y-axis unit because a chart is
* one biomarker only.
*
* A SECOND band draws the window a source report printed, when the readings in
* view carry one. It is visually distinct from the catalog band — an outlined
* band in the chart's secondary hue against the catalog's filled primary — and
* both stay muted; the in/out verdict is the badge's job, never the chart's
* colour. The source window is a per-reading fact, so the band is drawn from
* the NEWEST reading in view that carries one and the legend dates it. When
* the readings in view carry more than one distinct printed window the legend
* says so rather than implying one window covered the whole series.
*/
const RANGE_DAYS = [
@@ -46,6 +56,26 @@ type RangeKey = (typeof RANGE_DAYS)[number]["key"];
interface ChartPoint {
timestamp: number;
value: number;
/** The window this reading was judged against, for the tooltip. */
referenceLow: number | null;
referenceHigh: number | null;
referenceOrigin: LabResultDto["referenceOrigin"];
sourceReferenceText: string | null;
}
/** The source window drawn as the second band, resolved from the readings in view. */
interface SourceBand {
low: number | null;
high: number | null;
/** The reading the band was taken from. */
takenAt: number;
/** True when the readings in view printed more than one distinct window. */
mixed: boolean;
}
/** Identity of a printed window, for counting distinct ones. */
function bandKey(low: number | null, high: number | null): string {
return `${low ?? ""}|${high ?? ""}`;
}
export function LabBiomarkerChart({
@@ -87,9 +117,37 @@ export function LabBiomarkerChart({
return filtered.map((r) => ({
timestamp: new Date(r.takenAt).getTime(),
value: r.value,
referenceLow: r.referenceLow,
referenceHigh: r.referenceHigh,
referenceOrigin: r.referenceOrigin,
sourceReferenceText: r.sourceReferenceText,
}));
}, [readings, range, nowMs]);
// The source band: the newest in-view reading whose report printed a window
// with usable bounds. Readings without one contribute nothing — an absent
// window is absent, not a reason to widen or narrow the band.
const sourceBand = useMemo<SourceBand | null>(() => {
const withWindow = points.filter(
(p) =>
p.referenceOrigin === "source" &&
(p.referenceLow !== null || p.referenceHigh !== null),
);
if (withWindow.length === 0) return null;
const newest = withWindow.reduce((a, b) =>
b.timestamp > a.timestamp ? b : a,
);
const distinct = new Set(
withWindow.map((p) => bandKey(p.referenceLow, p.referenceHigh)),
);
return {
low: newest.referenceLow,
high: newest.referenceHigh,
takenAt: newest.timestamp,
mixed: distinct.size > 1,
};
}, [points]);
// Y domain wraps both the data and the reference window so the band is
// always visible even when every reading sits inside it.
const yDomain = useMemo<[number, number]>(() => {
@@ -97,15 +155,23 @@ export function LabBiomarkerChart({
const candidates = [...values];
if (lowerBound != null) candidates.push(lowerBound);
if (upperBound != null) candidates.push(upperBound);
// The source band has to fit too, or the second band is drawn off-canvas
// and the chart silently shows one window while claiming two.
if (sourceBand?.low != null) candidates.push(sourceBand.low);
if (sourceBand?.high != null) candidates.push(sourceBand.high);
if (candidates.length === 0) return [0, 1];
const min = Math.min(...candidates);
const max = Math.max(...candidates);
const pad = (max - min || Math.abs(max) || 1) * 0.1;
return [min - pad, max + pad];
}, [points, lowerBound, upperBound]);
}, [points, lowerBound, upperBound, sourceBand]);
const animate = !prefersReducedMotion();
const primary = "var(--primary)";
// A second muted hue from the chart palette. Distinct from the primary band
// by hue AND by treatment (outline vs fill), so the two are told apart
// without relying on colour alone.
const secondary = "var(--chart-2)";
return (
<div className="space-y-3">
@@ -185,6 +251,42 @@ export function LabBiomarkerChart({
strokeDasharray="4 4"
/>
) : null}
{/* The source window — outlined, not filled, so it reads as a
second band over the catalog one rather than a second fill
competing with it. Same no-alarm-colour rule. */}
{sourceBand &&
sourceBand.low != null &&
sourceBand.high != null ? (
<ReferenceArea
y1={sourceBand.low}
y2={sourceBand.high}
fill={secondary}
fillOpacity={0.06}
stroke={secondary}
strokeOpacity={0.5}
strokeDasharray="2 3"
/>
) : null}
{sourceBand &&
sourceBand.low != null &&
sourceBand.high == null ? (
<ReferenceLine
y={sourceBand.low}
stroke={secondary}
strokeOpacity={0.6}
strokeDasharray="2 3"
/>
) : null}
{sourceBand &&
sourceBand.high != null &&
sourceBand.low == null ? (
<ReferenceLine
y={sourceBand.high}
stroke={secondary}
strokeOpacity={0.6}
strokeDasharray="2 3"
/>
) : null}
<Tooltip
content={(props) => {
const active = props.active ?? false;
@@ -201,6 +303,22 @@ export function LabBiomarkerChart({
color: primary,
},
];
// Name the window THIS reading was judged against. Two
// readings on one chart can carry different printed windows,
// so the band alone cannot answer it per point.
if (point.referenceOrigin === "source") {
rows.push({
name: t("labs.chart.sourceRangeLabel"),
value:
point.sourceReferenceText ??
`${formatReferenceRange(
point.referenceLow,
point.referenceHigh,
formatLabValue,
)} ${unit}`.trim(),
color: secondary,
});
}
return (
<RichChartTooltip
active
@@ -221,6 +339,15 @@ export function LabBiomarkerChart({
/>
</ComposedChart>
</ResponsiveContainer>
{sourceBand ? (
<p className="text-muted-foreground mt-2 text-xs">
{sourceBand.mixed
? t("labs.chart.sourceBandMixed")
: t("labs.chart.sourceBandLegend", {
date: fmt.dateShortSmart(new Date(sourceBand.takenAt)),
})}
</p>
) : null}
</div>
)}
</div>
+73
View File
@@ -22,6 +22,7 @@ import {
import { Textarea } from "@/components/ui/textarea";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { parseReferenceRange } from "@/lib/labs/parse-reference-range";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { formatLabValue } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
@@ -118,6 +119,13 @@ export function LabForm({
const [valueText, setValueText] = useState("");
const [takenAt, setTakenAt] = useState(defaultTakenAtValue);
const [note, setNote] = useState("");
// The window the paper report printed for THIS reading. Optional and folded
// away by default: most entries are typed against a catalog marker and never
// need it, and a field nobody fills is a field in the way. One text input,
// because that is how a report states it — "3,5 - 5,0", "< 5", "bis 5,0".
// The bounds are derived from what is typed; what is typed is kept verbatim.
const [sourceRangeOpen, setSourceRangeOpen] = useState(false);
const [sourceRange, setSourceRange] = useState("");
// v1.30.1 H3 — which submit button is in flight, so the OTHER one doesn't
// also show a spinner. `!== null` replaces the old plain `submitting`
// boolean for every disabled check below.
@@ -193,11 +201,27 @@ export function LabForm({
setPendingAction(keepOpen ? "saveAndAddAnother" : "save");
try {
// Parse client-side so the user sees the derived window before saving,
// and send BOTH: the bounds where they could be read, the text always.
const printed = isQualitative
? null
: parseReferenceRange(sourceRange, selected?.unit);
const created = await apiPost<LabResultDto>("/api/labs", {
biomarkerId,
...(isQualitative
? { valueText: qualitativeValue }
: { value: numericValue }),
...(printed
? {
...(printed.low !== null
? { sourceReferenceLow: printed.low }
: {}),
...(printed.high !== null
? { sourceReferenceHigh: printed.high }
: {}),
sourceReferenceText: printed.text,
}
: {}),
takenAt: new Date(takenAt).toISOString(),
...(note.trim() ? { note: note.trim() } : {}),
});
@@ -221,6 +245,8 @@ export function LabForm({
setValue("");
setValueText("");
setNote("");
// The next analyte off the same report has its own printed window.
setSourceRange("");
setError(null);
onSavedKeepOpen?.(created);
// Return focus to wherever the next entry starts so the flow
@@ -252,6 +278,20 @@ export function LabForm({
)
: "";
// What the typed string resolved to, shown back so the user can see whether
// a window was read out of it before the reading is saved. When none was,
// the hint says the text is kept as written rather than staying silent.
const parsedSourceRange = parseReferenceRange(sourceRange, selected?.unit);
const sourceRangeHint = parsedSourceRange
? parsedSourceRange.low === null && parsedSourceRange.high === null
? t("labs.form.sourceRangeUnparsed")
: `${formatReferenceRange(
parsedSourceRange.low,
parsedSourceRange.high,
formatLabValue,
)} ${selected?.unit ?? ""}`.trim()
: "";
const footerNode = (
<>
{onCancel ? (
@@ -415,6 +455,39 @@ export function LabForm({
</p>
) : null}
{resultType === "numeric" ? (
<div className="space-y-1.5">
{sourceRangeOpen ? (
<>
<Label htmlFor="lab-sourceRange">
{t("labs.form.sourceRange")}
</Label>
<Input
id="lab-sourceRange"
value={sourceRange}
onChange={(e) => setSourceRange(e.target.value)}
placeholder={t("labs.form.sourceRangePlaceholder")}
maxLength={120}
autoFocus
/>
<p className="text-muted-foreground text-xs">
{sourceRangeHint || t("labs.form.sourceRangeHint")}
</p>
</>
) : (
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground h-auto px-0 text-xs"
onClick={() => setSourceRangeOpen(true)}
>
{t("labs.form.sourceRangeAdd")}
</Button>
)}
</div>
) : null}
<div className="space-y-1.5">
<Label htmlFor="lab-note">{t("labs.form.note")}</Label>
<Textarea
+8 -1
View File
@@ -35,6 +35,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { ReferenceRangeBadge } from "./reference-range-badge";
import { SourceRangeNote } from "./source-range-note";
import type { LabResultDetailDto, LabResultDto } from "./types";
const NOTE_MAX_LENGTH = 2000;
@@ -356,7 +357,13 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
{formatDateShort(r.takenAt, true)}
</TableCell>
<TableCell>
<ReferenceRangeBadge status={r.rangeStatus} />
<div className="flex flex-col items-start gap-1">
<ReferenceRangeBadge status={r.rangeStatus} />
<SourceRangeNote
reading={r}
className="text-muted-foreground text-xs"
/>
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{r.hasNote ? t("labs.hasNote") : ""}
+5
View File
@@ -23,6 +23,7 @@ import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
import { LabTrendSparkline } from "./lab-trend-sparkline";
import { ReferenceRangeBadge } from "./reference-range-badge";
import { SourceRangeNote } from "./source-range-note";
import type { LabResultDto, LabResultListResponse } from "./types";
interface MarkerGroup {
@@ -316,6 +317,10 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
)}
</span>
) : null}
<SourceRangeNote
reading={group.latest}
className="text-xs"
/>
<span className="text-xs">
{formatDate(group.latest.takenAt)}
</span>
@@ -76,6 +76,7 @@ function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null {
takenAt: takenAt.toISOString(),
...(row.referenceLow !== null ? { referenceLow: row.referenceLow } : {}),
...(row.referenceHigh !== null ? { referenceHigh: row.referenceHigh } : {}),
...(row.referenceText ? { referenceText: row.referenceText } : {}),
};
}
+3
View File
@@ -16,6 +16,8 @@ export interface OcrReviewRow {
unit: string | null;
referenceLow: number | null;
referenceHigh: number | null;
/** The reference range as printed on the scanned report, verbatim. */
referenceText: string | null;
/** ISO calendar day (YYYY-MM-DD) or null. */
takenAt: string | null;
confidence: OcrExtractedRowDto["confidence"];
@@ -39,6 +41,7 @@ export function seedReviewRows(
unit: row.unit,
referenceLow: row.referenceLow,
referenceHigh: row.referenceHigh,
referenceText: row.referenceText,
takenAt: row.takenAt ?? fallbackDate,
confidence: row.confidence,
biomarkerMatch: row.biomarkerMatch,
+26
View File
@@ -247,6 +247,32 @@ export function OcrRowEditor({
</div>
) : null}
{/* The window as the report printed it. Editable, because the human is
reviewing a transcription: the two bounds above are the derived
reading of this string, and correcting the string is how a range the
parser could not read ("bis 5,0", "negativ") still reaches the row
instead of being dropped. */}
{!isQualitative ? (
<div className="space-y-1">
<Label htmlFor={`${fieldId}-refText`} className="text-xs">
{t("labs.ocr.refTextLabel")}
</Label>
<Input
id={`${fieldId}-refText`}
value={row.referenceText ?? ""}
placeholder={t("labs.ocr.refTextPlaceholder")}
maxLength={120}
onChange={(e) => {
const raw = e.target.value;
onChange({
...row,
referenceText: raw.trim() === "" ? null : raw,
});
}}
/>
</div>
) : null}
{valueUnreadable ? (
<p className="text-muted-foreground flex items-center gap-1.5 text-xs">
<AlertCircle aria-hidden className="h-3.5 w-3.5" />
+69
View File
@@ -0,0 +1,69 @@
"use client";
import { formatLabValue } from "@/lib/labs/format-value";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { useTranslations } from "@/lib/i18n/context";
import type { LabResultDto } from "./types";
/**
* The reference window a reading's own report printed, shown wherever that
* reading is shown.
*
* Renders nothing for a reading with no printed window — most of them — so it
* costs no space on the common row. When the report DID state a window it
* names it, verbatim where the report gave words; and when that window and the
* catalog band disagree it names the catalog band too. A reader told only
* "3,9–5,4" and a reader told only "3,5–5,0" draw different conclusions from
* the same number, so a surface that shows one silently is showing a partial
* answer.
*
* Neutral by construction: no tint, no icon, muted text. The in/out verdict is
* the badge's job — this element states where the window came from, nothing
* about whether the value is good news.
*/
export function SourceRangeNote({
reading,
className,
}: {
reading: Pick<
LabResultDto,
| "unit"
| "referenceLow"
| "referenceHigh"
| "catalogReferenceLow"
| "catalogReferenceHigh"
| "sourceReferenceText"
| "referenceOrigin"
| "referenceDivergesFromCatalog"
>;
className?: string;
}) {
const { t } = useTranslations();
if (reading.referenceOrigin !== "source") return null;
const printed =
reading.sourceReferenceText ??
`${formatReferenceRange(
reading.referenceLow,
reading.referenceHigh,
formatLabValue,
)} ${reading.unit}`.trim();
const catalog = formatReferenceRange(
reading.catalogReferenceLow,
reading.catalogReferenceHigh,
formatLabValue,
);
return (
<span className={className}>
{t("labs.sourceRange.fromReport", { range: printed })}
{reading.referenceDivergesFromCatalog && catalog
? ` · ${t("labs.sourceRange.catalogDiffers", {
range: `${catalog} ${reading.unit}`.trim(),
})}`
: null}
</span>
);
}
+21 -1
View File
@@ -1,4 +1,7 @@
import type { ReferenceRangeStatus } from "@/lib/validations/labs";
import type {
ReferenceRangeOrigin,
ReferenceRangeStatus,
} from "@/lib/validations/labs";
/** A lab-result row as the list / create endpoints serialise it. */
export interface LabResultDto {
@@ -12,8 +15,25 @@ export interface LabResultDto {
/** v1.18.9 — qualitative result text ("negativ" / …); null for a numeric row. */
valueText: string | null;
unit: string;
/**
* The window this reading is judged against and the one `rangeStatus` was
* computed from: the range its own report printed when it carries one, the
* catalog band otherwise. Render these and the range shown always matches
* the verdict shown.
*/
referenceLow: number | null;
referenceHigh: number | null;
/** The catalog marker's band, so both windows can be drawn. */
catalogReferenceLow: number | null;
catalogReferenceHigh: number | null;
/** The window the source report printed; the text verbatim, bounds when derivable. */
sourceReferenceLow: number | null;
sourceReferenceHigh: number | null;
sourceReferenceText: string | null;
/** Which window is in force. */
referenceOrigin: ReferenceRangeOrigin;
/** True when the report's window and the catalog band state different limits. */
referenceDivergesFromCatalog: boolean;
takenAt: string;
source: string;
hasNote: boolean;
+1
View File
@@ -33,6 +33,7 @@ export interface OcrCommitRowInput {
unit?: string;
referenceLow?: number;
referenceHigh?: number;
referenceText?: string;
takenAt: string;
}