mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge remote-tracking branch 'origin/main' into wip/v140-E
This commit is contained in:
commit
f3ccc809a8
28 files changed
+891
-38
No files matched your search
@@ -12,6 +12,7 @@ import {
|
||||
handleFilePickerChange,
|
||||
OcrPageCoverageNote,
|
||||
OcrReviewDialog,
|
||||
unitSkippedAnalytes,
|
||||
} from "../ocr-review-dialog";
|
||||
|
||||
const hookState = vi.hoisted(() => ({ extractPending: false }));
|
||||
@@ -223,3 +224,40 @@ describe("<OcrPageCoverageNote> — a long PDF read from its first pages", () =>
|
||||
expect(html).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unitSkippedAnalytes", () => {
|
||||
const unit = (analyte: string) => ({
|
||||
analyte,
|
||||
reason: "unit_mismatch" as const,
|
||||
});
|
||||
|
||||
it("is null when nothing was skipped for a unit", () => {
|
||||
expect(unitSkippedAnalytes([])).toBeNull();
|
||||
expect(
|
||||
unitSkippedAnalytes([{ analyte: "LDL", reason: "duplicate" }]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("names the analytes skipped for a unit, ignoring duplicates", () => {
|
||||
expect(
|
||||
unitSkippedAnalytes([
|
||||
{ analyte: "LDL", reason: "duplicate" },
|
||||
unit("Glucose"),
|
||||
unit("Creatinine"),
|
||||
]),
|
||||
).toBe("Glucose, Creatinine");
|
||||
});
|
||||
|
||||
it("lists three and counts the rest, once each", () => {
|
||||
expect(
|
||||
unitSkippedAnalytes([
|
||||
unit("A"),
|
||||
unit("A"),
|
||||
unit("B"),
|
||||
unit("C"),
|
||||
unit("D"),
|
||||
unit("E"),
|
||||
]),
|
||||
).toBe("A, B, C +2");
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { OcrReviewRow } from "../ocr-review-types";
|
||||
import { seedReviewRows } from "../ocr-review-types";
|
||||
import {
|
||||
collectRowErrors,
|
||||
planSave,
|
||||
readingUnitDiffers,
|
||||
toCommitRow,
|
||||
validateReviewRow,
|
||||
} from "../ocr-review-validation";
|
||||
@@ -21,6 +23,7 @@ function row(overrides: Partial<OcrReviewRow> = {}): OcrReviewRow {
|
||||
takenAt: "2026-06-10",
|
||||
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
|
||||
biomarkerMatch: "existing",
|
||||
markerUnit: "mmol/L",
|
||||
duplicateOf: null,
|
||||
confirmed: true,
|
||||
...overrides,
|
||||
@@ -140,3 +143,72 @@ describe("planSave", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("a reading in another unit than its marker's", () => {
|
||||
it.each([
|
||||
["the same unit", { unit: "mmol/L" }, false],
|
||||
["the same unit, spelled differently", { unit: "mmol/l" }, false],
|
||||
["a different unit", { unit: "mg/dL" }, true],
|
||||
["a different magnitude of the same substance", { unit: "µmol/L" }, true],
|
||||
["a new marker (no unit yet)", { unit: "mg/dL", markerUnit: null }, false],
|
||||
["no unit stated", { unit: "", markerUnit: "mmol/L" }, false],
|
||||
[
|
||||
"a qualitative result",
|
||||
{ value: null, valueText: "negative", unit: "mg/dL" },
|
||||
false,
|
||||
],
|
||||
] as const)("%s → differs: %s", (_name, overrides, differs) => {
|
||||
expect(readingUnitDiffers(row(overrides))).toBe(differs);
|
||||
});
|
||||
|
||||
it("is a blocking error on the unit field, distinct from a missing unit", () => {
|
||||
expect(validateReviewRow(row({ unit: "mg/dL" }))).toEqual({
|
||||
unitMismatch: true,
|
||||
});
|
||||
expect(validateReviewRow(row({ unit: "" }))).toEqual({ unit: true });
|
||||
});
|
||||
|
||||
it("cannot be turned into a payload, whatever its other fields say", () => {
|
||||
expect(toCommitRow(row({ unit: "mg/dL" }))).toBeNull();
|
||||
expect(toCommitRow(row({ unit: "mmol/l" }))?.unit).toBe("mmol/l");
|
||||
});
|
||||
|
||||
it("blocks the whole save when it is selected, and not when it is not", () => {
|
||||
expect(
|
||||
planSave([row({ key: "a" }), row({ key: "b", unit: "mg/dL" })]),
|
||||
).toEqual({ kind: "blocked" });
|
||||
const plan = planSave([
|
||||
row({ key: "a" }),
|
||||
row({ key: "b", unit: "mg/dL", confirmed: false }),
|
||||
]);
|
||||
expect(plan.kind).toBe("ready");
|
||||
expect(
|
||||
collectRowErrors([row({ unit: "mg/dL", confirmed: false })]).size,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("starts unselected on the review screen, like a duplicate, so it is a choice", () => {
|
||||
const dto = (unit: string, markerUnit: string | null) => ({
|
||||
analyte: "Glucose",
|
||||
value: 5.6,
|
||||
valueText: null,
|
||||
unit,
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
referenceText: null,
|
||||
takenAt: "2026-06-10",
|
||||
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
|
||||
biomarkerMatch: "existing" as const,
|
||||
markerUnit,
|
||||
duplicateOf: null,
|
||||
});
|
||||
const [same, other, fresh] = seedReviewRows(
|
||||
[dto("mmol/L", "mmol/L"), dto("mmol/L", "mg/dL"), dto("mmol/L", null)],
|
||||
null,
|
||||
);
|
||||
expect(same.confirmed).toBe(true);
|
||||
expect(other.confirmed).toBe(false);
|
||||
expect(other.markerUnit).toBe("mg/dL");
|
||||
expect(fresh.confirmed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ const baseRow: OcrReviewRow = {
|
||||
takenAt: null,
|
||||
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
|
||||
biomarkerMatch: "existing",
|
||||
markerUnit: "mmol/L",
|
||||
duplicateOf: null,
|
||||
confirmed: true,
|
||||
};
|
||||
@@ -115,3 +116,42 @@ describe("<OcrRowEditor> layout", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("<OcrRowEditor> unit differs from the marker's", () => {
|
||||
const mismatched: OcrReviewRow = {
|
||||
...baseRow,
|
||||
takenAt: "2026-06-10",
|
||||
unit: "mg/dL",
|
||||
markerUnit: "mmol/L",
|
||||
};
|
||||
|
||||
it("says so before any save attempt, in calm wording", () => {
|
||||
const html = render(undefined, "en", mismatched);
|
||||
expect(html).toContain("Tracked in mmol/L; this report says mg/dL");
|
||||
expect(html).not.toContain('aria-invalid="true"');
|
||||
expect(html).not.toContain('role="alert"');
|
||||
});
|
||||
|
||||
it("marks the unit field and names the marker's unit once a save was refused", () => {
|
||||
const html = render({ unitMismatch: true }, "en", mismatched);
|
||||
const invalid = html.match(/<input[^>]*aria-invalid="true"[^>]*>/g) ?? [];
|
||||
expect(invalid).toHaveLength(1);
|
||||
expect(invalid[0]).toMatch(/id="[^"]*-unit"/);
|
||||
expect(html).toContain("This marker is tracked in mmol/L.");
|
||||
});
|
||||
|
||||
it("shows nothing when the units agree, however they are spelled", () => {
|
||||
const html = render(undefined, "en", { ...mismatched, unit: "mmol/l" });
|
||||
expect(html).not.toContain("Tracked in");
|
||||
});
|
||||
|
||||
it("shows nothing for a new marker, which adopts the row's unit", () => {
|
||||
const html = render(undefined, "en", { ...mismatched, markerUnit: null });
|
||||
expect(html).not.toContain("Tracked in");
|
||||
});
|
||||
|
||||
it("speaks German too", () => {
|
||||
const html = render(undefined, "de", mismatched);
|
||||
expect(html).toContain("Geführt in mmol/L; dieser Befund nennt mg/dL");
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import type { OcrSkippedRowDto } from "@/lib/validations/labs-ocr";
|
||||
import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
@@ -53,6 +54,25 @@ export function handleFilePickerChange(
|
||||
if (file) onFilePicked(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* The analytes the commit skipped for a unit other than their marker's, as one
|
||||
* short phrase for a toast (the first three, then a count), or null when none.
|
||||
*/
|
||||
export function unitSkippedAnalytes(
|
||||
skipped: readonly OcrSkippedRowDto[],
|
||||
): string | null {
|
||||
const names = [
|
||||
...new Set(
|
||||
skipped
|
||||
.filter((row) => row.reason === "unit_mismatch")
|
||||
.map((row) => row.analyte),
|
||||
),
|
||||
];
|
||||
if (names.length === 0) return null;
|
||||
const shown = names.slice(0, 3).join(", ");
|
||||
return names.length > 3 ? `${shown} +${names.length - 3}` : shown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate an extract failure into a friendly, error-code-aware message.
|
||||
* Refusals are read by `meta.errorCode`: a 403 is a missing consent only when
|
||||
@@ -241,19 +261,32 @@ export function OcrReviewDialog({
|
||||
// used to be green on any 200 and print the count regardless; the
|
||||
// server resolves the outcome from the two lists and the dialog
|
||||
// renders it.
|
||||
// A row skipped for its unit is the one the person has to act on, so
|
||||
// when there is one the message names it; the plain "already
|
||||
// recorded" wording would be wrong for it.
|
||||
const unitSkipped = unitSkippedAnalytes(result.skipped);
|
||||
toastWrittenOutcome(
|
||||
result.outcome,
|
||||
result.outcome === "success"
|
||||
? t("labs.ocr.savedToast", { count: result.inserted.length })
|
||||
: result.outcome === "partial"
|
||||
? t("labs.ocr.savedPartialToast", {
|
||||
count: result.inserted.length,
|
||||
skipped: result.skipped.length,
|
||||
})
|
||||
: result.outcome === "failed"
|
||||
? t("labs.ocr.savedNothingToast", {
|
||||
? unitSkipped
|
||||
? t("labs.ocr.savedPartialUnitToast", {
|
||||
count: result.inserted.length,
|
||||
analytes: unitSkipped,
|
||||
})
|
||||
: t("labs.ocr.savedPartialToast", {
|
||||
count: result.inserted.length,
|
||||
skipped: result.skipped.length,
|
||||
})
|
||||
: result.outcome === "failed"
|
||||
? unitSkipped
|
||||
? t("labs.ocr.savedNothingUnitToast", {
|
||||
analytes: unitSkipped,
|
||||
})
|
||||
: t("labs.ocr.savedNothingToast", {
|
||||
skipped: result.skipped.length,
|
||||
})
|
||||
: t("labs.ocr.savedEmptyToast"),
|
||||
);
|
||||
onCommitted();
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
*/
|
||||
import type { OcrExtractedRowDto } from "@/lib/validations/labs-ocr";
|
||||
|
||||
import { readingUnitDiffers } from "./ocr-review-validation";
|
||||
|
||||
export interface OcrReviewRow {
|
||||
/** Stable client key for the list (the analyte+index at seed time). */
|
||||
key: string;
|
||||
@@ -22,6 +24,11 @@ export interface OcrReviewRow {
|
||||
takenAt: string | null;
|
||||
confidence: OcrExtractedRowDto["confidence"];
|
||||
biomarkerMatch: "new" | "existing";
|
||||
/**
|
||||
* The unit the matched marker is tracked in; null for a marker that does not
|
||||
* exist yet. A numeric row whose `unit` differs from it cannot be saved.
|
||||
*/
|
||||
markerUnit: string | null;
|
||||
/** Non-null when the server flagged a likely duplicate of a live reading. */
|
||||
duplicateOf: string | null;
|
||||
/** Whether the row is selected for saving. Defaults false for duplicates. */
|
||||
@@ -45,8 +52,18 @@ export function seedReviewRows(
|
||||
takenAt: row.takenAt ?? fallbackDate,
|
||||
confidence: row.confidence,
|
||||
biomarkerMatch: row.biomarkerMatch,
|
||||
markerUnit: row.markerUnit,
|
||||
duplicateOf: row.duplicateOf,
|
||||
// A flagged duplicate starts unchecked; everything else starts confirmed.
|
||||
confirmed: row.duplicateOf === null,
|
||||
// A flagged duplicate starts unchecked, and so does a reading in another
|
||||
// unit than its marker's (it carries a badge saying why, and ticking it
|
||||
// marks the unit field until it is fixed); everything else starts
|
||||
// confirmed.
|
||||
confirmed:
|
||||
row.duplicateOf === null &&
|
||||
!readingUnitDiffers({
|
||||
valueText: row.valueText,
|
||||
unit: row.unit,
|
||||
markerUnit: row.markerUnit,
|
||||
}),
|
||||
}));
|
||||
}
|
||||
@@ -7,10 +7,13 @@
|
||||
* build the payload: it returns null exactly when `validateReviewRow` reports
|
||||
* at least one field.
|
||||
*/
|
||||
import { sameLabUnit } from "@/lib/labs/unit-normalise";
|
||||
|
||||
import type { OcrReviewRow } from "./ocr-review-types";
|
||||
import type { OcrCommitRowInput } from "./use-ocr-extract";
|
||||
|
||||
export type OcrRowField = "analyte" | "value" | "valueText" | "unit" | "date";
|
||||
export type OcrRowField =
|
||||
"analyte" | "value" | "valueText" | "unit" | "unitMismatch" | "date";
|
||||
|
||||
/** The fields of one row that block saving; empty when the row is writable. */
|
||||
export type OcrRowErrors = Partial<Record<OcrRowField, true>>;
|
||||
@@ -26,6 +29,23 @@ function isQualitativeRow(row: OcrReviewRow): boolean {
|
||||
return row.valueText !== null && row.valueText !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a numeric reading states a unit other than the one its marker is
|
||||
* tracked in. A qualitative reading has no unit, a new marker adopts the row's
|
||||
* own, and a row with no unit yet is a different problem (`unit`), so none of
|
||||
* those count. The same rule the commit applies when it skips such a row.
|
||||
*/
|
||||
export function readingUnitDiffers(row: {
|
||||
valueText: string | null | undefined;
|
||||
unit: string | null | undefined;
|
||||
markerUnit: string | null | undefined;
|
||||
}): boolean {
|
||||
if (row.valueText !== null && row.valueText !== undefined) return false;
|
||||
const stated = (row.unit ?? "").trim();
|
||||
if (!row.markerUnit || !stated) return false;
|
||||
return !sameLabUnit(stated, row.markerUnit);
|
||||
}
|
||||
|
||||
export function validateReviewRow(row: OcrReviewRow): OcrRowErrors {
|
||||
const errors: OcrRowErrors = {};
|
||||
if (!row.analyte.trim()) errors.analyte = true;
|
||||
@@ -36,6 +56,7 @@ export function validateReviewRow(row: OcrReviewRow): OcrRowErrors {
|
||||
} else {
|
||||
if (row.value === null || !Number.isFinite(row.value)) errors.value = true;
|
||||
if (!(row.unit ?? "").trim()) errors.unit = true;
|
||||
if (readingUnitDiffers(row)) errors.unitMismatch = true;
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
@@ -59,6 +80,7 @@ export function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null {
|
||||
if (row.value === null || !Number.isFinite(row.value)) return null;
|
||||
const unit = (row.unit ?? "").trim();
|
||||
if (!unit) return null;
|
||||
if (readingUnitDiffers(row)) return null;
|
||||
return {
|
||||
analyte,
|
||||
value: row.value,
|
||||
|
||||
@@ -32,7 +32,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import type { OcrReviewRow } from "./ocr-review-types";
|
||||
import type { OcrRowErrors } from "./ocr-review-validation";
|
||||
import { readingUnitDiffers, type OcrRowErrors } from "./ocr-review-validation";
|
||||
|
||||
/** Below this per-field confidence the field is flagged for a second look. */
|
||||
const CONFIDENCE_THRESHOLD = 0.6;
|
||||
@@ -65,10 +65,21 @@ export function OcrRowEditor({
|
||||
"aria-describedby": `${fieldId}-${field}-error`,
|
||||
}
|
||||
: {};
|
||||
const fieldError = (field: keyof OcrRowErrors, messageKey: string) =>
|
||||
const fieldError = (
|
||||
field: keyof OcrRowErrors,
|
||||
messageKey: string,
|
||||
params?: Record<string, string>,
|
||||
) =>
|
||||
errors?.[field] ? (
|
||||
<FieldError id={`${fieldId}-${field}-error`} message={t(messageKey)} />
|
||||
<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 cannot be written, and the person should see why
|
||||
// the row is unticked (or will not save) while they are still reading it.
|
||||
const unitDiffers = readingUnitDiffers(row);
|
||||
|
||||
const isQualitative = row.valueText !== null && row.valueText !== undefined;
|
||||
const lowValueConfidence =
|
||||
@@ -158,6 +169,17 @@ export function OcrRowEditor({
|
||||
{t("labs.ocr.duplicateWarning")}
|
||||
</Badge>
|
||||
) : null}
|
||||
{unitDiffers ? (
|
||||
<Badge variant="secondary" className="max-w-full">
|
||||
<Ruler aria-hidden />
|
||||
<span className="truncate">
|
||||
{t("labs.ocr.unitDiffersBadge", {
|
||||
markerUnit: row.markerUnit ?? "",
|
||||
unit: (row.unit ?? "").trim(),
|
||||
})}
|
||||
</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
{lowRowConfidence ? (
|
||||
<Badge variant="secondary" className="text-muted-foreground">
|
||||
<AlertCircle aria-hidden />
|
||||
@@ -224,9 +246,14 @@ export function OcrRowEditor({
|
||||
id={`${fieldId}-unit`}
|
||||
value={row.unit ?? ""}
|
||||
onChange={(e) => onChange({ ...row, unit: e.target.value })}
|
||||
{...invalidProps("unit")}
|
||||
{...invalidProps(
|
||||
errors?.unitMismatch ? "unitMismatch" : "unit",
|
||||
)}
|
||||
/>
|
||||
{fieldError("unit", "labs.ocr.errorUnit")}
|
||||
{fieldError("unitMismatch", "labs.ocr.errorUnitMismatch", {
|
||||
markerUnit: row.markerUnit ?? "",
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user