mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
feat(labs): keep the reference range a report prints beside a value
A lab runs its own method on its own device, so the window a physician evaluates a value against is the one printed next to it on that report, not a catalog band averaged over every lab. Reading a report pulled the value and dropped that range: the extraction asked for it, the commit handed it to the biomarker resolver, and the resolver only uses it when it mints a NEW marker. For every analyte the user already had — the normal case after a first panel — the printed range was read and then thrown away. Add `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText` to `LabResult` and carry the range from the report to the reading. The text is stored whether or not numbers can be read out of it, because "bis 5,0" and "negativ" state a window too; a range that could not be parsed is kept verbatim, never replaced with invented bounds. `resolveEffectiveReferenceRange` is the one place the precedence lives: the source window wins for that single reading, and the catalog stays the net for readings without one. A printed string with no derivable bound does not displace the catalog. Every surface that judges a reading routes through it — the API verdict, the doctor report, the insights blocks and per-marker assessment, the coach snapshot, the MCP read and the FHIR `referenceRange` — so a value cannot read in range on one surface and out on another. The API keeps `referenceLow` / `referenceHigh` as the window in force, so a client that reads only those two renders the right range and the right verdict with no change of its own; the catalog band, the source window, its origin and a divergence flag ride alongside for clients that want to show both. Read side: the chart draws the source window as a second, visually distinct band with the reading it came from named beneath it, the reading rows name the printed range and the catalog band where the two disagree, and the doctor-report PDF prints the report's own string with a footnote naming the saved range. Entry side: one optional field on the lab form, folded away until asked for, and the printed range is editable on the scan review screen. The range parser covers the notations German and English reports actually print — two-sided, one-sided, comma or dot decimals, thousands separators, trailing units — reads units without ever converting them, and refuses to guess on anything ambiguous.
This commit is contained in:
1 parent
3778e70ff5
commit
fd2bd67ff3
57 files changed
+2281
-175
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") : ""}
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface OcrCommitRowInput {
|
||||
unit?: string;
|
||||
referenceLow?: number;
|
||||
referenceHigh?: number;
|
||||
referenceText?: string;
|
||||
takenAt: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user