mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
4266203d3a
commit
c6cbd5442f
9 files changed
+83
-189
No files matched your search
+1
-8
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user