fix(labs): polish the biomarker-detail header and description

Resolve the catalog slug from both the current-locale display name and the
English canonical name. A marker stores the catalog name as it read at
creation time, so one minted under the English default and then viewed in
another locale never matched a map built only from the current locale's
names — its rich per-biomarker description silently fell back to the generic
line. The English bundle is always in the client chunk, so adding its names
makes the lookup independent of the locale the marker was created in.

Tidy the header control cluster:

- Drop the standalone "adjust target range" control and its sheet; the
  reference bounds are already editable inside the full marker editor behind
  the pencil. Remove the now-dead handler, mutation, and `targetRange.*`
  strings across all locales.
- Give the show-all-values, edit, and delete controls one shared ghost-icon
  treatment instead of the show-all control's separate muted variant.
- Add hover tooltips to edit and delete, matching the other icon controls.
- Order the controls left → right: delete, edit, show all values, add value.
This commit is contained in:
Marc-André Bombeck committed 2026-06-29 14:55:05 +02:00
1 parent 4266203d3a
commit c6cbd5442f
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>
);
}