Mirror the metric sub-page layout on the biomarker detail

Bring the lab biomarker detail in line with the Insights metric pages:

- Lead with the static catalog description directly beneath the heading,
  the explainer-caption position the metric pages use.
- Drop the early big-value + reference-badge block that did not match the
  assessment-card styling.
- Split numeric and qualitative markers: numeric keeps the stat strip and
  chart, qualitative shows only the latest result text (no strip, no chart,
  no range badge). The assessment card now renders for any marker with
  readings and self-suppresses for qualitative-only markers.
- Move the full reading history off the detail page onto a dedicated
  /labs/[biomarkerId]/values route, reached by a top-right list control,
  matching the metric pages' show-all-values affordance.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 20:37:01 +02:00
1 parent 651b02bc1b
commit ad43eaeff0
9 files changed
+301 -109

No files matched your search

+5 -2
View File
@@ -7061,9 +7061,12 @@
"restoredToast": "Wert wiederhergestellt",
"restoreError": "Wert konnte nicht wiederhergestellt werden",
"detail": {
"history": "Verlauf",
"emptyTitle": "Noch keine Werte",
"emptyDescription": "Füge einen Wert hinzu, um diesen Biomarker über die Zeit zu verfolgen."
"emptyDescription": "Füge einen Wert hinzu, um diesen Biomarker über die Zeit zu verfolgen.",
"valuesTitle": "{marker} — alle Werte",
"valuesDescription": "Jeder Messwert für diesen Biomarker. Bearbeite oder entferne einen Eintrag direkt.",
"backToMarker": "Zurück zu {marker}",
"latestResult": "Letztes Ergebnis"
},
"chart": {
"empty": "Keine Werte in diesem Zeitraum.",
+5 -2
View File
@@ -7061,9 +7061,12 @@
"restoredToast": "Reading restored",
"restoreError": "Could not restore the reading",
"detail": {
"history": "History",
"emptyTitle": "No readings yet",
"emptyDescription": "Add a value to start tracking this biomarker over time."
"emptyDescription": "Add a value to start tracking this biomarker over time.",
"valuesTitle": "{marker} — all readings",
"valuesDescription": "Every reading for this biomarker. Edit or remove an entry directly.",
"backToMarker": "Back to {marker}",
"latestResult": "Latest result"
},
"chart": {
"empty": "No readings in this period.",
+5 -2
View File
@@ -7061,9 +7061,12 @@
"restoredToast": "Medición restaurada",
"restoreError": "No se pudo restaurar la medición",
"detail": {
"history": "Historial",
"emptyTitle": "Aún no hay mediciones",
"emptyDescription": "Añade un valor para seguir este biomarcador con el tiempo."
"emptyDescription": "Añade un valor para seguir este biomarcador con el tiempo.",
"valuesTitle": "{marker} — todas las lecturas",
"valuesDescription": "Cada lectura de este biomarcador. Edita o elimina una entrada directamente.",
"backToMarker": "Volver a {marker}",
"latestResult": "Último resultado"
},
"chart": {
"empty": "Sin mediciones en este periodo.",
+5 -2
View File
@@ -7061,9 +7061,12 @@
"restoredToast": "Mesure restaurée",
"restoreError": "Impossible de restaurer la mesure",
"detail": {
"history": "Historique",
"emptyTitle": "Aucune mesure pour l'instant",
"emptyDescription": "Ajoutez une valeur pour suivre ce biomarqueur dans le temps."
"emptyDescription": "Ajoutez une valeur pour suivre ce biomarqueur dans le temps.",
"valuesTitle": "{marker} — toutes les valeurs",
"valuesDescription": "Chaque mesure de ce biomarqueur. Modifiez ou supprimez une entrée directement.",
"backToMarker": "Retour à {marker}",
"latestResult": "Dernier résultat"
},
"chart": {
"empty": "Aucune mesure sur cette période.",
+5 -2
View File
@@ -7061,9 +7061,12 @@
"restoredToast": "Misurazione ripristinata",
"restoreError": "Impossibile ripristinare la misurazione",
"detail": {
"history": "Cronologia",
"emptyTitle": "Ancora nessuna misurazione",
"emptyDescription": "Aggiungi un valore per seguire questo biomarcatore nel tempo."
"emptyDescription": "Aggiungi un valore per seguire questo biomarcatore nel tempo.",
"valuesTitle": "{marker} — tutte le letture",
"valuesDescription": "Ogni lettura di questo biomarcatore. Modifica o rimuovi una voce direttamente.",
"backToMarker": "Torna a {marker}",
"latestResult": "Ultimo risultato"
},
"chart": {
"empty": "Nessuna misurazione in questo periodo.",
+5 -2
View File
@@ -7061,9 +7061,12 @@
"restoredToast": "Pomiar przywrócony",
"restoreError": "Nie udało się przywrócić pomiaru",
"detail": {
"history": "Historia",
"emptyTitle": "Brak pomiarów",
"emptyDescription": "Dodaj wartość, aby śledzić ten biomarker w czasie."
"emptyDescription": "Dodaj wartość, aby śledzić ten biomarker w czasie.",
"valuesTitle": "{marker} — wszystkie odczyty",
"valuesDescription": "Każdy odczyt tego biomarkera. Edytuj lub usuń wpis bezpośrednio.",
"backToMarker": "Powrót do {marker}",
"latestResult": "Ostatni wynik"
},
"chart": {
"empty": "Brak pomiarów w tym okresie.",
@@ -0,0 +1,75 @@
"use client";
/**
* v1.25.1 — `/labs/[biomarkerId]/values`.
*
* The "show all readings" sub-page for one biomarker, mirroring the metric
* sub-pages' `/insights/values/[type]`. The detail page keeps the
* numbers-first spine (description → stat strip → chart → assessment) and
* links here for the raw, editable reading feed. Owns auth + the back link to
* the biomarker detail, and hands the marker id to `<LabValuesList>`.
*/
import { use, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { LabValuesList } from "@/components/labs/lab-values-list";
import type { BiomarkerDto } from "@/components/labs/types";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { BackLink } from "@/components/ui/back-link";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
export default function LabBiomarkerValuesPage({
params,
}: {
params: Promise<{ biomarkerId: string }>;
}) {
const { biomarkerId } = use(params);
const { isAuthenticated, isLoading } = useAuth();
const mounted = useMounted();
const router = useRouter();
const { t } = useTranslations();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push("/auth/login");
}
}, [isLoading, isAuthenticated, router]);
const { data: marker } = useQuery({
queryKey: queryKeys.biomarkerDetail(biomarkerId),
queryFn: () => apiGet<BiomarkerDto>(`/api/biomarkers/${biomarkerId}`),
enabled: isAuthenticated && biomarkerId.length > 0,
});
if (!mounted || isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
}
const markerName = marker?.name ?? "";
return (
<SubPageShell
title={t("labs.detail.valuesTitle", { marker: markerName })}
description={t("labs.detail.valuesDescription")}
backLink={
<BackLink
href={`/labs/${biomarkerId}`}
label={t("labs.detail.backToMarker", { marker: markerName })}
dataSlot="lab-values-back"
/>
}
>
<LabValuesList biomarkerId={biomarkerId} />
</SubPageShell>
);
}
+84 -97
View File
@@ -1,6 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
useInfiniteQuery,
@@ -8,7 +9,13 @@ import {
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { FlaskConical, Pencil, Plus, SlidersHorizontal } from "lucide-react";
import {
FlaskConical,
ListOrdered,
Pencil,
Plus,
SlidersHorizontal,
} from "lucide-react";
import { toast } from "sonner";
import { DeleteButton } from "@/components/data-list";
@@ -26,9 +33,8 @@ import { useMounted } from "@/hooks/use-mounted";
import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch";
import { summarize, type DataSummary } from "@/lib/analytics/trends";
import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog";
import { classifyReferenceRange } from "@/lib/labs/reference-range";
import { formatLabValue } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { queryKeys } from "@/lib/query-keys";
import dynamic from "next/dynamic";
@@ -39,8 +45,6 @@ import { importWithRetry } from "@/lib/retry-import";
import { BiomarkerForm } from "./biomarker-form";
import { LabForm } from "./lab-form";
import { LabHistoryList } from "./lab-history-list";
import { ReferenceRangeBadge } from "./reference-range-badge";
import type {
BiomarkerDto,
LabResultDto,
@@ -121,9 +125,6 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
data: list,
isLoading,
isError: listError,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: queryKeys.labResultsInfinite({ biomarkerId, sortDir: "desc" }),
initialPageParam: 0,
@@ -143,9 +144,6 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
() => list?.pages.flatMap((p) => p.results) ?? [],
[list],
);
const total = list?.pages[0]?.meta.total ?? 0;
// More rows exist on the server than are currently loaded.
const truncated = hasNextPage ?? false;
const latest =
readings.length > 0
? [...readings].sort(
@@ -164,6 +162,10 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
() => readings.filter((r) => r.value !== null),
[readings],
);
// A marker is qualitative when it has readings but none of them carry a
// numeric value — a stat strip / chart make no sense, so the page shows the
// latest result text + assessment + the history (via the values sub-page).
const isQualitative = readings.length > 0 && numericReadings.length === 0;
const summary = useMemo<DataSummary | null>(
() =>
numericReadings.length > 0
@@ -178,7 +180,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
);
const mounted = useMounted();
const { data: assessment, isLoading: assessmentLoading } =
useInsightBiomarkerAssessment(biomarkerId, numericReadings.length > 0);
useInsightBiomarkerAssessment(biomarkerId, readings.length > 0);
function afterAdd() {
setAddOpen(false);
@@ -245,16 +247,6 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
);
}
// A qualitative latest reading (numeric value null) has no range verdict.
const latestStatus =
latest && latest.value !== null
? classifyReferenceRange(
latest.value,
marker?.lowerBound ?? null,
marker?.upperBound ?? null,
)
: "unknown";
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
@@ -270,6 +262,30 @@ 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"
@@ -313,25 +329,10 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
</div>
</div>
{latest ? (
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="text-2xl font-bold tabular-nums">
{latest.value !== null ? (
<>
{formatLabValue(latest.value)}{" "}
<span className="text-muted-foreground text-base font-normal">
{marker?.unit ?? latest.unit}
</span>
</>
) : (
// Qualitative latest reading — show the result text, no unit.
(latest.valueText ?? "")
)}
</span>
<ReferenceRangeBadge status={latestStatus} />
</div>
) : null}
{/* v1.25.1 — the static description leads the page, directly beneath the
heading, mirroring the metric sub-pages' explainer caption. Resolves
the catalog slug from the marker name, falling back to the user's own
`context`. */}
{(() => {
const slug = marker?.name
? slugByName.get(marker.name.trim().toLowerCase())
@@ -346,21 +347,15 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
) : null;
})()}
{/* Numbers-first stat strip — Min / Max / Median / Mean over the
numeric readings, mirroring the metric sub-pages. Self-gating:
renders nothing for a qualitative-only or empty marker. */}
<MetricStatStrip
summary={summary}
unit={marker?.unit ?? latest?.unit ?? ""}
seriesLabel={marker?.name}
icon={FlaskConical}
/>
<Card>
<CardContent className="pt-6">
{isLoading ? (
{isLoading ? (
<Card>
<CardContent className="pt-6">
<Skeleton className="h-60 w-full" />
) : readings.length === 0 ? (
</CardContent>
</Card>
) : readings.length === 0 ? (
<Card>
<CardContent className="pt-6">
<EmptyState
icon={<FlaskConical className="size-6" />}
title={t("labs.detail.emptyTitle")}
@@ -371,55 +366,47 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
</Button>
}
/>
) : (
<LabBiomarkerChart
readings={readings}
unit={marker?.unit ?? latest?.unit ?? ""}
lowerBound={marker?.lowerBound ?? null}
upperBound={marker?.upperBound ?? null}
/>
)}
</CardContent>
</Card>
{readings.length > 0 ? (
<div className="space-y-2">
<h2 className="text-sm font-semibold">{t("labs.detail.history")}</h2>
{truncated ? (
</CardContent>
</Card>
) : isQualitative ? (
// Qualitative marker — no numeric strip / chart. Show the latest
// result text on its own simple line, no unit, no range badge.
<Card>
<CardContent className="space-y-1 pt-6">
<p className="text-muted-foreground text-xs">
{t("labs.showingLatestOf", {
shown: readings.length,
total,
})}
{t("labs.detail.latestResult")}
</p>
) : null}
<p className="text-2xl font-bold">{latest?.valueText ?? ""}</p>
</CardContent>
</Card>
) : (
<>
{/* Numbers-first stat strip — Min / Max / Median / Mean over the
numeric readings, mirroring the metric sub-pages. */}
<MetricStatStrip
summary={summary}
unit={marker?.unit ?? latest?.unit ?? ""}
seriesLabel={marker?.name}
icon={FlaskConical}
/>
<Card>
<CardContent className="py-0">
<LabHistoryList readings={readings} />
<CardContent className="pt-6">
<LabBiomarkerChart
readings={readings}
unit={marker?.unit ?? latest?.unit ?? ""}
lowerBound={marker?.lowerBound ?? null}
upperBound={marker?.upperBound ?? null}
/>
</CardContent>
</Card>
{hasNextPage ? (
<div className="flex justify-center">
<Button
variant="outline"
className="min-h-11 sm:min-h-9"
disabled={isFetchingNextPage}
onClick={() => fetchNextPage()}
>
{isFetchingNextPage
? t("common.loading")
: t("labs.loadMoreReadings")}
</Button>
</div>
) : null}
</div>
) : null}
</>
)}
{/* AI assessment — the spine's closing block, mirroring the metric
sub-pages (intro → stat strip → chart → history → assessment). The
card self-suppresses when the operator disabled status cards, and
the hook is gated on the marker having numeric readings. */}
{numericReadings.length > 0 ? (
sub-pages (intro → stat strip → chart → assessment). The card
self-suppresses for a qualitative-only marker (the read-only route
returns `insufficient` without calling a provider). */}
{readings.length > 0 ? (
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<FlaskConical className="h-5 w-5" />}
+112
View File
@@ -0,0 +1,112 @@
"use client";
import { useMemo } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { LabHistoryList } from "./lab-history-list";
import type { LabResultDto, LabResultListResponse } from "./types";
// Page size for the offset-paginated reading feed. The server caps a single
// read at 500 (`listLabResultsSchema`); this stays well under that and keeps
// the first paint light, with "Load more" pulling subsequent pages.
const READINGS_PAGE_SIZE = 200;
/**
* v1.25.1 — the full reading history for one biomarker, lifted off the detail
* page onto its own `/labs/[biomarkerId]/values` route (mirroring the metric
* sub-pages' `/insights/values/<type>` split). The detail page keeps the
* numbers-first spine; this is the raw, editable reading feed with "Load more".
* Reuses the same offset-paginated infinite query key as the detail page so the
* two share cache.
*/
export function LabValuesList({ biomarkerId }: { biomarkerId: string }) {
const { t } = useTranslations();
const {
data: list,
isLoading,
isError,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: queryKeys.labResultsInfinite({ biomarkerId, sortDir: "desc" }),
initialPageParam: 0,
queryFn: ({ pageParam }) =>
apiGet<LabResultListResponse>(
`/api/labs?biomarkerId=${encodeURIComponent(
biomarkerId,
)}&limit=${READINGS_PAGE_SIZE}&offset=${pageParam}&sortDir=desc`,
),
getNextPageParam: (lastPage) => {
const next = lastPage.meta.offset + lastPage.meta.limit;
return next < lastPage.meta.total ? next : undefined;
},
});
const readings: LabResultDto[] = useMemo(
() => list?.pages.flatMap((p) => p.results) ?? [],
[list],
);
const total = list?.pages[0]?.meta.total ?? 0;
const truncated = hasNextPage ?? false;
if (isError) {
return (
<p className="text-destructive py-8 text-center text-sm">
{t("labs.loadError")}
</p>
);
}
if (isLoading) {
return <Skeleton className="h-60 w-full" />;
}
if (readings.length === 0) {
return (
<p className="text-muted-foreground py-8 text-center text-sm">
{t("labs.detail.emptyTitle")}
</p>
);
}
return (
<div className="space-y-2">
{truncated ? (
<p className="text-muted-foreground text-xs">
{t("labs.showingLatestOf", {
shown: readings.length,
total,
})}
</p>
) : null}
<Card>
<CardContent className="py-0">
<LabHistoryList readings={readings} />
</CardContent>
</Card>
{hasNextPage ? (
<div className="flex justify-center">
<Button
variant="outline"
className="min-h-11 sm:min-h-9"
disabled={isFetchingNextPage}
onClick={() => fetchNextPage()}
>
{isFetchingNextPage
? t("common.loading")
: t("labs.loadMoreReadings")}
</Button>
</div>
) : null}
</div>
);
}