diff --git a/messages/de.json b/messages/de.json
index 43caea3e7..9abff5ab1 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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",
diff --git a/messages/en.json b/messages/en.json
index 8283bcb7e..2a1315079 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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",
diff --git a/messages/es.json b/messages/es.json
index c1a856c45..b44f90227 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -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",
diff --git a/messages/fr.json b/messages/fr.json
index fe2b34b3a..7510ec4ef 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -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",
diff --git a/messages/it.json b/messages/it.json
index 5dd3fad48..8082b599e 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -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",
diff --git a/messages/pl.json b/messages/pl.json
index 71db46251..9aa72c2a0 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -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",
diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx
index ab1a0cd5c..bf24b1201 100644
--- a/src/components/labs/lab-list.tsx
+++ b/src/components/labs/lab-list.tsx
@@ -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 } = {}) {
r.value)} />
-
+ 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"
+ />
@@ -187,12 +187,11 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
group.latest.referenceHigh !== null ? (
{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,
+ )}
) : null}
diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx
index 162de30bb..98b927df9 100644
--- a/src/components/measurement-reminders/vorsorge-section.tsx
+++ b/src/components/measurement-reminders/vorsorge-section.tsx
@@ -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({
{t("measurementReminders.markDone")}
-
+
diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts
index 36ea49480..11fd211b5 100644
--- a/src/lib/doctor-report-pdf-core.ts
+++ b/src/lib/doctor-report-pdf-core.ts
@@ -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;
@@ -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) => [
diff --git a/src/lib/labs/__tests__/reference-range.test.ts b/src/lib/labs/__tests__/reference-range.test.ts
new file mode 100644
index 000000000..fe56d3733
--- /dev/null
+++ b/src/lib/labs/__tests__/reference-range.test.ts
@@ -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");
+ });
+});
diff --git a/src/lib/labs/reference-range.ts b/src/lib/labs/reference-range.ts
new file mode 100644
index 000000000..85369b176
--- /dev/null
+++ b/src/lib/labs/reference-range.ts
@@ -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 ?? "";
+}
diff --git a/src/lib/validations/labs.ts b/src/lib/validations/labs.ts
index fde18bc7c..3a28ee7b2 100644
--- a/src/lib/validations/labs.ts
+++ b/src/lib/validations/labs.ts
@@ -145,25 +145,12 @@ export const listLabResultsSchema = z.object({
export type ListLabResultsInput = z.infer;
/**
- * 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";