mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
The marker list offers a merge with the server's preview: what moves, what is converted, which names become aliases, and a refusal when a unit cannot be converted; it says a merge cannot be undone. The marker page shows and edits the other spellings and follows a merged marker to its target. Scan and document review name the matched marker or offer its suggestions and a new marker; the choice is remembered. The biomarkerMerge feature flag is on.
193 lines
6.7 KiB
TypeScript
193 lines
6.7 KiB
TypeScript
/**
|
|
* Per-row validation for the Lab-OCR review screen.
|
|
*
|
|
* A confirmed row can only be written when it carries everything the commit
|
|
* payload needs. `validateReviewRow` reports WHICH fields are missing so the
|
|
* review screen can mark them, and `toCommitRow` is the same rule applied to
|
|
* build the payload: it returns null exactly when `validateReviewRow` reports
|
|
* at least one field.
|
|
*/
|
|
import { convertLabValue } from "@/lib/labs/unit-conversion/convert";
|
|
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" | "unitMismatch" | "date";
|
|
|
|
/** The fields of one row that block saving; empty when the row is writable. */
|
|
export type OcrRowErrors = Partial<Record<OcrRowField, true>>;
|
|
|
|
/** A calendar day → an ISO instant at noon UTC, avoiding a TZ day-shift. */
|
|
function dayAtNoonUtc(day: string | null): Date | null {
|
|
if (!day) return null;
|
|
const at = new Date(`${day}T12:00:00.000Z`);
|
|
return Number.isNaN(at.getTime()) ? null : at;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* What Save will store for a numeric row whose unit differs from its marker's
|
|
* and can be converted: the value in the marker's unit, beside what the report
|
|
* stated. Null when the units agree, the row is qualitative, or the pair has no
|
|
* conversion. The commit does the same conversion on the server; this only lets
|
|
* the screen say so before Save.
|
|
*/
|
|
export function readingConversion(row: {
|
|
value: number | null | undefined;
|
|
valueText: string | null | undefined;
|
|
unit: string | null | undefined;
|
|
markerUnit: string | null | undefined;
|
|
markerAnalyteKey: string | null | undefined;
|
|
}): {
|
|
value: number;
|
|
unit: string;
|
|
statedValue: number;
|
|
statedUnit: string;
|
|
} | null {
|
|
if (!readingUnitDiffers(row)) return null;
|
|
if (row.value === null || row.value === undefined) return null;
|
|
if (!Number.isFinite(row.value)) return null;
|
|
const statedUnit = (row.unit ?? "").trim();
|
|
const converted = convertLabValue({
|
|
analyteKey: row.markerAnalyteKey,
|
|
value: row.value,
|
|
from: statedUnit,
|
|
to: row.markerUnit as string,
|
|
});
|
|
if (!converted.ok) return null;
|
|
return {
|
|
value: converted.value,
|
|
unit: row.markerUnit as string,
|
|
statedValue: row.value,
|
|
statedUnit,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A numeric reading in another unit than its marker's that cannot be
|
|
* converted: the one case that still cannot be saved. A row with no number yet
|
|
* has nothing to convert, so it is blocked here as well as on `value`.
|
|
*/
|
|
export function readingUnitBlocked(row: {
|
|
value: number | null | undefined;
|
|
valueText: string | null | undefined;
|
|
unit: string | null | undefined;
|
|
markerUnit: string | null | undefined;
|
|
markerAnalyteKey: string | null | undefined;
|
|
}): boolean {
|
|
if (!readingUnitDiffers(row)) return false;
|
|
return readingConversion(row) === null;
|
|
}
|
|
|
|
export function validateReviewRow(row: OcrReviewRow): OcrRowErrors {
|
|
const errors: OcrRowErrors = {};
|
|
if (!row.analyte.trim()) errors.analyte = true;
|
|
if (!dayAtNoonUtc(row.takenAt)) errors.date = true;
|
|
|
|
if (isQualitativeRow(row)) {
|
|
if (!(row.valueText ?? "").trim()) errors.valueText = true;
|
|
} else {
|
|
if (row.value === null || !Number.isFinite(row.value)) errors.value = true;
|
|
if (!(row.unit ?? "").trim()) errors.unit = true;
|
|
if (readingUnitBlocked(row)) errors.unitMismatch = true;
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
export function hasRowErrors(errors: OcrRowErrors): boolean {
|
|
return Object.keys(errors).length > 0;
|
|
}
|
|
|
|
/** Map a confirmed review row to the commit payload, or null when invalid. */
|
|
export function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null {
|
|
const analyte = row.analyte.trim();
|
|
const takenAt = dayAtNoonUtc(row.takenAt);
|
|
if (!analyte || !takenAt) return null;
|
|
|
|
if (isQualitativeRow(row)) {
|
|
const valueText = (row.valueText ?? "").trim();
|
|
if (!valueText) return null;
|
|
return {
|
|
analyte,
|
|
valueText,
|
|
takenAt: takenAt.toISOString(),
|
|
...chosen(row),
|
|
};
|
|
}
|
|
|
|
if (row.value === null || !Number.isFinite(row.value)) return null;
|
|
const unit = (row.unit ?? "").trim();
|
|
if (!unit) return null;
|
|
if (readingUnitBlocked(row)) return null;
|
|
return {
|
|
analyte,
|
|
value: row.value,
|
|
unit,
|
|
takenAt: takenAt.toISOString(),
|
|
...(row.referenceLow !== null ? { referenceLow: row.referenceLow } : {}),
|
|
...(row.referenceHigh !== null ? { referenceHigh: row.referenceHigh } : {}),
|
|
...(row.referenceText ? { referenceText: row.referenceText } : {}),
|
|
...chosen(row),
|
|
};
|
|
}
|
|
|
|
/** The marker the person picked, which the commit then files the row under. */
|
|
function chosen(row: OcrReviewRow): { biomarkerId?: string } {
|
|
return row.chosenBiomarkerId ? { biomarkerId: row.chosenBiomarkerId } : {};
|
|
}
|
|
|
|
/** Blocking fields per CONFIRMED row, keyed by the row's key; writable rows are absent. */
|
|
export function collectRowErrors(
|
|
rows: readonly OcrReviewRow[],
|
|
): Map<string, OcrRowErrors> {
|
|
const byKey = new Map<string, OcrRowErrors>();
|
|
for (const row of rows) {
|
|
if (!row.confirmed) continue;
|
|
const errors = validateReviewRow(row);
|
|
if (hasRowErrors(errors)) byKey.set(row.key, errors);
|
|
}
|
|
return byKey;
|
|
}
|
|
|
|
/**
|
|
* What pressing Save does. A save is all-or-nothing over the selected rows:
|
|
* one unwritable row blocks the whole save instead of being dropped, because a
|
|
* reading that is silently left out is a reading the person believes was kept.
|
|
*/
|
|
export type OcrSavePlan =
|
|
| { kind: "nothing-selected" }
|
|
| { kind: "blocked" }
|
|
| { kind: "ready"; payload: OcrCommitRowInput[] };
|
|
|
|
export function planSave(rows: readonly OcrReviewRow[]): OcrSavePlan {
|
|
const confirmed = rows.filter((row) => row.confirmed);
|
|
if (confirmed.length === 0) return { kind: "nothing-selected" };
|
|
if (collectRowErrors(confirmed).size > 0) return { kind: "blocked" };
|
|
const payload = confirmed
|
|
.map(toCommitRow)
|
|
.filter((row): row is OcrCommitRowInput => row !== null);
|
|
return { kind: "ready", payload };
|
|
}
|