Merge w10/labsui into release/v1.18.10

Turn the labs add action into a scan/manual choice; add opt-in client-side local
OCR (tesseract.js) so non-vision providers can scan reports; move biomarker
delete to the detail view; make overview tags compact; surface reference-range
confidence for new biomarkers.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 16:51:22 +02:00
commit d819389483
27 files changed
+973 -153

No files matched your search

+16 -44
View File
@@ -2,17 +2,15 @@
import { useMemo } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { ChevronRight, FlaskConical } from "lucide-react";
import { toast } from "sonner";
import { DeleteButton } from "@/components/data-list";
import { MedicationCardHeader } from "@/components/medications/medication-card-header";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import { apiGet } from "@/lib/api/api-fetch";
import { formatDate } from "@/lib/format";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { formatLabReading, formatLabValue } from "@/lib/labs/format-value";
@@ -77,22 +75,8 @@ function groupReadings(results: LabResultDto[]): MarkerGroup[] {
export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { prefs } = useModuleListPrefs("labs");
// Delete a biomarker directly from the list (#41/#3). The endpoint's
// `onDelete: SetNull` FK keeps the readings and unlinks them; both the
// catalog and the result list invalidate so the row drops in the same tick.
const deleteBiomarker = useMutation({
mutationFn: (id: string) => apiDelete(`/api/biomarkers/${id}`),
onSuccess: () => {
toast.success(t("labs.biomarker.deletedToast"));
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
},
onError: () => toast.error(t("labs.biomarker.deleteError")),
});
const listKey = queryKeys.labResultsList({
biomarkerId: undefined,
analyte: undefined,
@@ -181,23 +165,9 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
);
}
// Per-group biomarker delete (#41/#3), surfaced on both views. Only
// catalog-linked groups carry an id the endpoint can delete; legacy
// un-linked rows have no biomarker to remove.
function biomarkerDelete(group: MarkerGroup) {
if (!group.biomarkerId) return null;
const id = group.biomarkerId;
return (
<DeleteButton
onConfirm={() => deleteBiomarker.mutate(id)}
title={t("labs.biomarker.deleteConfirmTitle")}
description={t("labs.biomarker.deleteConfirmDescription")}
confirmLabel={t("labs.biomarker.delete")}
className="size-9"
iconClassName="h-4 w-4"
/>
);
}
// v1.18.10 (#3) — delete lives ONLY on the value detail view (next to Edit),
// not on these overview rows. The list/tile rows navigate; the trash icon was
// removed here so a stray tap can't delete a biomarker from the overview.
// v1.18.6 (MOD-03) — compact list view: one bordered card holding tight
// divided rows instead of a card per biomarker. This is the default view
@@ -219,7 +189,10 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
<span className="truncate font-medium">
{group.analyte}
</span>
<ReferenceRangeBadge status={group.latest.rangeStatus} />
<ReferenceRangeBadge
status={group.latest.rangeStatus}
compact
/>
</div>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-xs">
<span className="text-foreground font-semibold tabular-nums">
@@ -229,8 +202,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
</div>
</div>
);
// The delete control + chevron sit OUTSIDE the navigating Link
// so the delete confirm never also deep-links into the detail.
// The whole row navigates into the detail (where delete lives).
return (
<div
key={group.key}
@@ -255,7 +227,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
/>
</div>
)}
{biomarkerDelete(group)}
</div>
);
})}
@@ -268,9 +239,8 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
// v1.18.9 (#40) — card/tile view. The tile reuses the medication module's
// `MedicationCardHeader` so a lab tile reads identically to a medication or
// Vorsorge tile side-by-side: name on line 1, the reference-range badge as
// the line-1 chip, the panel as the category badge, and the delete control
// in the header's `actions` slot (a sibling of the header Link, so deleting
// never also deep-links).
// the line-1 chip, the panel as the category badge. v1.18.10 (#3) — no delete
// control on the tile; the trash icon lives only on the value detail view.
return (
<ul className="grid list-none gap-4 p-0 sm:grid-cols-2">
{truncated ? (
@@ -288,13 +258,15 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
dose=""
categoryLabel={group.panel ?? group.unit}
nameChip={
<ReferenceRangeBadge status={group.latest.rangeStatus} />
<ReferenceRangeBadge
status={group.latest.rangeStatus}
compact
/>
}
href={
group.biomarkerId ? `/labs/${group.biomarkerId}` : undefined
}
linkLabel={group.analyte}
actions={biomarkerDelete(group)}
/>
<CardContent>
<div className="flex items-center justify-between gap-3">
+26
View File
@@ -19,6 +19,7 @@ import {
FlaskConical,
LayoutGrid,
ListOrdered,
ScanText,
} from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -26,6 +27,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { NativeSelect } from "@/components/ui/native-select";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -42,11 +44,18 @@ import {
import { BiomarkerManager } from "./biomarker-manager";
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
import { useLabsLocalOcr, useUpdateLabsLocalOcr } from "./use-ocr-extract";
export function LabsSettings() {
const { t } = useTranslations();
const { prefs, setView, setOrder, setSortDir } = useModuleListPrefs("labs");
// v1.18.10 — local (in-browser) OCR opt-in. Lets a user whose AI provider
// can't read images still scan a report: the image is OCR'd on-device and
// only the extracted text is sent to the text-only provider.
const localOcr = useLabsLocalOcr();
const updateLocalOcr = useUpdateLabsLocalOcr();
const { data, isLoading } = useQuery({
queryKey: queryKeys.biomarkers(),
queryFn: () => apiGet<BiomarkerListResponse>("/api/biomarkers"),
@@ -120,6 +129,23 @@ export function LabsSettings() {
</SettingsCard>
) : null}
{/* v1.18.10 — local (in-browser) OCR opt-in for lab scans. */}
<SettingsCard id="labs-local-ocr" className="scroll-mt-28 space-y-4">
<SettingsCardHeader
icon={ScanText}
title={t("labs.localOcr.heading")}
description={t("labs.localOcr.description")}
status={
<Switch
checked={localOcr.data?.labsLocalOcrEnabled ?? false}
disabled={localOcr.isLoading || updateLocalOcr.isPending}
onCheckedChange={(checked) => updateLocalOcr.mutate(checked)}
aria-label={t("labs.localOcr.heading")}
/>
}
/>
</SettingsCard>
{/* Biomarker catalog — define / edit / delete. */}
<SettingsCard id="labs-biomarkers" className="scroll-mt-28 space-y-4">
<SettingsCardHeader
+29 -4
View File
@@ -24,12 +24,17 @@ import { seedReviewRows, type OcrReviewRow } from "./ocr-review-types";
import {
useOcrCommit,
useOcrExtract,
useOcrTextExtract,
type OcrCommitRowInput,
} from "./use-ocr-extract";
type Stage = "pick" | "review";
/** How the scan runs: native vision vs in-browser (local) OCR. */
export type OcrMode = "vision" | "text";
const ACCEPT = "image/jpeg,image/png,image/webp,application/pdf";
const ACCEPT_IMAGE_ONLY = "image/jpeg,image/png,image/webp";
/** Map a confirmed review row to the commit payload, or null when invalid. */
function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null {
@@ -88,11 +93,14 @@ function extractErrorMessage(err: unknown, t: (key: string) => string): string {
export function OcrReviewDialog({
open,
onOpenChange,
mode,
pdfSupported,
onCommitted,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Native vision vs in-browser local OCR. Text mode is image-only. */
mode: OcrMode;
pdfSupported: boolean;
onCommitted: () => void;
}) {
@@ -101,8 +109,14 @@ export function OcrReviewDialog({
const [stage, setStage] = useState<Stage>("pick");
const [rows, setRows] = useState<OcrReviewRow[]>([]);
const extract = useOcrExtract();
// Text mode OCR's the image in the browser then POSTs the text; vision mode
// uploads the image. Both resolve with the same proposed-rows DTO.
const visionExtract = useOcrExtract();
const textExtract = useOcrTextExtract();
const extract = mode === "text" ? textExtract : visionExtract;
const commit = useOcrCommit();
// Text mode never accepts PDFs (tesseract.js can't read them).
const allowPdf = mode === "vision" && pdfSupported;
const confirmedCount = useMemo(
() => rows.filter((r) => r.confirmed).length,
@@ -197,7 +211,7 @@ export function OcrReviewDialog({
<input
ref={inputRef}
type="file"
accept={pdfSupported ? ACCEPT : "image/jpeg,image/png,image/webp"}
accept={allowPdf ? ACCEPT : ACCEPT_IMAGE_ONLY}
className="sr-only"
onChange={(e) => {
const file = e.target.files?.[0];
@@ -216,7 +230,12 @@ export function OcrReviewDialog({
<>
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("labs.ocr.extracting")}
{/* Text mode runs OCR on-device first, then structures it —
the first run also downloads the OCR engine, so the copy
sets the "this is reading on your device" expectation. */}
{mode === "text"
? t("labs.ocr.readingOnDevice")
: t("labs.ocr.extracting")}
</span>
</>
) : (
@@ -230,10 +249,16 @@ export function OcrReviewDialog({
</span>
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
<Upload className="h-3.5 w-3.5" aria-hidden />
{pdfSupported
{allowPdf
? t("labs.ocr.acceptWithPdf")
: t("labs.ocr.acceptImageOnly")}
</span>
{/* Honest accuracy caveat for the local-OCR fallback. */}
{mode === "text" ? (
<span className="text-muted-foreground max-w-xs text-xs">
{t("labs.ocr.localModeHint")}
</span>
) : null}
</>
)}
</button>
+32 -1
View File
@@ -15,7 +15,13 @@
*/
import { useId } from "react";
import { AlertCircle, FilePlus2, Link2, TriangleAlert } from "lucide-react";
import {
AlertCircle,
FilePlus2,
Link2,
Ruler,
TriangleAlert,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
@@ -43,6 +49,16 @@ export function OcrRowEditor({
!isQualitative && row.confidence.value < CONFIDENCE_THRESHOLD;
const valueUnreadable = !isQualitative && row.value === null;
// v1.18.10 (#5) — a NEW numeric biomarker mints its catalog reference range
// from THIS extracted row. The range is therefore load-bearing and worth a
// second look. Surface the range confidence prominently for new numeric
// markers, and call it out explicitly when the model was unsure (or set no
// bounds at all) so the user verifies the range it will be judged against.
const isNewNumericMarker = !isQualitative && row.biomarkerMatch === "new";
const lowRangeConfidence = row.confidence.range < CONFIDENCE_THRESHOLD;
const hasBounds = row.referenceLow !== null || row.referenceHigh !== null;
const flagRange = isNewNumericMarker && (lowRangeConfidence || !hasBounds);
return (
<div className="space-y-3 rounded-lg border p-3">
<div className="flex items-start gap-3">
@@ -156,6 +172,21 @@ export function OcrRowEditor({
</div>
</div>
{/* v1.18.10 (#5) — for a NEW numeric 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. */}
{isNewNumericMarker ? (
<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}
{!isQualitative ? (
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
+19 -3
View File
@@ -22,17 +22,33 @@ import type { ReferenceRangeStatus } from "@/lib/validations/labs";
*/
export function ReferenceRangeBadge({
status,
compact = false,
}: {
status: ReferenceRangeStatus;
/**
* v1.18.10 (#4) — a quieter, smaller treatment for the labs overview, where
* the badge sits beside the analyte heading and must not compete with it.
* The hero/detail view keeps the default (un-compact) size.
*/
compact?: boolean;
}) {
const { t } = useTranslations();
if (status === "unknown") return null;
// Compact: drop the icon, shrink the text and padding so the badge reads as a
// subtle qualifier of the heading rather than a second focal point.
const compactClass = compact
? "h-auto gap-0.5 px-1.5 py-0 text-[0.625rem] font-normal [&>svg]:size-2.5"
: "";
if (status === "in-range") {
return (
<Badge variant="outline" className="text-muted-foreground">
<Minus aria-hidden />
<Badge
variant="outline"
className={`text-muted-foreground ${compactClass}`.trim()}
>
{compact ? null : <Minus aria-hidden />}
{t("labs.range.inRange")}
</Badge>
);
@@ -44,7 +60,7 @@ export function ReferenceRangeBadge({
const label =
status === "below" ? t("labs.range.below") : t("labs.range.above");
return (
<Badge variant="secondary">
<Badge variant="secondary" className={compactClass || undefined}>
<Icon aria-hidden />
{label}
</Badge>
+52 -2
View File
@@ -13,7 +13,8 @@
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch, apiPost } from "@/lib/api/api-fetch";
import { apiFetch, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { ocrImageToText } from "@/lib/labs/local-ocr";
import { queryKeys } from "@/lib/query-keys";
import type {
OcrCapabilityDto,
@@ -49,7 +50,7 @@ export function useOcrCapability(enabled: boolean) {
});
}
/** Upload + extract. Resolves with the proposed rows for the review screen. */
/** Upload + extract (VISION mode). Resolves with the proposed review rows. */
export function useOcrExtract() {
return useMutation<OcrExtractResponseDto, Error, File>({
mutationFn: (file: File) => {
@@ -65,6 +66,55 @@ export function useOcrExtract() {
});
}
/**
* TEXT mode (v1.18.10) — OCR the image IN THE BROWSER (tesseract.js), then POST
* only the extracted text. The raw image never leaves the device. Resolves with
* the same proposed-rows DTO the vision path returns, so the review/commit flow
* is shared verbatim.
*/
export function useOcrTextExtract() {
return useMutation<OcrExtractResponseDto, Error, File>({
mutationFn: async (file: File) => {
const text = await ocrImageToText(file);
return apiPost<OcrExtractResponseDto>("/api/labs/ocr/extract", {
mode: "text",
text,
});
},
});
}
/** The local-OCR opt-in preference (read + toggle). */
export interface LabsLocalOcrPref {
labsLocalOcrEnabled: boolean;
}
/** Read the current local-OCR opt-in flag. */
export function useLabsLocalOcr(enabled = true) {
return useQuery<LabsLocalOcrPref>({
queryKey: queryKeys.labsLocalOcr(),
queryFn: () => apiGet<LabsLocalOcrPref>("/api/auth/me/labs-local-ocr"),
enabled,
staleTime: 60_000,
});
}
/** Toggle the local-OCR opt-in; invalidates the flag + the capability probe. */
export function useUpdateLabsLocalOcr() {
const queryClient = useQueryClient();
return useMutation<LabsLocalOcrPref, Error, boolean>({
mutationFn: (labsLocalOcrEnabled: boolean) =>
apiPatch<LabsLocalOcrPref>("/api/auth/me/labs-local-ocr", {
labsLocalOcrEnabled,
}),
onSuccess: (data) => {
queryClient.setQueryData(queryKeys.labsLocalOcr(), data);
// The toggle changes whether text-mode scanning is available.
queryClient.invalidateQueries({ queryKey: queryKeys.ocrCapability() });
},
});
}
/** Commit the confirmed rows and invalidate the dependent reads. */
export function useOcrCommit() {
const queryClient = useQueryClient();