mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
Merge main v1.46.6 into feat/ru-locale
- baselines: take main's entries, keep the two ru-driven line counts (coach-prose-grounding 1091, insight-generator 1115) and regenerate the function baseline. - messages/ru.json: translate the five unit-less markers keys the release added (labs.unit.none/noneHint, labs.ocr.errorUnitMismatchNoUnit, labs.ocr.unitDiffersBadgeNoUnit, recordImport.unitlessHint) — parity is 11 159 keys again.
This commit is contained in:
commit
c6372bde94
113 files changed
+3808
-1080
No files matched your search
@@ -26,6 +26,7 @@ import {
|
||||
type LeafScope,
|
||||
type Translate,
|
||||
} from "./report-sections";
|
||||
import { withLabUnit } from "@/lib/labs/dimensionless";
|
||||
|
||||
/** Join the parts of a composed value, dropping the ones with nothing in. */
|
||||
function compose(parts: Array<string | null | undefined>): string {
|
||||
@@ -73,7 +74,7 @@ export function LabResultsSection({
|
||||
const qualitative = lab.value === null;
|
||||
const reading = qualitative
|
||||
? lab.valueText
|
||||
: `${fmtLab(lab.value as number, lab.sourceValue)} ${lab.unit}`.trim();
|
||||
: withLabUnit(fmtLab(lab.value as number, lab.sourceValue), lab.unit);
|
||||
const range = (
|
||||
low: number | null,
|
||||
high: number | null,
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
InboundDocumentDetailDto,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
import { DocumentFactsSection } from "../document-facts-review";
|
||||
import { DocumentFactsSection, FactRow } from "../document-facts-review";
|
||||
|
||||
/**
|
||||
* The staged-facts block's honest states, pinned by static render:
|
||||
@@ -173,3 +173,56 @@ describe("<DocumentFactsSection>", () => {
|
||||
expect(html).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<FactRow>", () => {
|
||||
// A confident lab value the last save refused (here a ratio printed without
|
||||
// a unit) gets the same edit control as a low-confidence one: it is the only
|
||||
// way to add the unit and finish the review.
|
||||
const unitless = fact({
|
||||
data: {
|
||||
label: "Ratio",
|
||||
code: null,
|
||||
codeSystem: null,
|
||||
value: 4.2,
|
||||
valueText: null,
|
||||
unit: null,
|
||||
referenceLow: null,
|
||||
referenceHigh: 5,
|
||||
referenceText: "<5.0",
|
||||
effectiveDate: "2026-08-01",
|
||||
},
|
||||
provenance: {
|
||||
sourceText: "Ratio 4.2 <5.0",
|
||||
anchored: true,
|
||||
sourceOffset: 10,
|
||||
page: 0,
|
||||
confidence: 0.95,
|
||||
},
|
||||
});
|
||||
|
||||
const rowProps = {
|
||||
documentId: "doc-1",
|
||||
checked: true,
|
||||
onCheckedChange: () => {},
|
||||
chosenBiomarkerId: null,
|
||||
onChooseBiomarker: () => {},
|
||||
markerName: () => null,
|
||||
};
|
||||
|
||||
it("offers the edit control on a confident row whose save was refused", () => {
|
||||
const html = render(
|
||||
<FactRow
|
||||
{...rowProps}
|
||||
fact={unitless}
|
||||
failure="observation.unitRequired"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="document-fact-verify-open"');
|
||||
expect(html).toContain("A numeric value needs a unit");
|
||||
});
|
||||
|
||||
it("leaves a confident row without a refusal free of the edit control", () => {
|
||||
const html = render(<FactRow {...rowProps} fact={fact()} failure={null} />);
|
||||
expect(html).not.toContain('data-slot="document-fact-verify-open"');
|
||||
});
|
||||
});
|
||||
@@ -37,10 +37,12 @@ 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 { LabUnitInput } from "@/components/labs/lab-unit-input";
|
||||
import { MarkerMatchField } from "@/components/labs/marker-match-field";
|
||||
import type { BiomarkerListResponse } from "@/components/labs/types";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import type { DocumentExtractRunResult } from "@/lib/documents/ai-runs/types";
|
||||
import { withLabUnit } from "@/lib/labs/dimensionless";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -81,7 +83,7 @@ function factHeadline(fact: ExtractedFactDto): string {
|
||||
const data = fact.data as ObservationFactData;
|
||||
const value =
|
||||
typeof data.value === "number"
|
||||
? `${data.value}${data.unit ? ` ${data.unit}` : ""}`
|
||||
? withLabUnit(String(data.value), data.unit)
|
||||
: (data.valueText ?? "");
|
||||
return value ? `${data.label}: ${value}` : data.label;
|
||||
}
|
||||
@@ -204,8 +206,8 @@ function draftFor(fact: ExtractedFactDto): VerifyDraft {
|
||||
};
|
||||
}
|
||||
|
||||
/** One pending fact: include-toggle, stated values, verify-first when low-confidence. */
|
||||
function FactRow({
|
||||
/** One pending fact: include-toggle, stated values, and the edit control when it is low-confidence or its last save was refused. */
|
||||
export function FactRow({
|
||||
documentId,
|
||||
fact,
|
||||
checked,
|
||||
@@ -221,7 +223,7 @@ function FactRow({
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
/** A per-fact commit refusal from the last confirm attempt, or null. */
|
||||
failure: string | null;
|
||||
/** v1.43 — the suggestion the person picked for a lab value, or null. */
|
||||
/** The suggestion the person picked for a lab value, or null. */
|
||||
chosenBiomarkerId: string | null;
|
||||
onChooseBiomarker: (biomarkerId: string | null) => void;
|
||||
/** The account's marker name by id, for the matched marker. */
|
||||
@@ -250,8 +252,7 @@ function FactRow({
|
||||
{
|
||||
onSuccess: () => {
|
||||
setVerifying(false);
|
||||
// The refetched fact arrives with needsReview cleared; include it —
|
||||
// the person just asserted these values.
|
||||
// Include it: the refetched fact clears needsReview, as asserted.
|
||||
onCheckedChange(true);
|
||||
},
|
||||
},
|
||||
@@ -319,9 +320,10 @@ function FactRow({
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{fact.needsReview && !verifying ? (
|
||||
{(fact.needsReview || failure !== null) && !verifying ? (
|
||||
<Button
|
||||
type="button"
|
||||
data-slot="document-fact-verify-open"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 shrink-0 sm:min-h-8"
|
||||
@@ -411,13 +413,12 @@ function FactRow({
|
||||
? t("documents.review.fieldUnit")
|
||||
: t("documents.review.fieldDose")}
|
||||
</Label>
|
||||
<Input
|
||||
<LabUnitInput
|
||||
id={`fact-unit-${fact.id}`}
|
||||
value={draft.unit}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, unit: e.target.value }))
|
||||
}
|
||||
onChange={(unit) => setDraft((d) => ({ ...d, unit }))}
|
||||
maxLength={isObservation ? 80 : 120}
|
||||
allowNoUnit={isObservation}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -22,14 +22,9 @@ import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
|
||||
import { CoachConversation } from "./coach-conversation";
|
||||
|
||||
// v1.12.0 — `useResettableValue` + `nextResettableValue` moved into
|
||||
// `./use-resettable-value` so the shared `<CoachConversation>` surface
|
||||
// can seed its composer without importing the drawer shell. Re-exported
|
||||
// here for the existing prefill-controller unit test import path.
|
||||
export {
|
||||
useResettableValue,
|
||||
nextResettableValue,
|
||||
} from "./use-resettable-value";
|
||||
// `nextResettableValue` lives in `./use-resettable-value` beside its hook;
|
||||
// it is re-exported here for the prefill-controller unit test's import path.
|
||||
export { nextResettableValue } from "./use-resettable-value";
|
||||
|
||||
/**
|
||||
* v1.28.52 (Documents R3) — the full-page target the maximize control hands
|
||||
|
||||
@@ -43,20 +43,6 @@ export const BAND_BORDER_CLASS: Record<ScoreBand, string> = {
|
||||
red: "border-destructive/40",
|
||||
};
|
||||
|
||||
/**
|
||||
* The raw CSS variable per band — used where a Recharts `fill` needs an
|
||||
* actual colour string rather than a Tailwind class (the score ring's
|
||||
* `<RadialBar fill>`). Reads the semantic tokens so dark and Alucard light
|
||||
* modes track the AA-safe light overrides automatically (the
|
||||
* `--dracula-*` primitives carry no light override and would fail AA on
|
||||
* the white card).
|
||||
*/
|
||||
export const BAND_VAR: Record<ScoreBand, string> = {
|
||||
green: "var(--success)",
|
||||
yellow: "var(--warning)",
|
||||
red: "var(--destructive)",
|
||||
};
|
||||
|
||||
/**
|
||||
* Map a 0..100 score to its band. Mirrors the thresholds the existing
|
||||
* health-score blend uses (≥ 70 green, ≥ 40 yellow, else red) so a
|
||||
|
||||
@@ -1,43 +1,8 @@
|
||||
/**
|
||||
* v1.10.0 — the derived-metrics design-system primitives barrel.
|
||||
*
|
||||
* Four reusable, presentational components that extend the existing token +
|
||||
* sentiment vocabulary; none introduce a new colour system. Reused across
|
||||
* the dashboard, Insights pages, the Coach / briefing and the doctor/FHIR
|
||||
* report. See `.planning/v1.10-build/design-sota-and-direction.md`.
|
||||
* The derived-metrics surfaces the Insights page loads on demand. Their
|
||||
* building blocks (score ring, coverage meter, band tokens and the rest) are
|
||||
* imported from their own modules by whatever uses them.
|
||||
*/
|
||||
|
||||
export { ScoreRing, type ScoreRingProps } from "./score-ring";
|
||||
export { CoverageMeter, type CoverageMeterProps } from "./coverage-meter";
|
||||
export {
|
||||
SparklineDeltaTile,
|
||||
type SparklineDeltaTileProps,
|
||||
} from "./sparkline-delta-tile";
|
||||
export {
|
||||
ProvenanceExplainer,
|
||||
type ProvenanceExplainerProps,
|
||||
type ProvenanceStandard,
|
||||
} from "./provenance-explainer";
|
||||
export {
|
||||
ScoreAnatomyView,
|
||||
type ScoreAnatomyViewProps,
|
||||
type AnatomyContributor,
|
||||
} from "./score-anatomy-view";
|
||||
export {
|
||||
bandForScore,
|
||||
clampScore,
|
||||
BAND_NUMBER_CLASS,
|
||||
BAND_PROGRESS_CLASS,
|
||||
BAND_BORDER_CLASS,
|
||||
BAND_VAR,
|
||||
type ScoreBand,
|
||||
} from "./band-tokens";
|
||||
|
||||
// ── W2b vitals dashboard surface + query hook ────────────────────────
|
||||
export { VitalsDashboard } from "./vitals-dashboard";
|
||||
export { WellnessScores } from "./wellness-scores";
|
||||
export {
|
||||
useDerivedMetric,
|
||||
type DerivedMetricResponse,
|
||||
type UseDerivedMetricOptions,
|
||||
} from "./use-derived-metric";
|
||||
@@ -11,6 +11,7 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import type { LabChange } from "@/lib/insights/labs-changes";
|
||||
import { InsightSectionCard } from "./insight-section-card";
|
||||
import { withLabUnit } from "@/lib/labs/dimensionless";
|
||||
|
||||
/**
|
||||
* v1.25 — "what changed since your last panel" card.
|
||||
@@ -81,7 +82,7 @@ export function LabsChangesCard({
|
||||
const signedDelta =
|
||||
c.direction === "flat"
|
||||
? t("insights.labsChanges.directionFlat")
|
||||
: `${c.delta > 0 ? "+" : ""}${c.delta} ${c.unit}`.trim();
|
||||
: withLabUnit(`${c.delta > 0 ? "+" : ""}${c.delta}`, c.unit);
|
||||
return (
|
||||
<div
|
||||
key={c.analyte}
|
||||
@@ -93,7 +94,7 @@ export function LabsChangesCard({
|
||||
{c.analyte}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{`${c.latest} ${c.unit}`.trim()}
|
||||
{withLabUnit(String(c.latest), c.unit)}
|
||||
{labelKey ? ` · ${t(labelKey)}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { primeLocales } from "@/__tests__/helpers/prime-locales";
|
||||
import { LabTrendCard } from "../lab-trend-card";
|
||||
import type { BiomarkerTrendDto } from "../types";
|
||||
|
||||
primeLocales("de");
|
||||
primeLocales("de", "ko");
|
||||
|
||||
const OK: BiomarkerTrendDto = {
|
||||
status: "ok",
|
||||
@@ -29,11 +29,12 @@ const OK: BiomarkerTrendDto = {
|
||||
|
||||
function render(
|
||||
trend: BiomarkerTrendDto | undefined,
|
||||
locale: "en" | "de" = "en",
|
||||
locale: "en" | "de" | "ko" = "en",
|
||||
unit = "mg/dL",
|
||||
) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<LabTrendCard trend={trend} unit="mg/dL" />
|
||||
<LabTrendCard trend={trend} unit={unit} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
@@ -48,6 +49,16 @@ describe("<LabTrendCard>", () => {
|
||||
expect(html).toContain("4.25 to 20.75 mg/dL");
|
||||
});
|
||||
|
||||
it("leaves no gap where a value without a unit has none, in any language", () => {
|
||||
const en = render(OK, "en", "1");
|
||||
expect(en).toContain("Rising by about 13 a year across 4 readings");
|
||||
expect(en).toContain("4.25 to 20.75.");
|
||||
const ko = render(OK, "ko", "1");
|
||||
expect(ko).toContain("약 13씩");
|
||||
expect(ko).not.toMatch(/ 씩/);
|
||||
expect(ko).not.toContain("\u0000");
|
||||
});
|
||||
|
||||
it("speaks German with du", () => {
|
||||
const html = render(OK, "de");
|
||||
expect(html).toContain("deines Referenzbereichs");
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { userEvent } from "vitest/browser";
|
||||
|
||||
import { renderWithApp } from "../../../../tests/browser/render";
|
||||
import { LabUnitInput } from "../lab-unit-input";
|
||||
|
||||
/**
|
||||
* The unit field as a form holds it, typed into key by key. A unit that
|
||||
* starts with a 1 (10^9/L, 1/min) passes through "1" on the way, and that
|
||||
* keystroke must not turn into the "no unit" choice.
|
||||
*/
|
||||
function Controlled({ initial = "" }: { initial?: string }) {
|
||||
const [unit, setUnit] = useState(initial);
|
||||
return (
|
||||
<>
|
||||
<label htmlFor="u">Unit</label>
|
||||
<LabUnitInput id="u" value={unit} onChange={setUnit} />
|
||||
<output data-testid="unit">{unit}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("<LabUnitInput> typed into", () => {
|
||||
it("keeps a unit that starts with a 1", async () => {
|
||||
const { screen } = await renderWithApp(<Controlled />);
|
||||
await userEvent.type(screen.getByLabelText("Unit"), "10^9/L");
|
||||
await expect
|
||||
.element(screen.getByTestId("unit"))
|
||||
.toHaveTextContent("10^9/L");
|
||||
await expect
|
||||
.element(screen.getByRole("checkbox", { name: /no unit/i }))
|
||||
.not.toBeChecked();
|
||||
});
|
||||
|
||||
it("shows a stored unity as the ticked choice and gives the field back on unticking", async () => {
|
||||
const { screen } = await renderWithApp(<Controlled initial="1" />);
|
||||
const box = screen.getByRole("checkbox", { name: /no unit/i });
|
||||
await expect.element(box).toBeChecked();
|
||||
await expect.element(screen.getByLabelText("Unit")).toBeDisabled();
|
||||
await box.click();
|
||||
await expect.element(screen.getByLabelText("Unit")).toBeEnabled();
|
||||
await userEvent.type(screen.getByLabelText("Unit"), "mmol/L");
|
||||
await expect
|
||||
.element(screen.getByTestId("unit"))
|
||||
.toHaveTextContent("mmol/L");
|
||||
});
|
||||
|
||||
it("sets the unity when the box is ticked", async () => {
|
||||
const { screen } = await renderWithApp(<Controlled initial="mg/dL" />);
|
||||
await screen.getByRole("checkbox", { name: /no unit/i }).click();
|
||||
await expect
|
||||
.poll(() => screen.getByTestId("unit").element().textContent)
|
||||
.toBe("1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import en from "../../../../messages/en.json";
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/api/api-fetch")>()),
|
||||
apiPost: vi.fn(),
|
||||
apiPut: vi.fn(),
|
||||
}));
|
||||
|
||||
import { BiomarkerForm } from "../biomarker-form";
|
||||
import { LabUnitInput } from "../lab-unit-input";
|
||||
import type { OcrReviewRow } from "../ocr-review-types";
|
||||
import { OcrRowEditor } from "../ocr-row-editor";
|
||||
|
||||
/**
|
||||
* The explicit "no unit" choice for a ratio or an index. It is offered next to
|
||||
* every lab unit field a person fills in, it shows as chosen when the unit is
|
||||
* the UCUM unity `"1"`, and it never reads an empty unit as that choice.
|
||||
*/
|
||||
|
||||
function render(node: React.ReactNode): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const NONE = en.labs.unit.none;
|
||||
|
||||
function noneBox(html: string): string {
|
||||
const box = html.match(/<button[^>]*role="checkbox"[^>]*>/)?.[0];
|
||||
if (!box) throw new Error("no checkbox rendered");
|
||||
return box;
|
||||
}
|
||||
|
||||
describe("<LabUnitInput>", () => {
|
||||
it("offers the choice and leaves it unticked for an empty unit", () => {
|
||||
const html = render(
|
||||
<LabUnitInput id="u" value="" onChange={() => {}} required />,
|
||||
);
|
||||
expect(html).toContain(NONE);
|
||||
expect(html).toContain(en.labs.unit.noneHint);
|
||||
expect(noneBox(html)).toContain('aria-checked="false"');
|
||||
expect(html).toMatch(/<input[^>]*id="u"[^>]*required/);
|
||||
});
|
||||
|
||||
it("shows the unity as the ticked choice, not as a typed 1", () => {
|
||||
const html = render(
|
||||
<LabUnitInput id="u" value="1" onChange={() => {}} required />,
|
||||
);
|
||||
expect(noneBox(html)).toContain('aria-checked="true"');
|
||||
const input = html.match(/<input[^>]*id="u"[^>]*>/)?.[0] ?? "";
|
||||
expect(input).toContain('value=""');
|
||||
expect(input).toContain("disabled");
|
||||
expect(input).not.toContain("required");
|
||||
});
|
||||
|
||||
it("leaves a real unit as it is", () => {
|
||||
const html = render(
|
||||
<LabUnitInput id="u" value="mg/dL" onChange={() => {}} />,
|
||||
);
|
||||
expect(noneBox(html)).toContain('aria-checked="false"');
|
||||
expect(html).toContain('value="mg/dL"');
|
||||
});
|
||||
|
||||
it("is a plain field where no unit makes no sense", () => {
|
||||
const html = render(
|
||||
<LabUnitInput id="u" value="1" onChange={() => {}} allowNoUnit={false} />,
|
||||
);
|
||||
expect(html).not.toContain(NONE);
|
||||
expect(html).toContain('value="1"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("the no-unit choice on the lab forms", () => {
|
||||
it("is offered when a marker is defined", () => {
|
||||
const html = render(<BiomarkerForm />);
|
||||
expect(html).toContain(NONE);
|
||||
});
|
||||
|
||||
it("is offered on a scanned report's row", () => {
|
||||
const row: OcrReviewRow = {
|
||||
key: "INR-0",
|
||||
analyte: "INR",
|
||||
value: 1.1,
|
||||
valueText: null,
|
||||
unit: null,
|
||||
referenceLow: 0.8,
|
||||
referenceHigh: 1.2,
|
||||
referenceText: null,
|
||||
takenAt: null,
|
||||
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
|
||||
biomarkerMatch: "new",
|
||||
markerUnit: null,
|
||||
markerAnalyteKey: null,
|
||||
duplicateOf: null,
|
||||
biomarkerId: null,
|
||||
matchKind: null,
|
||||
suggestions: [],
|
||||
chosenBiomarkerId: null,
|
||||
confirmed: true,
|
||||
};
|
||||
const html = render(<OcrRowEditor row={row} onChange={() => {}} />);
|
||||
expect(html).toContain(NONE);
|
||||
});
|
||||
|
||||
it("names a marker without a unit in words where a scanned row disagrees", () => {
|
||||
const row: OcrReviewRow = {
|
||||
key: "INR-1",
|
||||
analyte: "INR",
|
||||
value: 1.1,
|
||||
valueText: null,
|
||||
unit: "s",
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
referenceText: null,
|
||||
takenAt: null,
|
||||
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
|
||||
biomarkerMatch: "existing",
|
||||
markerUnit: "1",
|
||||
markerAnalyteKey: null,
|
||||
duplicateOf: null,
|
||||
biomarkerId: "bm-inr",
|
||||
matchKind: "name",
|
||||
suggestions: [],
|
||||
chosenBiomarkerId: null,
|
||||
confirmed: true,
|
||||
};
|
||||
const html = render(
|
||||
<OcrRowEditor
|
||||
row={row}
|
||||
onChange={() => {}}
|
||||
errors={{ unitMismatch: true }}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain(en.labs.ocr.errorUnitMismatchNoUnit);
|
||||
expect(html).toContain("Tracked without a unit; this report says s");
|
||||
expect(html).not.toContain("tracked in 1");
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from "@/lib/labs/biomarker-catalog";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { LabUnitInput } from "./lab-unit-input";
|
||||
import type { BiomarkerDto } from "./types";
|
||||
|
||||
const CONTEXT_MAX_LENGTH = 2000;
|
||||
@@ -219,10 +220,10 @@ export function BiomarkerForm({
|
||||
<Label htmlFor="biomarker-unit">
|
||||
{t("labs.biomarker.form.unit")}
|
||||
</Label>
|
||||
<Input
|
||||
<LabUnitInput
|
||||
id="biomarker-unit"
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.target.value)}
|
||||
onChange={setUnit}
|
||||
placeholder={t("labs.biomarker.form.unitPlaceholder")}
|
||||
maxLength={40}
|
||||
required
|
||||
|
||||
@@ -24,6 +24,8 @@ import { BiomarkerForm } from "./biomarker-form";
|
||||
import { BiomarkerMergeSheet } from "./biomarker-merge-sheet";
|
||||
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
|
||||
import { useLabNumber } from "./use-lab-format";
|
||||
import { withLabUnit } from "@/lib/labs/dimensionless";
|
||||
import { LabUnitName } from "./lab-unit-input";
|
||||
|
||||
/**
|
||||
* v1.18.1 — the Biomarker catalog manager. v1.22 — compact editable rows.
|
||||
@@ -124,11 +126,11 @@ export function BiomarkerManager() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<span className="truncate text-sm font-medium">{marker.name}</span>
|
||||
<span className="text-muted-foreground text-xs">{marker.unit}</span>
|
||||
<LabUnitName unit={marker.unit} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{range
|
||||
? `${t("labs.referenceLabel")} ${range} ${marker.unit}`
|
||||
? `${t("labs.referenceLabel")} ${withLabUnit(range, marker.unit)}`
|
||||
: t("labs.biomarker.noRange")}
|
||||
{marker.panel ? ` · ${marker.panel}` : ""}
|
||||
</p>
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import type { BiomarkerDto, BiomarkerMergeResultDto } from "./types";
|
||||
import { labUnitLabel } from "@/lib/labs/dimensionless";
|
||||
|
||||
/** The most sources one merge takes, matching the server. */
|
||||
const MAX_SOURCES = 10;
|
||||
@@ -166,7 +167,7 @@ export function BiomarkerMergeSheet({
|
||||
<p className="text-sm">
|
||||
{tCount("labs.merge.previewSummary", preview.movedReadings, {
|
||||
name: preview.target.name,
|
||||
unit: preview.target.unit,
|
||||
unit: labUnitLabel(preview.target.unit, t),
|
||||
})}
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
@@ -178,7 +179,7 @@ export function BiomarkerMergeSheet({
|
||||
<p className="text-sm font-medium break-words">
|
||||
{source.name}{" "}
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
{source.unit}
|
||||
{labUnitLabel(source.unit, t)}
|
||||
</span>
|
||||
</p>
|
||||
<p
|
||||
@@ -190,8 +191,8 @@ export function BiomarkerMergeSheet({
|
||||
>
|
||||
{source.conversion === "refused"
|
||||
? t("labs.merge.refused", {
|
||||
unit: source.unit,
|
||||
targetUnit: preview.target.unit,
|
||||
unit: labUnitLabel(source.unit, t),
|
||||
targetUnit: labUnitLabel(preview.target.unit, t),
|
||||
})
|
||||
: source.conversion === "converted"
|
||||
? tCount("labs.merge.converted", source.readingCount, {
|
||||
@@ -247,7 +248,7 @@ export function BiomarkerMergeSheet({
|
||||
{marker.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{marker.unit}
|
||||
{labUnitLabel(marker.unit, t)}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
|
||||
@@ -61,6 +61,7 @@ import type {
|
||||
LabResultDto,
|
||||
LabResultListResponse,
|
||||
} from "./types";
|
||||
import { labUnitLabel, labUnitText } from "@/lib/labs/dimensionless";
|
||||
|
||||
// v1.18.11 — defer the recharts biomarker chart through
|
||||
// `next/dynamic` so recharts is off `/labs/[biomarkerId]`'s first-load JS.
|
||||
@@ -426,7 +427,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
one description line every page header carries. */}
|
||||
{marker?.unit ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{marker.unit}
|
||||
{labUnitLabel(marker.unit, t)}
|
||||
{marker.panel ? ` · ${marker.panel}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -492,7 +493,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
numeric readings, mirroring the metric sub-pages. */}
|
||||
<MetricStatStrip
|
||||
summary={summary}
|
||||
unit={marker?.unit ?? latest?.unit ?? ""}
|
||||
unit={labUnitText(marker?.unit ?? latest?.unit)}
|
||||
seriesLabel={marker?.name}
|
||||
icon={FlaskConical}
|
||||
/>
|
||||
@@ -500,7 +501,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
<CardContent>
|
||||
<LabBiomarkerChart
|
||||
readings={readings}
|
||||
unit={marker?.unit ?? latest?.unit ?? ""}
|
||||
unit={labUnitText(marker?.unit ?? latest?.unit)}
|
||||
lowerBound={marker?.lowerBound ?? null}
|
||||
upperBound={marker?.upperBound ?? null}
|
||||
/>
|
||||
@@ -508,7 +509,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
</Card>
|
||||
<LabTrendCard
|
||||
trend={marker?.trend}
|
||||
unit={marker?.unit ?? latest?.unit ?? ""}
|
||||
unit={labUnitText(marker?.unit ?? latest?.unit)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -39,6 +39,11 @@ import type {
|
||||
BiomarkerListResponse,
|
||||
LabResultDto,
|
||||
} from "./types";
|
||||
import {
|
||||
labUnitLabel,
|
||||
labUnitText,
|
||||
withLabUnit,
|
||||
} from "@/lib/labs/dimensionless";
|
||||
|
||||
const NOTE_MAX_LENGTH = 2000;
|
||||
const DEFINE_NEW = "__define_new__";
|
||||
@@ -330,9 +335,8 @@ export function LabForm({
|
||||
? formatReferenceRange(selected.lowerBound, selected.upperBound, labNumber)
|
||||
: "";
|
||||
|
||||
// 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.
|
||||
// What the typed string resolved to, shown back before the save. When no
|
||||
// window was read out of it, the hint says the text stays as written.
|
||||
const parsedSourceRange = parseReferenceRange(sourceRange, selected?.unit);
|
||||
const sourceRangeHint = parsedSourceRange
|
||||
? isUnreadableRange(parsedSourceRange)
|
||||
@@ -341,7 +345,7 @@ export function LabForm({
|
||||
parsedSourceRange.low,
|
||||
parsedSourceRange.high,
|
||||
labNumber,
|
||||
)} ${selected?.unit ?? ""}`.trim()
|
||||
)} ${labUnitText(selected?.unit)}`.trim()
|
||||
: "";
|
||||
|
||||
const footerNode = (
|
||||
@@ -406,7 +410,7 @@ export function LabForm({
|
||||
<SelectContent>
|
||||
{markers.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name} · {m.unit}
|
||||
{m.name} · {labUnitLabel(m.unit, t)}
|
||||
</SelectItem>
|
||||
))}
|
||||
{!lockedBiomarkerId ? (
|
||||
@@ -457,7 +461,7 @@ export function LabForm({
|
||||
{selected ? (
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{" "}
|
||||
({selected.unit})
|
||||
({labUnitLabel(selected.unit, t)})
|
||||
</span>
|
||||
) : null}
|
||||
</Label>
|
||||
@@ -515,7 +519,8 @@ export function LabForm({
|
||||
|
||||
{resultType === "numeric" && selected && referenceText ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.referenceLabel")} {referenceText} {selected.unit}
|
||||
{t("labs.referenceLabel")}{" "}
|
||||
{withLabUnit(referenceText, selected.unit)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
} from "./lab-result-related-links";
|
||||
import type { LabResultDto } from "./types";
|
||||
import { useLabDate, useLabNumber } from "./use-lab-format";
|
||||
import { labUnitText } from "@/lib/labs/dimensionless";
|
||||
|
||||
const NOTE_MAX_LENGTH = 2000;
|
||||
|
||||
@@ -103,7 +104,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
reading.value === null
|
||||
? (reading.valueText ?? "")
|
||||
: labNumber(reading.value, reading.sourceValue),
|
||||
unit: reading.value === null ? undefined : reading.unit,
|
||||
unit: reading.value === null ? undefined : labUnitText(reading.unit),
|
||||
compare: before
|
||||
? {
|
||||
label: t("day.previousReading", { date: labDate(before.takenAt) }),
|
||||
|
||||
@@ -31,6 +31,7 @@ import type {
|
||||
LabOverviewResponse,
|
||||
LabResultDto,
|
||||
} from "./types";
|
||||
import { labUnitLabel } from "@/lib/labs/dimensionless";
|
||||
|
||||
interface MarkerGroup {
|
||||
/** Stable group key: `bm:<biomarkerId>`, or `analyte:<lower>` for legacy rows. */
|
||||
@@ -39,6 +40,7 @@ interface MarkerGroup {
|
||||
biomarkerId: string | null;
|
||||
analyte: string;
|
||||
panel: string | null;
|
||||
/** The marker's unit as the card names it ("No unit" for a ratio). */
|
||||
unit: string;
|
||||
/** Every live reading of the marker, counted by the server. */
|
||||
count: number;
|
||||
@@ -53,13 +55,16 @@ interface MarkerGroup {
|
||||
* inert until the backfill links them. The server already orders by the
|
||||
* newest reading.
|
||||
*/
|
||||
function toGroups(markers: LabOverviewMarkerDto[]): MarkerGroup[] {
|
||||
function toGroups(
|
||||
markers: LabOverviewMarkerDto[],
|
||||
t: (key: string) => string,
|
||||
): MarkerGroup[] {
|
||||
return markers.map((marker) => ({
|
||||
key: marker.key,
|
||||
biomarkerId: marker.latest.biomarkerId,
|
||||
analyte: marker.latest.analyte,
|
||||
panel: marker.latest.panel,
|
||||
unit: marker.latest.unit,
|
||||
unit: labUnitLabel(marker.latest.unit, t),
|
||||
count: marker.count,
|
||||
trend: marker.trend,
|
||||
latest: marker.latest,
|
||||
@@ -152,7 +157,7 @@ export function LabList({
|
||||
});
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const base = toGroups(data?.markers ?? []);
|
||||
const base = toGroups(data?.markers ?? [], t);
|
||||
// v1.18.6 — honour the user's Labs sort choice. The server
|
||||
// order is most-recent-first; `recentAsc` reverses, the alpha
|
||||
// options sort by analyte name (#43), and `manual` applies the persisted
|
||||
@@ -173,7 +178,7 @@ export function LabList({
|
||||
return applyOrder(base, prefs.order, (g) => g.biomarkerId ?? g.key);
|
||||
}
|
||||
return base;
|
||||
}, [data?.markers, prefs.sortDir, prefs.order]);
|
||||
}, [data?.markers, prefs.sortDir, prefs.order, t]);
|
||||
|
||||
const listEmpty = !isLoading && !isError && groups.length === 0;
|
||||
useEffect(() => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RangeBar } from "@/components/targets/range-bar";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useLabNumber } from "./use-lab-format";
|
||||
import { labUnitText, withLabUnit } from "@/lib/labs/dimensionless";
|
||||
|
||||
/**
|
||||
* v1.37.34 — compact reference-range visualization for saved lab readings.
|
||||
@@ -43,11 +44,14 @@ export function LabReferenceRangeBar({
|
||||
const scale = Math.max(Math.abs(referenceLow ?? referenceHigh!), 1);
|
||||
const min = referenceLow ?? referenceHigh! - scale;
|
||||
const max = referenceHigh ?? referenceLow! + scale;
|
||||
const rangeLabel = !hasLow
|
||||
? `≤${labNumber(referenceHigh!)} ${unit}`
|
||||
: !hasHigh
|
||||
? `≥${labNumber(referenceLow!)} ${unit}`
|
||||
: `${labNumber(referenceLow!)}–${labNumber(referenceHigh!)} ${unit}`;
|
||||
const rangeLabel = withLabUnit(
|
||||
!hasLow
|
||||
? `≤${labNumber(referenceHigh!)}`
|
||||
: !hasHigh
|
||||
? `≥${labNumber(referenceLow!)}`
|
||||
: `${labNumber(referenceLow!)}–${labNumber(referenceHigh!)}`,
|
||||
unit,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -59,7 +63,7 @@ export function LabReferenceRangeBar({
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
unit={unit}
|
||||
unit={labUnitText(unit)}
|
||||
compact
|
||||
tone="lab"
|
||||
formatValue={labNumber}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import type { BiomarkerTrendDto } from "./types";
|
||||
import { labUnitText } from "@/lib/labs/dimensionless";
|
||||
|
||||
/**
|
||||
* v1.44 — the marker's slope over all its readings, and where that straight
|
||||
@@ -35,12 +36,21 @@ export function LabTrendCard({
|
||||
: trend.slopePerYear < 0
|
||||
? "falling"
|
||||
: "flat";
|
||||
const slopeLine = t(`labs.trend.slope.${direction}`, {
|
||||
amount: per(trend.slopePerYear),
|
||||
unit,
|
||||
n: trend.n,
|
||||
since: fmt.dateShortSmartCalendar(trend.firstAt),
|
||||
});
|
||||
const shownUnit = labUnitText(unit);
|
||||
// A value without a unit fills the {unit} slot with a marker and drops it
|
||||
// together with the space before it, so no language keeps the gap ("0.3 .",
|
||||
// "0.3 씩").
|
||||
const SLOT = "\u0000";
|
||||
const tidy = (text: string) =>
|
||||
shownUnit ? text : text.replace(new RegExp(` ?${SLOT}`, "g"), "");
|
||||
const slopeLine = tidy(
|
||||
t(`labs.trend.slope.${direction}`, {
|
||||
amount: per(trend.slopePerYear),
|
||||
unit: shownUnit || SLOT,
|
||||
n: trend.n,
|
||||
since: fmt.dateShortSmartCalendar(trend.firstAt),
|
||||
}),
|
||||
);
|
||||
const projectionLine =
|
||||
trend.projection === "exits" && trend.exitDate
|
||||
? t(`labs.trend.projection.exits.${trend.exitBound ?? "high"}`, {
|
||||
@@ -85,11 +95,13 @@ export function LabTrendCard({
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.trend.interval", {
|
||||
lo: fmt.number(trend.slopeCi95[0], 2),
|
||||
hi: fmt.number(trend.slopeCi95[1], 2),
|
||||
unit,
|
||||
})}
|
||||
{tidy(
|
||||
t("labs.trend.interval", {
|
||||
lo: fmt.number(trend.slopeCi95[0], 2),
|
||||
hi: fmt.number(trend.slopeCi95[1], 2),
|
||||
unit: shownUnit || SLOT,
|
||||
}),
|
||||
)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useRef, useState } from "react";
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
DIMENSIONLESS_UNIT,
|
||||
isDimensionlessUnit,
|
||||
labUnitLabel,
|
||||
} from "@/lib/labs/dimensionless";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** A marker's unit as a muted label, "No unit" for a ratio or an index. */
|
||||
export function LabUnitName({
|
||||
unit,
|
||||
className,
|
||||
}: {
|
||||
unit: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<span className={cn("text-muted-foreground text-xs", className)}>
|
||||
{labUnitLabel(unit, t)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface LabUnitInputProps {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (unit: string) => void;
|
||||
maxLength?: number;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
"aria-describedby"?: string;
|
||||
"aria-invalid"?: boolean;
|
||||
/**
|
||||
* Offer the explicit "no unit" choice. Off for a field that only shares the
|
||||
* input, such as a medication dose on the document review.
|
||||
*/
|
||||
allowNoUnit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The unit of a lab value, with an explicit "no unit" choice for a ratio or an
|
||||
* index. Ticking it sets the unit to the UCUM unity `"1"`, which every write
|
||||
* path accepts as a real unit; leaving the field empty is still refused, so a
|
||||
* unit an extraction dropped is never taken for a value that has none. Only
|
||||
* the box makes the choice: a unit typed key by key passes through "1" on its
|
||||
* way to `10^9/L`, and that keystroke stays text.
|
||||
*/
|
||||
export function LabUnitInput({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
maxLength,
|
||||
placeholder,
|
||||
required,
|
||||
allowNoUnit = true,
|
||||
...aria
|
||||
}: LabUnitInputProps) {
|
||||
const { t } = useTranslations();
|
||||
const noneId = useId();
|
||||
const hintId = `${noneId}-hint`;
|
||||
// Set when a keystroke, not the box, left the field at "1".
|
||||
const [typedOne, setTypedOne] = useState(false);
|
||||
const none = allowNoUnit && isDimensionlessUnit(value) && !typedOne;
|
||||
// What was typed before the box was ticked, so unticking gives it back.
|
||||
const typed = useRef(none ? "" : value);
|
||||
|
||||
const input = (
|
||||
<Input
|
||||
id={id}
|
||||
value={none ? "" : value}
|
||||
onChange={(e) => {
|
||||
typed.current = e.target.value;
|
||||
setTypedOne(isDimensionlessUnit(e.target.value));
|
||||
onChange(e.target.value);
|
||||
}}
|
||||
placeholder={none ? t("labs.unit.none") : placeholder}
|
||||
maxLength={maxLength}
|
||||
required={required && !none}
|
||||
disabled={none}
|
||||
{...aria}
|
||||
/>
|
||||
);
|
||||
if (!allowNoUnit) return input;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5" data-slot="lab-unit-input">
|
||||
{input}
|
||||
<label
|
||||
htmlFor={noneId}
|
||||
className="flex min-h-11 cursor-pointer items-start gap-2 sm:min-h-0"
|
||||
>
|
||||
<Checkbox
|
||||
id={noneId}
|
||||
checked={none}
|
||||
onCheckedChange={(state) => {
|
||||
setTypedOne(false);
|
||||
onChange(
|
||||
state === true
|
||||
? DIMENSIONLESS_UNIT
|
||||
: isDimensionlessUnit(typed.current)
|
||||
? ""
|
||||
: typed.current,
|
||||
);
|
||||
}}
|
||||
aria-describedby={hintId}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="space-y-0.5">
|
||||
<span className="block text-sm">{t("labs.unit.none")}</span>
|
||||
<span id={hintId} className="text-muted-foreground block text-xs">
|
||||
{t("labs.unit.noneHint")}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
import { BiomarkerManager } from "./biomarker-manager";
|
||||
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
|
||||
import { useLabsLocalOcr, useUpdateLabsLocalOcr } from "./use-ocr-extract";
|
||||
import { labUnitLabel } from "@/lib/labs/dimensionless";
|
||||
|
||||
export function LabsSettings() {
|
||||
const { t } = useTranslations();
|
||||
@@ -61,7 +62,7 @@ export function LabsSettings() {
|
||||
const reorderItems: ReorderItem[] = ordered.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
secondary: m.panel ? `${m.unit} · ${m.panel}` : m.unit,
|
||||
secondary: [labUnitLabel(m.unit, t), m.panel].filter(Boolean).join(" · "),
|
||||
}));
|
||||
|
||||
const manual = prefs.sortDir === "manual";
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { labUnitLabel } from "@/lib/labs/dimensionless";
|
||||
|
||||
export interface MarkerSuggestionDto {
|
||||
biomarkerId: string;
|
||||
@@ -110,7 +111,7 @@ export function MarkerMatchField({
|
||||
<option key={suggestion.biomarkerId} value={suggestion.biomarkerId}>
|
||||
{t("labs.match.suggestionOption", {
|
||||
name: suggestion.name,
|
||||
unit: suggestion.unit,
|
||||
unit: labUnitLabel(suggestion.unit, t),
|
||||
})}
|
||||
</option>
|
||||
))}
|
||||
|
||||
@@ -24,12 +24,14 @@ 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 {
|
||||
@@ -51,6 +53,28 @@ function RequiredMark() {
|
||||
);
|
||||
}
|
||||
|
||||
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 ?? "" }];
|
||||
}
|
||||
|
||||
export function OcrRowEditor({
|
||||
row,
|
||||
onChange,
|
||||
@@ -197,12 +221,7 @@ export function OcrRowEditor({
|
||||
{unitBlocked ? (
|
||||
<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>
|
||||
<span className="truncate">{unitDiffersText(row, t)}</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
{conversion ? (
|
||||
@@ -294,10 +313,10 @@ export function OcrRowEditor({
|
||||
{t("labs.ocr.unitLabel")}
|
||||
<RequiredMark />
|
||||
</Label>
|
||||
<Input
|
||||
<LabUnitInput
|
||||
id={`${fieldId}-unit`}
|
||||
value={row.unit ?? ""}
|
||||
onChange={(e) => onChange({ ...row, unit: e.target.value })}
|
||||
onChange={(unit) => onChange({ ...row, unit })}
|
||||
{...invalidProps(
|
||||
errors?.unitMismatch
|
||||
? "unitMismatch"
|
||||
@@ -310,9 +329,7 @@ export function OcrRowEditor({
|
||||
{fieldError("unitTooLong", "labs.ocr.errorTooLong", {
|
||||
max: String(OCR_UNIT_MAX),
|
||||
})}
|
||||
{fieldError("unitMismatch", "labs.ocr.errorUnitMismatch", {
|
||||
markerUnit: row.markerUnit ?? "",
|
||||
})}
|
||||
{fieldError("unitMismatch", ...unitMismatchMessage(row))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import type { LabResultDto } from "./types";
|
||||
import { useLabNumber } from "./use-lab-format";
|
||||
import { withLabUnit } from "@/lib/labs/dimensionless";
|
||||
|
||||
/**
|
||||
* The reference window a reading's own report printed, shown wherever that
|
||||
@@ -54,11 +55,14 @@ export function SourceRangeNote({
|
||||
// then stated from the converted bounds, in the marker's unit.
|
||||
const inForce =
|
||||
reading.sourceReferenceText ??
|
||||
`${formatReferenceRange(
|
||||
reading.referenceLow,
|
||||
reading.referenceHigh,
|
||||
labNumber,
|
||||
)} ${reading.unit}`.trim();
|
||||
withLabUnit(
|
||||
formatReferenceRange(
|
||||
reading.referenceLow,
|
||||
reading.referenceHigh,
|
||||
labNumber,
|
||||
),
|
||||
reading.unit,
|
||||
);
|
||||
|
||||
const catalog = formatReferenceRange(
|
||||
reading.catalogReferenceLow,
|
||||
@@ -77,7 +81,7 @@ export function SourceRangeNote({
|
||||
: null}
|
||||
{reading.referenceDivergesFromCatalog && catalog
|
||||
? ` · ${t("labs.sourceRange.catalogDiffers", {
|
||||
range: `${catalog} ${reading.unit}`.trim(),
|
||||
range: withLabUnit(catalog, reading.unit),
|
||||
})}`
|
||||
: null}
|
||||
</span>
|
||||
|
||||
@@ -100,6 +100,26 @@ export function RecordCsvImportCard() {
|
||||
);
|
||||
}
|
||||
|
||||
/** What the sheet says about reading the file, below the format choices. */
|
||||
function ImportNotes({ kind }: { kind: RecordImportKind }) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recordImport.timezoneNote")}
|
||||
</p>
|
||||
{kind === "labs" ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="record-import-unitless-hint"
|
||||
>
|
||||
{t("recordImport.unitlessHint")}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordCsvImportSheet({
|
||||
kind,
|
||||
onOpenChange,
|
||||
@@ -421,9 +441,7 @@ function RecordCsvImportSheet({
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recordImport.timezoneNote")}
|
||||
</p>
|
||||
<ImportNotes kind={kind} />
|
||||
|
||||
<div
|
||||
className="max-h-48 overflow-auto overscroll-contain rounded-lg border"
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
formatDayMonthYear,
|
||||
} from "./timeline-dates";
|
||||
import { seriesNameLines, type TimelineLayout } from "./timeline-geometry";
|
||||
import { withLabUnit } from "@/lib/labs/dimensionless";
|
||||
|
||||
/** "18 ng/mL", every digit the reading holds, in the reader's locale. */
|
||||
export type LabValueText = (
|
||||
@@ -50,7 +51,7 @@ export function useLabValueText(): LabValueText {
|
||||
const labNumber = useLabNumber();
|
||||
return useCallback(
|
||||
(value, sourceValue, unit) =>
|
||||
`${labNumber(value, sourceValue)} ${unit}`.trim(),
|
||||
withLabUnit(labNumber(value, sourceValue), unit),
|
||||
[labNumber],
|
||||
);
|
||||
}
|
||||
@@ -84,7 +85,7 @@ export function useLabReference(): (
|
||||
(v) => labNumber(v),
|
||||
);
|
||||
return range
|
||||
? t("timeline.labs.reference", { range: `${range} ${unit}`.trim() })
|
||||
? t("timeline.labs.reference", { range: withLabUnit(range, unit) })
|
||||
: null;
|
||||
},
|
||||
[t, labNumber],
|
||||
|
||||
Reference in new issue
Block a user