merge(reconcile): r2-labs audit findings

This commit is contained in:
Marc-André Bombeck committed 2026-06-16 00:59:31 +02:00
commit c2537e2c08
12 files changed
+202 -62

No files matched your search

+4
View File
@@ -6439,6 +6439,8 @@
"addButton": "Erinnerung hinzufügen",
"markDone": "Erledigt",
"delete": "Löschen",
"deleteConfirmTitle": "Erinnerung löschen?",
"deleteConfirmDescription": "Diese Vorsorge-Erinnerung wird dauerhaft gelöscht.",
"disabledBadge": "Aus",
"nextDue": {
"none": "Nicht geplant",
@@ -6495,6 +6497,8 @@
"referenceLabel": "Referenz",
"readingsCount": "{count} Werte",
"deleteLatest": "Letzten Wert löschen",
"deleteConfirmTitle": "Laborwert löschen?",
"deleteConfirmDescription": "Dieser Laborwert wird dauerhaft gelöscht.",
"deletedToast": "Wert gelöscht",
"deleteError": "Wert konnte nicht gelöscht werden",
"loadError": "Laborwerte konnten nicht geladen werden",
+4
View File
@@ -6439,6 +6439,8 @@
"addButton": "Add reminder",
"markDone": "Done",
"delete": "Delete",
"deleteConfirmTitle": "Delete reminder?",
"deleteConfirmDescription": "This preventive-care reminder will be permanently deleted.",
"disabledBadge": "Off",
"nextDue": {
"none": "Not scheduled",
@@ -6495,6 +6497,8 @@
"referenceLabel": "Reference",
"readingsCount": "{count} readings",
"deleteLatest": "Delete latest reading",
"deleteConfirmTitle": "Delete lab result?",
"deleteConfirmDescription": "This lab result will be permanently deleted.",
"deletedToast": "Result deleted",
"deleteError": "Could not delete the result",
"loadError": "Could not load lab results",
+4
View File
@@ -6439,6 +6439,8 @@
"addButton": "Añadir recordatorio",
"markDone": "Hecho",
"delete": "Eliminar",
"deleteConfirmTitle": "¿Eliminar recordatorio?",
"deleteConfirmDescription": "Este recordatorio de prevención se eliminará de forma permanente.",
"disabledBadge": "Apagado",
"nextDue": {
"none": "Sin programar",
@@ -6495,6 +6497,8 @@
"referenceLabel": "Referencia",
"readingsCount": "{count} valores",
"deleteLatest": "Eliminar el último valor",
"deleteConfirmTitle": "¿Eliminar resultado de laboratorio?",
"deleteConfirmDescription": "Este resultado de laboratorio se eliminará de forma permanente.",
"deletedToast": "Resultado eliminado",
"deleteError": "No se pudo eliminar el resultado",
"loadError": "No se pudieron cargar los resultados",
+4
View File
@@ -6439,6 +6439,8 @@
"addButton": "Ajouter un rappel",
"markDone": "Terminé",
"delete": "Supprimer",
"deleteConfirmTitle": "Supprimer le rappel ?",
"deleteConfirmDescription": "Ce rappel de prévention sera définitivement supprimé.",
"disabledBadge": "Désactivé",
"nextDue": {
"none": "Non planifié",
@@ -6495,6 +6497,8 @@
"referenceLabel": "Référence",
"readingsCount": "{count} valeurs",
"deleteLatest": "Supprimer la dernière valeur",
"deleteConfirmTitle": "Supprimer le résultat d'analyse ?",
"deleteConfirmDescription": "Ce résultat d'analyse sera définitivement supprimé.",
"deletedToast": "Résultat supprimé",
"deleteError": "Impossible de supprimer le résultat",
"loadError": "Impossible de charger les résultats",
+4
View File
@@ -6439,6 +6439,8 @@
"addButton": "Aggiungi promemoria",
"markDone": "Fatto",
"delete": "Elimina",
"deleteConfirmTitle": "Eliminare il promemoria?",
"deleteConfirmDescription": "Questo promemoria di prevenzione verrà eliminato definitivamente.",
"disabledBadge": "Disattivato",
"nextDue": {
"none": "Non pianificato",
@@ -6495,6 +6497,8 @@
"referenceLabel": "Riferimento",
"readingsCount": "{count} valori",
"deleteLatest": "Elimina l'ultimo valore",
"deleteConfirmTitle": "Eliminare il risultato di laboratorio?",
"deleteConfirmDescription": "Questo risultato di laboratorio verrà eliminato definitivamente.",
"deletedToast": "Risultato eliminato",
"deleteError": "Impossibile eliminare il risultato",
"loadError": "Impossibile caricare i risultati",
+4
View File
@@ -6439,6 +6439,8 @@
"addButton": "Dodaj przypomnienie",
"markDone": "Gotowe",
"delete": "Usuń",
"deleteConfirmTitle": "Usunąć przypomnienie?",
"deleteConfirmDescription": "To przypomnienie profilaktyczne zostanie trwale usunięte.",
"disabledBadge": "Wył.",
"nextDue": {
"none": "Niezaplanowane",
@@ -6495,6 +6497,8 @@
"referenceLabel": "Norma",
"readingsCount": "{count} wyników",
"deleteLatest": "Usuń ostatni wynik",
"deleteConfirmTitle": "Usunąć wynik laboratoryjny?",
"deleteConfirmDescription": "Ten wynik laboratoryjny zostanie trwale usunięty.",
"deletedToast": "Wynik usunięty",
"deleteError": "Nie udało się usunąć wyniku",
"loadError": "Nie udało się wczytać wyników",
+16 -17
View File
@@ -2,9 +2,11 @@
import { useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FlaskConical, Trash2 } from "lucide-react";
import { FlaskConical } from "lucide-react";
import { toast } from "sonner";
import { DeleteButton } from "@/components/data-list";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -166,16 +168,14 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
</CardTitle>
<CardAction className="flex items-center gap-3">
<LabTrendSparkline values={group.readings.map((r) => r.value)} />
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive"
aria-label={t("labs.deleteLatest")}
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate(group.latest.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
<DeleteButton
onConfirm={() => deleteMutation.mutate(group.latest.id)}
title={t("labs.deleteConfirmTitle")}
description={t("labs.deleteConfirmDescription")}
confirmLabel={t("labs.deleteLatest")}
className="size-9"
iconClassName="h-4 w-4"
/>
</CardAction>
</CardHeader>
<CardContent>
@@ -187,12 +187,11 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
group.latest.referenceHigh !== null ? (
<span className="text-xs">
{t("labs.referenceLabel")}{" "}
{group.latest.referenceLow !== null &&
group.latest.referenceHigh !== null
? `${formatValue(group.latest.referenceLow)}–${formatValue(group.latest.referenceHigh)}`
: group.latest.referenceHigh !== null
? `≤ ${formatValue(group.latest.referenceHigh)}`
: `≥ ${formatValue(group.latest.referenceLow as number)}`}
{formatReferenceRange(
group.latest.referenceLow,
group.latest.referenceHigh,
formatValue,
)}
</span>
) : null}
<span className="text-xs">
@@ -13,9 +13,10 @@
* relative to "now" but never recomputes the cadence.
*/
import { useState } from "react";
import { CheckCircle2, Plus, Trash2 } from "lucide-react";
import { CheckCircle2, Plus } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { DeleteButton } from "@/components/data-list";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -365,16 +366,14 @@ function VorsorgeCard({
<CheckCircle2 className="mr-1 h-4 w-4" />
{t("measurementReminders.markDone")}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={onRemove}
disabled={busy}
aria-label={t("measurementReminders.delete")}
>
<Trash2 className="h-4 w-4" />
</Button>
<DeleteButton
onConfirm={onRemove}
title={t("measurementReminders.deleteConfirmTitle")}
description={t("measurementReminders.deleteConfirmDescription")}
confirmLabel={t("measurementReminders.delete")}
className="size-9"
iconClassName="h-4 w-4"
/>
</CardAction>
</CardHeader>
<CardContent className="space-y-1">
+16 -13
View File
@@ -14,6 +14,10 @@ import autoTable from "jspdf-autotable";
import { makeFormatters } from "./format-locale";
import type { Locale } from "./i18n/config";
import { convertGlucose, resolveGlucoseUnit } from "./glucose";
import {
classifyReferenceRange,
formatReferenceRange,
} from "./labs/reference-range";
import type { DoctorReportData } from "./doctor-report-data";
type T = (key: string, params?: Record<string, string | number>) => string;
@@ -1338,15 +1342,8 @@ export function buildDoctorReportPdfDocument(
doc.text(t("doctorReport.labsTitle"), margin, y);
y += 6;
const rangeText = (
low: number | null,
high: number | null,
): string => {
if (low !== null && high !== null) return `${num(low)}–${num(high)}`;
if (high !== null) return `≤ ${num(high)}`;
if (low !== null) return `≥ ${num(low)}`;
return "—";
};
const rangeText = (low: number | null, high: number | null): string =>
formatReferenceRange(low, high, num, { emptyText: "—" });
// Quiet, non-colour status glyph. In-range reads as a neutral dash so
// the table is not a field of warning marks; out-of-range reads as a
// direction arrow the clinician can scan, with no alarm tint.
@@ -1355,10 +1352,16 @@ export function buildDoctorReportPdfDocument(
low: number | null,
high: number | null,
): string => {
if (low === null && high === null) return "";
if (low !== null && value < low) return "↓";
if (high !== null && value > high) return "↑";
return "—";
switch (classifyReferenceRange(value, low, high)) {
case "unknown":
return "";
case "below":
return "↓";
case "above":
return "↑";
default:
return "—";
}
};
const labRows = data.labResults.map((lr) => [
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import {
classifyReferenceRange,
formatReferenceRange,
} from "@/lib/labs/reference-range";
describe("classifyReferenceRange", () => {
it("is unknown when no bounds are reported", () => {
expect(classifyReferenceRange(5, null, null)).toBe("unknown");
expect(classifyReferenceRange(5, undefined, undefined)).toBe("unknown");
});
it("treats both bounds as inclusive", () => {
expect(classifyReferenceRange(4, 4, 6)).toBe("in-range");
expect(classifyReferenceRange(6, 4, 6)).toBe("in-range");
expect(classifyReferenceRange(5, 4, 6)).toBe("in-range");
});
it("flags below / above the full range", () => {
expect(classifyReferenceRange(3.9, 4, 6)).toBe("below");
expect(classifyReferenceRange(6.1, 4, 6)).toBe("above");
});
it("classifies against a high-only bound", () => {
expect(classifyReferenceRange(100, null, 116)).toBe("in-range");
expect(classifyReferenceRange(120, null, 116)).toBe("above");
});
it("classifies against a low-only bound", () => {
expect(classifyReferenceRange(8, 10, null)).toBe("below");
expect(classifyReferenceRange(12, 10, null)).toBe("in-range");
});
});
describe("formatReferenceRange", () => {
// An identity-ish formatter keeps the structure assertions readable.
const fmt = (n: number) => String(n);
it("renders a full low–high range with an en-dash and no spaces", () => {
expect(formatReferenceRange(4, 6, fmt)).toBe("4–6");
});
it("renders a high-only bound with ≤", () => {
expect(formatReferenceRange(null, 116, fmt)).toBe("≤ 116");
});
it("renders a low-only bound with ≥", () => {
expect(formatReferenceRange(10, null, fmt)).toBe("≥ 10");
});
it("returns the empty string for no bounds by default", () => {
expect(formatReferenceRange(null, null, fmt)).toBe("");
expect(formatReferenceRange(undefined, undefined, fmt)).toBe("");
});
it("honours a custom emptyText for the no-bounds case", () => {
expect(formatReferenceRange(null, null, fmt, { emptyText: "—" })).toBe("—");
});
it("defers digit formatting entirely to the callback", () => {
const oneDecimal = (n: number) => n.toFixed(1);
expect(formatReferenceRange(4, 6, oneDecimal)).toBe("4.0–6.0");
});
});
+63
View File
@@ -0,0 +1,63 @@
/**
* v1.18.0 — single source of truth for lab reference-range classification
* and range-string structure.
*
* Three surfaces need the same logic: the API response (`rangeStatus`), the
* doctor-report PDF table, and the lab list card. They were drifting copies.
* The CLASSIFICATION (below / in-range / above / unknown) and the range
* STRUCTURE (`low–high` / `≤ high` / `≥ low` / empty) are shared here; the
* per-surface NUMBER FORMATTING stays a caller concern — the PDF wants a
* locale-aware one-decimal formatter, the list trims to int/2-dec — so
* `formatReferenceRange` takes a `formatNumber` callback and never imposes a
* formatter of its own.
*/
/**
* Reference-range classification. Deliberately a three-state, NEUTRAL
* verdict — the badge that renders it must stay calm and informative, NOT
* alarming (no red "out of range" tint). `"unknown"` when the lab reported
* no usable bounds.
*
* Bounds are treated as inclusive: a value exactly on the reference limit
* reads as in-range, matching how labs print "≤" / "≥" reference notation.
*/
export type ReferenceRangeStatus = "in-range" | "below" | "above" | "unknown";
export function classifyReferenceRange(
value: number,
referenceLow: number | null | undefined,
referenceHigh: number | null | undefined,
): ReferenceRangeStatus {
const hasLow = referenceLow !== null && referenceLow !== undefined;
const hasHigh = referenceHigh !== null && referenceHigh !== undefined;
if (!hasLow && !hasHigh) return "unknown";
if (hasLow && value < (referenceLow as number)) return "below";
if (hasHigh && value > (referenceHigh as number)) return "above";
return "in-range";
}
/**
* Render the reference range as text. Owns only the STRUCTURE — which of the
* four shapes (`low–high`, `≤ high`, `≥ low`, empty) applies — and defers
* every digit to the caller-supplied `formatNumber`, so each surface keeps
* its own number formatting byte-for-byte.
*
* `emptyText` is what to return when the lab reported no bounds at all; it
* defaults to the empty string (the lab list never renders the no-bounds
* case), while the PDF passes a neutral em-dash placeholder.
*/
export function formatReferenceRange(
low: number | null | undefined,
high: number | null | undefined,
formatNumber: (value: number) => string,
opts?: { emptyText?: string },
): string {
const hasLow = low !== null && low !== undefined;
const hasHigh = high !== null && high !== undefined;
if (hasLow && hasHigh) {
return `${formatNumber(low as number)}–${formatNumber(high as number)}`;
}
if (hasHigh) return `≤ ${formatNumber(high as number)}`;
if (hasLow) return `≥ ${formatNumber(low as number)}`;
return opts?.emptyText ?? "";
}
+8 -21
View File
@@ -145,25 +145,12 @@ export const listLabResultsSchema = z.object({
export type ListLabResultsInput = z.infer<typeof listLabResultsSchema>;
/**
* Reference-range classification. Deliberately a three-state, NEUTRAL
* verdict — the badge that renders it must stay calm and informative, NOT
* alarming (no red "out of range" tint). `"unknown"` when the lab reported
* no usable bounds.
*
* Bounds are treated as inclusive: a value exactly on the reference limit
* reads as in-range, matching how labs print "≤" / "≥" reference notation.
* Reference-range classification lives in `@/lib/labs/reference-range` so the
* API response, the doctor-report PDF, and the lab list card all share one
* implementation. Re-exported here for the existing import sites.
*/
export type ReferenceRangeStatus = "in-range" | "below" | "above" | "unknown";
export function classifyReferenceRange(
value: number,
referenceLow: number | null | undefined,
referenceHigh: number | null | undefined,
): ReferenceRangeStatus {
const hasLow = referenceLow !== null && referenceLow !== undefined;
const hasHigh = referenceHigh !== null && referenceHigh !== undefined;
if (!hasLow && !hasHigh) return "unknown";
if (hasLow && value < (referenceLow as number)) return "below";
if (hasHigh && value > (referenceHigh as number)) return "above";
return "in-range";
}
export {
classifyReferenceRange,
formatReferenceRange,
type ReferenceRangeStatus,
} from "@/lib/labs/reference-range";