From ba7a72f4841c6304b33b77586273314db03981c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 00:45:19 +0200 Subject: [PATCH 1/2] fix(labs): confirm destructive deletes on lab results and Vorsorge reminders The single-row delete on lab results and on preventive-care reminders fired immediately on click, unlike the measurement and mood lists which gate the same action behind a confirm dialog. Route both through the shared DeleteButton so a stray tap can no longer drop a reading or a reminder, and add the per-surface confirm copy across all locales. --- messages/de.json | 4 ++++ messages/en.json | 4 ++++ messages/es.json | 4 ++++ messages/fr.json | 4 ++++ messages/it.json | 4 ++++ messages/pl.json | 4 ++++ src/components/labs/lab-list.tsx | 21 +++++++++---------- .../vorsorge-section.tsx | 21 +++++++++---------- 8 files changed, 44 insertions(+), 22 deletions(-) diff --git a/messages/de.json b/messages/de.json index a423c6f78..9114e703e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6437,6 +6437,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", @@ -6493,6 +6495,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 4efb384dc..27899f29d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6437,6 +6437,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", @@ -6493,6 +6495,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 e78370478..cae5e473f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6437,6 +6437,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", @@ -6493,6 +6495,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 32f5c2b32..f89760ec4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6437,6 +6437,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é", @@ -6493,6 +6495,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 162c586dd..0d5c25c41 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6437,6 +6437,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", @@ -6493,6 +6495,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 c16c94efd..e0a362da2 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6437,6 +6437,8 @@ "addButton": "Dodaj przypomnienie", "markDone": "Gotowe", "delete": "Usuń", + "deleteConfirmTitle": "Usunąć przypomnienie?", + "deleteConfirmDescription": "To przypomnienie profilaktyczne zostanie trwale usunięte.", "disabledBadge": "Wył.", "nextDue": { "none": "Niezaplanowane", @@ -6493,6 +6495,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..3d94b330e 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -2,9 +2,10 @@ 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 { Button } from "@/components/ui/button"; import { Card, @@ -166,16 +167,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" + /> 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")} - + From bf5deb33e2977268baf12b047773adbb06eefe53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 00:58:27 +0200 Subject: [PATCH 2/2] refactor(labs): one shared reference-range classifier and range formatter Reference-range classification + low/high range rendering was implemented three times (validations, PDF core, lab list). Extract a single classifier and a structure-only range formatter that takes a per-surface number formatter, so each surface keeps its own number formatting while sharing the canonical classification. Behaviour is unchanged per surface and now covered by one test. --- src/components/labs/lab-list.tsx | 12 ++-- src/lib/doctor-report-pdf-core.ts | 29 +++++---- .../labs/__tests__/reference-range.test.ts | 65 +++++++++++++++++++ src/lib/labs/reference-range.ts | 63 ++++++++++++++++++ src/lib/validations/labs.ts | 29 +++------ 5 files changed, 158 insertions(+), 40 deletions(-) create mode 100644 src/lib/labs/__tests__/reference-range.test.ts create mode 100644 src/lib/labs/reference-range.ts diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index 3d94b330e..bf24b1201 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -6,6 +6,7 @@ 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, @@ -186,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/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";