mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
A report that prints a text result such as "SEE NOTE" with a numeric range beside it minted the marker without bounds: the document commit, the scan commit, the free-text lab write and the CSV import each passed null bounds for a qualitative reading, and the CSV row reader and the scan review payload dropped the range before that. The marker's first number then had nothing to be judged against. Every mint path now passes the printed window. A range that is text itself yields no bounds, an existing marker keeps its own, a transposed window is not minted, and the reading itself still records no window. The scan review shows the range on a text row that mints a new marker. Refs #1313
483 lines
19 KiB
TypeScript
483 lines
19 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* v1.18.9 — a single proposed Lab-OCR row on the review screen.
|
|
*
|
|
* Mandatory human review: each row is shown for per-row confirm/edit/discard.
|
|
* The checkbox confirms; the inline fields edit analyte / value-or-valueText /
|
|
* unit / reference range / date. Server-computed hints surface as calm badges:
|
|
* a new-vs-existing-biomarker hint, a duplicate warning (the row defaults to
|
|
* unchecked when flagged), and a low-confidence flag per the model's self-score.
|
|
*
|
|
* The no-alarming-colour ethos holds: an out-of-range or duplicate row is not
|
|
* painted red — these are informative `secondary`/`outline` badges, same weight
|
|
* as the in-range state.
|
|
*/
|
|
import { useId } from "react";
|
|
|
|
import { AlertCircle, Ruler, TriangleAlert } from "lucide-react";
|
|
|
|
import { FieldError } from "@/components/forms/field-error";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { DateField } from "@/components/ui/date-field";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { isDimensionlessUnit, labUnitLabel } from "@/lib/labs/dimensionless";
|
|
import {
|
|
OCR_ANALYTE_MAX,
|
|
OCR_UNIT_MAX,
|
|
OCR_VALUE_TEXT_MAX,
|
|
} from "@/lib/validations/labs-ocr";
|
|
|
|
import { LabUnitInput } from "./lab-unit-input";
|
|
import { MarkerMatchField } from "./marker-match-field";
|
|
import { chooseMarker, type OcrReviewRow } from "./ocr-review-types";
|
|
import {
|
|
readingConversion,
|
|
readingUnitBlocked,
|
|
type OcrRowErrors,
|
|
} from "./ocr-review-validation";
|
|
import { useLabNumber } from "./use-lab-format";
|
|
|
|
/** Below this per-field confidence the field is flagged for a second look. */
|
|
const CONFIDENCE_THRESHOLD = 0.6;
|
|
|
|
/** The visible marker that a field must be filled before the row can save. */
|
|
function RequiredMark() {
|
|
return (
|
|
<span aria-hidden className="text-destructive">
|
|
{" *"}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
type Translate = (key: string, params?: Record<string, string>) => string;
|
|
|
|
/** The badge naming a row whose unit differs from its marker's. */
|
|
function unitDiffersText(row: OcrReviewRow, t: Translate): string {
|
|
const unit = labUnitLabel(row.unit, t);
|
|
return isDimensionlessUnit(row.markerUnit)
|
|
? t("labs.ocr.unitDiffersBadgeNoUnit", { unit })
|
|
: t("labs.ocr.unitDiffersBadge", {
|
|
markerUnit: row.markerUnit ?? "",
|
|
unit,
|
|
});
|
|
}
|
|
|
|
/** The message key and params of a unit the marker cannot take. */
|
|
function unitMismatchMessage(
|
|
row: OcrReviewRow,
|
|
): [string, Record<string, string>?] {
|
|
return isDimensionlessUnit(row.markerUnit)
|
|
? ["labs.ocr.errorUnitMismatchNoUnit"]
|
|
: ["labs.ocr.errorUnitMismatch", { markerUnit: row.markerUnit ?? "" }];
|
|
}
|
|
|
|
/**
|
|
* v1.18.10 (#5) — a NEW biomarker mints its catalog reference range from THIS
|
|
* extracted row, so the range is load-bearing and worth a second look. Surface
|
|
* the range confidence for a new numeric marker, and call it out when the
|
|
* model was unsure (or set no bounds at all) so the user verifies the range it
|
|
* will be judged against. A text result ("SEE NOTE") records no window of its
|
|
* own, but a new marker minted from it takes the one printed beside it, so the
|
|
* range is shown there too, and only called out when there is one: a text
|
|
* result often has no numeric range, and that is not a gap.
|
|
*/
|
|
function rangeReview(row: OcrReviewRow): {
|
|
showRange: boolean;
|
|
showRangeBadge: boolean;
|
|
flagRange: boolean;
|
|
} {
|
|
const isQualitative = row.valueText !== null && row.valueText !== undefined;
|
|
const isNew = row.biomarkerMatch === "new";
|
|
const lowRangeConfidence = row.confidence.range < CONFIDENCE_THRESHOLD;
|
|
const hasBounds = row.referenceLow !== null || row.referenceHigh !== null;
|
|
const showRangeBadge = isNew && (!isQualitative || hasBounds);
|
|
return {
|
|
showRange: !isQualitative || isNew,
|
|
showRangeBadge,
|
|
flagRange: showRangeBadge && (lowRangeConfidence || !hasBounds),
|
|
};
|
|
}
|
|
|
|
export function OcrRowEditor({
|
|
row,
|
|
onChange,
|
|
errors,
|
|
markerLookup = () => null,
|
|
}: {
|
|
row: OcrReviewRow;
|
|
onChange: (next: OcrReviewRow) => void;
|
|
/**
|
|
* The account's marker by id, for the matched marker's name and the
|
|
* analyte a chosen suggestion converts with. Absent: names fall back to the
|
|
* printed analyte.
|
|
*/
|
|
markerLookup?: (
|
|
biomarkerId: string,
|
|
) => { name: string; analyteKey: string | null } | null;
|
|
/** The blocking fields of this row, once a save has been attempted. */
|
|
errors?: OcrRowErrors;
|
|
}) {
|
|
const { t } = useTranslations();
|
|
const fieldId = useId();
|
|
const invalidProps = (field: keyof OcrRowErrors) =>
|
|
errors?.[field]
|
|
? {
|
|
"aria-invalid": true as const,
|
|
"aria-describedby": `${fieldId}-${field}-error`,
|
|
}
|
|
: {};
|
|
const fieldError = (
|
|
field: keyof OcrRowErrors,
|
|
messageKey: string,
|
|
params?: Record<string, string>,
|
|
) =>
|
|
errors?.[field] ? (
|
|
<FieldError
|
|
id={`${fieldId}-${field}-error`}
|
|
message={t(messageKey, params)}
|
|
/>
|
|
) : null;
|
|
// Stated before Save, not only after a failed attempt. A reading in another
|
|
// unit than its marker's is either converted (the badge says what Save will
|
|
// store) or cannot be written (the person should see why the row is unticked,
|
|
// or will not save, while they are still reading it).
|
|
const unitBlocked = readingUnitBlocked(row);
|
|
const conversion = readingConversion(row);
|
|
const labNumber = useLabNumber();
|
|
|
|
const isQualitative = row.valueText !== null && row.valueText !== undefined;
|
|
const lowValueConfidence =
|
|
!isQualitative && row.confidence.value < CONFIDENCE_THRESHOLD;
|
|
const valueUnreadable = !isQualitative && row.value === null;
|
|
|
|
// The model self-scores each field it transcribed. Surface a single calm
|
|
// per-row flag when ANY tracked field came back below the threshold so a
|
|
// shaky read is visible at a glance — including on qualitative rows, which
|
|
// have no value-confidence footnote of their own. Informative, not alarming.
|
|
const lowRowConfidence =
|
|
row.confidence.analyte < CONFIDENCE_THRESHOLD ||
|
|
row.confidence.unit < CONFIDENCE_THRESHOLD ||
|
|
(isQualitative
|
|
? row.confidence.value < CONFIDENCE_THRESHOLD
|
|
: lowValueConfidence);
|
|
|
|
const { showRange, showRangeBadge, flagRange } = rangeReview(row);
|
|
|
|
return (
|
|
// `@container` makes the row follow the width of the dialog, not the
|
|
// window: in a narrow dialog (or on a phone) it stacks as before; once the
|
|
// dialog is wide enough it splits into the reading on the left and its
|
|
// fields on the right, so a long report reads as a table, not a column.
|
|
<div className="@container">
|
|
<div className="space-y-3 rounded-lg border p-3 @3xl:grid @3xl:grid-cols-[minmax(0,2fr)_minmax(0,3fr)] @3xl:items-start @3xl:space-y-0 @3xl:gap-x-6">
|
|
<div className="flex items-start gap-3">
|
|
{/* The checkbox is the PRIMARY per-row confirm on a touch-first OCR
|
|
review screen, so it needs a ≥44px hit area on coarse pointers. The
|
|
wrapping label supplies the touch target (and forwards the click to
|
|
the control) without enlarging the 20px glyph or bloating the row on
|
|
desktop, where the negative margins collapse the padding back. */}
|
|
<label
|
|
htmlFor={`${fieldId}-confirm`}
|
|
className="-m-3 flex min-h-11 min-w-11 cursor-pointer items-start justify-center p-3 sm:m-0 sm:min-h-0 sm:min-w-0 sm:p-0"
|
|
>
|
|
<Checkbox
|
|
id={`${fieldId}-confirm`}
|
|
checked={row.confirmed}
|
|
onCheckedChange={(checked) =>
|
|
onChange({ ...row, confirmed: checked === true })
|
|
}
|
|
className="mt-0.5 min-h-5 min-w-5 sm:mt-1"
|
|
aria-label={t("labs.ocr.confirmRow")}
|
|
/>
|
|
</label>
|
|
<div className="min-w-0 flex-1 space-y-1">
|
|
<Input
|
|
value={row.analyte}
|
|
onChange={(e) => onChange({ ...row, analyte: e.target.value })}
|
|
aria-label={t("labs.ocr.analyteLabel")}
|
|
{...invalidProps(
|
|
errors?.analyteTooLong ? "analyteTooLong" : "analyte",
|
|
)}
|
|
className="font-medium"
|
|
/>
|
|
{fieldError("analyte", "labs.ocr.errorAnalyte")}
|
|
{fieldError("analyteTooLong", "labs.ocr.errorTooLong", {
|
|
max: String(OCR_ANALYTE_MAX),
|
|
})}
|
|
{/* v1.43 — the marker this value goes to: the server's
|
|
match, or a choice between its suggestions and a new one. */}
|
|
<MarkerMatchField
|
|
matchedName={
|
|
row.biomarkerId !== null
|
|
? (markerLookup(row.biomarkerId)?.name ?? row.analyte)
|
|
: null
|
|
}
|
|
matchKind={row.matchKind}
|
|
suggestions={row.suggestions}
|
|
chosenId={row.chosenBiomarkerId}
|
|
onChoose={(suggestion) =>
|
|
onChange(
|
|
chooseMarker(
|
|
row,
|
|
suggestion,
|
|
(id) => markerLookup(id)?.analyteKey ?? null,
|
|
),
|
|
)
|
|
}
|
|
slot="ocr-row-marker"
|
|
/>
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
{row.duplicateOf ? (
|
|
<Badge variant="secondary">
|
|
<TriangleAlert aria-hidden />
|
|
{t("labs.ocr.duplicateWarning")}
|
|
</Badge>
|
|
) : null}
|
|
{unitBlocked ? (
|
|
<Badge variant="secondary" className="max-w-full">
|
|
<Ruler aria-hidden />
|
|
<span className="truncate">{unitDiffersText(row, t)}</span>
|
|
</Badge>
|
|
) : null}
|
|
{conversion ? (
|
|
<Badge
|
|
variant="outline"
|
|
// Wraps rather than truncates: the printed value is the end
|
|
// of the sentence, exactly what a cut at 390 px would hide.
|
|
className="h-auto max-w-full justify-start rounded-md text-left whitespace-normal"
|
|
data-slot="ocr-row-converted-badge"
|
|
>
|
|
<Ruler aria-hidden />
|
|
<span className="min-w-0 break-words">
|
|
{t("labs.ocr.unitConvertedBadge", {
|
|
value: labNumber(
|
|
conversion.value,
|
|
conversion.statedValue,
|
|
),
|
|
markerUnit: conversion.unit,
|
|
statedValue: labNumber(conversion.statedValue),
|
|
unit: conversion.statedUnit,
|
|
})}
|
|
</span>
|
|
</Badge>
|
|
) : null}
|
|
{lowRowConfidence ? (
|
|
<Badge variant="secondary" className="text-muted-foreground">
|
|
<AlertCircle aria-hidden />
|
|
{t("labs.ocr.lowConfidenceBadge")}
|
|
</Badge>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* The fields are a container of their own, so how many columns they
|
|
get follows the width they actually have: the whole row when
|
|
stacked, the right-hand column once the row splits. */}
|
|
<div className="@container min-w-0 space-y-3">
|
|
<div className="grid grid-cols-2 gap-2 @md:grid-cols-3">
|
|
{isQualitative ? (
|
|
<div className="col-span-2 space-y-1 @md:col-span-3">
|
|
<Label htmlFor={`${fieldId}-vt`} className="text-xs">
|
|
{t("labs.ocr.resultLabel")}
|
|
<RequiredMark />
|
|
</Label>
|
|
<Input
|
|
id={`${fieldId}-vt`}
|
|
value={row.valueText ?? ""}
|
|
onChange={(e) =>
|
|
onChange({ ...row, valueText: e.target.value })
|
|
}
|
|
{...invalidProps(
|
|
errors?.valueTextTooLong ? "valueTextTooLong" : "valueText",
|
|
)}
|
|
/>
|
|
{fieldError("valueText", "labs.ocr.errorResult")}
|
|
{fieldError("valueTextTooLong", "labs.ocr.errorTooLong", {
|
|
max: String(OCR_VALUE_TEXT_MAX),
|
|
})}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="space-y-1">
|
|
<Label htmlFor={`${fieldId}-val`} className="text-xs">
|
|
{t("labs.ocr.valueLabel")}
|
|
<RequiredMark />
|
|
</Label>
|
|
<Input
|
|
id={`${fieldId}-val`}
|
|
inputMode="decimal"
|
|
value={row.value === null ? "" : String(row.value)}
|
|
onChange={(e) => {
|
|
const raw = e.target.value.trim();
|
|
const parsed = raw === "" ? null : Number(raw);
|
|
onChange({
|
|
...row,
|
|
value:
|
|
parsed !== null && Number.isFinite(parsed)
|
|
? parsed
|
|
: null,
|
|
});
|
|
}}
|
|
{...invalidProps("value")}
|
|
/>
|
|
{fieldError("value", "labs.ocr.errorValue")}
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label htmlFor={`${fieldId}-unit`} className="text-xs">
|
|
{t("labs.ocr.unitLabel")}
|
|
<RequiredMark />
|
|
</Label>
|
|
<LabUnitInput
|
|
id={`${fieldId}-unit`}
|
|
value={row.unit ?? ""}
|
|
onChange={(unit) => onChange({ ...row, unit })}
|
|
{...invalidProps(
|
|
errors?.unitMismatch
|
|
? "unitMismatch"
|
|
: errors?.unitTooLong
|
|
? "unitTooLong"
|
|
: "unit",
|
|
)}
|
|
/>
|
|
{fieldError("unit", "labs.ocr.errorUnit")}
|
|
{fieldError("unitTooLong", "labs.ocr.errorTooLong", {
|
|
max: String(OCR_UNIT_MAX),
|
|
})}
|
|
{fieldError("unitMismatch", ...unitMismatchMessage(row))}
|
|
</div>
|
|
</>
|
|
)}
|
|
<div className="space-y-1">
|
|
<Label htmlFor={`${fieldId}-date`} className="text-xs">
|
|
{t("labs.ocr.dateLabel")}
|
|
<RequiredMark />
|
|
</Label>
|
|
<DateField
|
|
id={`${fieldId}-date`}
|
|
value={row.takenAt ?? ""}
|
|
onChange={(value) =>
|
|
onChange({ ...row, takenAt: value || null })
|
|
}
|
|
{...invalidProps("date")}
|
|
// The bordered wrapper is the visible control; the input inside
|
|
// it only carries the aria state.
|
|
className={errors?.date ? "border-destructive" : undefined}
|
|
/>
|
|
{fieldError("date", "labs.ocr.errorDate")}
|
|
</div>
|
|
</div>
|
|
|
|
{/* v1.18.10 (#5) — for a NEW biomarker the reference range below
|
|
becomes the marker's catalog range. Make that consequence visible, and
|
|
flag low range-confidence / a missing range so the user verifies it
|
|
before saving. A calm `secondary` badge, not an alarm. */}
|
|
{showRangeBadge ? (
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<Badge variant={flagRange ? "secondary" : "outline"}>
|
|
<Ruler aria-hidden />
|
|
{flagRange
|
|
? t("labs.ocr.rangeVerify")
|
|
: t("labs.ocr.rangeFromScan")}
|
|
</Badge>
|
|
</div>
|
|
) : null}
|
|
|
|
{showRange ? (
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="space-y-1">
|
|
<Label htmlFor={`${fieldId}-lo`} className="text-xs">
|
|
{t("labs.ocr.refLowLabel")}
|
|
</Label>
|
|
<Input
|
|
id={`${fieldId}-lo`}
|
|
inputMode="decimal"
|
|
value={
|
|
row.referenceLow === null ? "" : String(row.referenceLow)
|
|
}
|
|
onChange={(e) => {
|
|
const raw = e.target.value.trim();
|
|
const parsed = raw === "" ? null : Number(raw);
|
|
onChange({
|
|
...row,
|
|
referenceLow:
|
|
parsed !== null && Number.isFinite(parsed)
|
|
? parsed
|
|
: null,
|
|
});
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label htmlFor={`${fieldId}-hi`} className="text-xs">
|
|
{t("labs.ocr.refHighLabel")}
|
|
</Label>
|
|
<Input
|
|
id={`${fieldId}-hi`}
|
|
inputMode="decimal"
|
|
value={
|
|
row.referenceHigh === null ? "" : String(row.referenceHigh)
|
|
}
|
|
onChange={(e) => {
|
|
const raw = e.target.value.trim();
|
|
const parsed = raw === "" ? null : Number(raw);
|
|
onChange({
|
|
...row,
|
|
referenceHigh:
|
|
parsed !== null && Number.isFinite(parsed)
|
|
? parsed
|
|
: null,
|
|
});
|
|
}}
|
|
/>
|
|
</div>
|
|
</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. */}
|
|
{showRange ? (
|
|
<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" />
|
|
{t("labs.ocr.valueUnreadable")}
|
|
</p>
|
|
) : lowValueConfidence ? (
|
|
<p className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
|
<AlertCircle aria-hidden className="h-3.5 w-3.5" />
|
|
{t("labs.ocr.lowConfidence")}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|