mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
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:
commit
d819389483
27 files changed
+973
-153
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user