mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
merge(reconcile): r2-labs audit findings
This commit is contained in:
commit
c2537e2c08
12 files changed
+202
-62
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 ?? "";
|
||||
}
|
||||
@@ -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";
|
||||
Reference in new issue
Block a user