Merge v1.25.3: v1253-labs-detail

This commit is contained in:
Marc-André Bombeck committed 2026-06-29 15:30:32 +02:00
commit 9ede7575cf
9 files changed
+83 -189

No files matched your search

+1 -8
View File
@@ -7129,14 +7129,7 @@
"unhiddenToast": "Biomarker wiederhergestellt",
"hideError": "Biomarker konnte nicht aktualisiert werden",
"allHidden": "Alle Biomarker sind ausgeblendet.",
"hiddenSection": "Ausgeblendet",
"targetRange": {
"title": "Zielbereich anpassen",
"description": "Lege die untere und obere Referenz für diesen Marker fest. Lass ein Feld leer für eine offene Grenze.",
"save": "Bereich speichern",
"savedToast": "Zielbereich aktualisiert",
"saveError": "Zielbereich konnte nicht aktualisiert werden"
}
"hiddenSection": "Ausgeblendet"
},
"catalog": {
"total-cholesterol": "Gesamtcholesterin",
+1 -8
View File
@@ -7129,14 +7129,7 @@
"unhiddenToast": "Biomarker restored",
"hideError": "Could not update the biomarker",
"allHidden": "Every biomarker is hidden.",
"hiddenSection": "Hidden",
"targetRange": {
"title": "Adjust target range",
"description": "Set the reference low and high for this marker. Leave a field empty for an open-ended bound.",
"save": "Save range",
"savedToast": "Target range updated",
"saveError": "Could not update the target range"
}
"hiddenSection": "Hidden"
},
"catalog": {
"total-cholesterol": "Total cholesterol",
+1 -8
View File
@@ -7129,14 +7129,7 @@
"unhiddenToast": "Biomarcador restaurado",
"hideError": "No se pudo actualizar el biomarcador",
"allHidden": "Todos los biomarcadores están ocultos.",
"hiddenSection": "Ocultos",
"targetRange": {
"title": "Ajustar el rango objetivo",
"description": "Define la referencia mínima y máxima de este marcador. Deja un campo vacío para un límite abierto.",
"save": "Guardar rango",
"savedToast": "Rango objetivo actualizado",
"saveError": "No se pudo actualizar el rango objetivo"
}
"hiddenSection": "Ocultos"
},
"catalog": {
"total-cholesterol": "Colesterol total",
+1 -8
View File
@@ -7129,14 +7129,7 @@
"unhiddenToast": "Biomarqueur restauré",
"hideError": "Impossible de mettre à jour le biomarqueur",
"allHidden": "Tous les biomarqueurs sont masqués.",
"hiddenSection": "Masqués",
"targetRange": {
"title": "Ajuster la plage cible",
"description": "Définis la référence basse et haute de ce marqueur. Laisse un champ vide pour une limite ouverte.",
"save": "Enregistrer la plage",
"savedToast": "Plage cible mise à jour",
"saveError": "Impossible de mettre à jour la plage cible"
}
"hiddenSection": "Masqués"
},
"catalog": {
"total-cholesterol": "Cholestérol total",
+1 -8
View File
@@ -7129,14 +7129,7 @@
"unhiddenToast": "Biomarcatore ripristinato",
"hideError": "Impossibile aggiornare il biomarcatore",
"allHidden": "Tutti i biomarcatori sono nascosti.",
"hiddenSection": "Nascosti",
"targetRange": {
"title": "Regola l'intervallo obiettivo",
"description": "Imposta il riferimento minimo e massimo per questo marcatore. Lascia un campo vuoto per un limite aperto.",
"save": "Salva intervallo",
"savedToast": "Intervallo obiettivo aggiornato",
"saveError": "Impossibile aggiornare l'intervallo obiettivo"
}
"hiddenSection": "Nascosti"
},
"catalog": {
"total-cholesterol": "Colesterolo totale",
+1 -8
View File
@@ -7129,14 +7129,7 @@
"unhiddenToast": "Biomarker przywrócony",
"hideError": "Nie udało się zaktualizować biomarkera",
"allHidden": "Wszystkie biomarkery są ukryte.",
"hiddenSection": "Ukryte",
"targetRange": {
"title": "Dostosuj zakres docelowy",
"description": "Ustaw dolną i górną wartość referencyjną dla tego markera. Pozostaw pole puste, aby granica była otwarta.",
"save": "Zapisz zakres",
"savedToast": "Zaktualizowano zakres docelowy",
"saveError": "Nie udało się zaktualizować zakresu docelowego"
}
"hiddenSection": "Ukryte"
},
"catalog": {
"total-cholesterol": "Cholesterol całkowity",
@@ -32,6 +32,7 @@ export function DeleteButton({
description,
cancelLabel,
confirmLabel,
triggerTitle,
className = "",
iconClassName = "h-3.5 w-3.5",
}: {
@@ -40,6 +41,8 @@ export function DeleteButton({
description: string;
cancelLabel?: string;
confirmLabel?: string;
/** Native hover tooltip on the trigger; defaults to the accessible name. */
triggerTitle?: string;
className?: string;
iconClassName?: string;
}) {
@@ -52,6 +55,7 @@ export function DeleteButton({
size="icon-lg"
className={`text-destructive ${className}`}
aria-label={confirmLabel ?? t("common.delete")}
title={triggerTitle ?? confirmLabel ?? t("common.delete")}
>
<Trash2 className={iconClassName} />
</Button>
@@ -136,4 +136,22 @@ describe("<LabBiomarkerDetail>", () => {
// skeleton geometry — proof the assessment card mounted on the page.
expect(html).toContain('data-testid="insight-status-card-loading"');
});
it("renders the rich catalog description for a catalogued marker", () => {
// "LDL Cholesterol" resolves to the `ldl` catalog slug (case-insensitive),
// so the page shows the per-biomarker explainer, not the generic fallback.
expect(html).toContain("low-density lipoprotein");
expect(html).not.toContain(
"A lab biomarker you track. Add readings to follow",
);
});
it("offers the edit + delete controls with hover tooltips", () => {
expect(html).toContain('title="Edit"');
expect(html).toContain('title="Delete"');
});
it("drops the standalone adjust-target-range control", () => {
expect(html).not.toContain("Adjust target range");
});
});
+55 -141
View File
@@ -9,32 +9,25 @@ import {
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
FlaskConical,
ListOrdered,
Pencil,
Plus,
SlidersHorizontal,
} from "lucide-react";
import { FlaskConical, ListOrdered, Pencil, Plus } from "lucide-react";
import { toast } from "sonner";
import { DeleteButton } from "@/components/data-list";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { useInsightBiomarkerAssessment } from "@/hooks/use-insight-status";
import { useMounted } from "@/hooks/use-mounted";
import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import { summarize, type DataSummary } from "@/lib/analytics/trends";
import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { fallbackMessages } from "@/lib/i18n/load-locale";
import { resolveKey } from "@/lib/i18n/resolve-key";
import { queryKeys } from "@/lib/query-keys";
import dynamic from "next/dynamic";
@@ -89,13 +82,29 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
const queryClient = useQueryClient();
// v1.24 — the per-marker description used to live on the labs overview rows.
// It belongs on the detail page beneath the heading (mirroring the metric
// pages' explainer caption). Resolve the catalog slug from the marker name
// exactly as the overview did, then fall back to the user's own `context`.
// pages' explainer caption). Resolve the catalog slug from the marker name,
// then fall back to the user's own `context`.
//
// v1.25.3 — key the lookup on BOTH the current-locale display name and the
// English canonical name. A marker stores the catalog name as it read at
// creation time (`biomarker-form` writes `t("labs.catalog.<slug>")`), so a
// marker minted under the English default and then viewed in another locale
// never matched a map built only from the current locale's names — the rich
// description silently fell through to the generic line. The English bundle
// is always in the client chunk (`fallbackMessages`), so adding its names
// makes resolution independent of the locale the marker was created in.
const slugByName = useMemo(() => {
const map = new Map<string, string>();
for (const seed of BIOMARKER_CATALOG) {
const norm = t(`labs.catalog.${seed.slug}`).trim().toLowerCase();
if (norm) map.set(norm, seed.slug);
const localized = t(`labs.catalog.${seed.slug}`).trim().toLowerCase();
if (localized) map.set(localized, seed.slug);
const canonical = resolveKey(
fallbackMessages,
`labs.catalog.${seed.slug}`,
)
?.trim()
.toLowerCase();
if (canonical && !map.has(canonical)) map.set(canonical, seed.slug);
}
return map;
}, [t]);
@@ -104,12 +113,6 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
const [editOpen, setEditOpen] = useState(false);
const [editFooterEl, setEditFooterEl] = useState<HTMLDivElement | null>(null);
// v1.24 — focused "adjust target range" sheet. Edits only the reference
// bounds (the full marker editor lives behind the pencil); seeded from the
// marker each time it opens.
const [rangeOpen, setRangeOpen] = useState(false);
const [lowerInput, setLowerInput] = useState("");
const [upperInput, setUpperInput] = useState("");
const {
data: marker,
@@ -217,33 +220,6 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
onError: () => toast.error(t("labs.biomarker.deleteError")),
});
function openRange() {
setLowerInput(marker?.lowerBound != null ? String(marker.lowerBound) : "");
setUpperInput(marker?.upperBound != null ? String(marker.upperBound) : "");
setRangeOpen(true);
}
const saveRange = useMutation({
mutationFn: () => {
const lower = lowerInput.trim() === "" ? null : Number(lowerInput);
const upper = upperInput.trim() === "" ? null : Number(upperInput);
return apiPut(`/api/biomarkers/${biomarkerId}`, {
lowerBound: lower,
upperBound: upper,
});
},
onSuccess: () => {
toast.success(t("labs.biomarker.targetRange.savedToast"));
setRangeOpen(false);
queryClient.invalidateQueries({
queryKey: queryKeys.biomarkerDetail(biomarkerId),
});
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
},
onError: () => toast.error(t("labs.biomarker.targetRange.saveError")),
});
// v1.25.1 — the per-marker description leads the page, mirroring the metric
// sub-pages' explainer caption. Resolve the catalog slug from the marker name
// and fall back to the user's own `context`, then to a generic explainer so a
@@ -306,45 +282,17 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
{/* v1.25.1 — "show all readings" rides the header cluster as an icon
button, mirroring the metric sub-pages' `<SubPageShell>` control.
The full reading feed lives on `/labs/[biomarkerId]/values`; the
detail page keeps the numbers-first spine. */}
{readings.length > 0 ? (
<Button
asChild
variant="ghost"
size="icon"
data-slot="lab-show-all-values"
className={cn(
"text-muted-foreground hover:text-foreground relative size-10",
"before:absolute before:-inset-1.5 before:content-['']",
)}
>
<Link
href={`/labs/${biomarkerId}/values`}
aria-label={t("insights.subPage.showAllValues")}
title={t("insights.subPage.showAllValues")}
>
<ListOrdered className="size-4" aria-hidden="true" />
</Link>
</Button>
) : null}
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
onClick={() => setEditOpen(true)}
disabled={!marker}
aria-label={t("labs.biomarker.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
{/* v1.25.3 — header control cluster, left → right:
Delete · Edit · Show-all-values · Add. The icon controls share one
ghost-icon treatment (the target-range editing lives inside the
full marker editor behind the pencil, so the separate range
control was dropped). */}
<DeleteButton
onConfirm={() => deleteMarker.mutate()}
title={t("labs.biomarker.deleteConfirmTitle")}
description={t("labs.biomarker.deleteConfirmDescription")}
confirmLabel={t("labs.biomarker.delete")}
triggerTitle={t("labs.biomarker.delete")}
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
iconClassName="h-4 w-4"
/>
@@ -352,12 +300,34 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
onClick={openRange}
onClick={() => setEditOpen(true)}
disabled={!marker}
aria-label={t("labs.biomarker.targetRange.title")}
aria-label={t("labs.biomarker.edit")}
title={t("labs.biomarker.edit")}
>
<SlidersHorizontal className="h-4 w-4" />
<Pencil className="h-4 w-4" />
</Button>
{/* "Show all readings" mirrors the metric sub-pages' `<SubPageShell>`
control. The full reading feed lives on
`/labs/[biomarkerId]/values`; the detail page keeps the
numbers-first spine. */}
{readings.length > 0 ? (
<Button
asChild
variant="ghost"
size="icon"
data-slot="lab-show-all-values"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href={`/labs/${biomarkerId}/values`}
aria-label={t("insights.subPage.showAllValues")}
title={t("insights.subPage.showAllValues")}
>
<ListOrdered className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
) : null}
<Button
onClick={() => setAddOpen(true)}
// v1.18.10 (W10) — on the narrowest phones the h1 + Edit + Delete +
@@ -496,62 +466,6 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
/>
) : null}
</ResponsiveSheet>
<ResponsiveSheet
open={rangeOpen}
onOpenChange={setRangeOpen}
title={t("labs.biomarker.targetRange.title")}
description={t("labs.biomarker.targetRange.description")}
footer={
<div className="flex w-full justify-end gap-2">
<Button
variant="outline"
onClick={() => setRangeOpen(false)}
disabled={saveRange.isPending}
>
{t("common.cancel")}
</Button>
<Button
onClick={() => saveRange.mutate()}
disabled={saveRange.isPending}
>
{t("labs.biomarker.targetRange.save")}
</Button>
</div>
}
>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="biomarker-lower-bound">
{t("labs.biomarker.form.lowerBound")}
</Label>
<Input
id="biomarker-lower-bound"
type="number"
inputMode="decimal"
value={lowerInput}
onChange={(e) => setLowerInput(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="biomarker-upper-bound">
{t("labs.biomarker.form.upperBound")}
</Label>
<Input
id="biomarker-upper-bound"
type="number"
inputMode="decimal"
value={upperInput}
onChange={(e) => setUpperInput(e.target.value)}
/>
</div>
</div>
{marker?.unit ? (
<p className="text-muted-foreground mt-2 text-xs">
{t("labs.biomarker.form.rangeHint")}
</p>
) : null}
</ResponsiveSheet>
</div>
);
}