From ad43eaeff0edef33eda280526097c41028001db4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 20:37:01 +0200 Subject: [PATCH] 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. --- messages/de.json | 7 +- messages/en.json | 7 +- messages/es.json | 7 +- messages/fr.json | 7 +- messages/it.json | 7 +- messages/pl.json | 7 +- src/app/labs/[biomarkerId]/values/page.tsx | 75 ++++++++ src/components/labs/lab-biomarker-detail.tsx | 181 +++++++++---------- src/components/labs/lab-values-list.tsx | 112 ++++++++++++ 9 files changed, 301 insertions(+), 109 deletions(-) create mode 100644 src/app/labs/[biomarkerId]/values/page.tsx create mode 100644 src/components/labs/lab-values-list.tsx diff --git a/messages/de.json b/messages/de.json index a90fecd6a..60c6294dd 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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.", diff --git a/messages/en.json b/messages/en.json index 511493b1c..9a322e0fc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/messages/es.json b/messages/es.json index 298329f0c..f8af0d4e6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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.", diff --git a/messages/fr.json b/messages/fr.json index ae54eb4e2..dfa7e48ac 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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.", diff --git a/messages/it.json b/messages/it.json index 7d47c79f9..e8a3e61f2 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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.", diff --git a/messages/pl.json b/messages/pl.json index 6e0a5ce53..078ef65fd 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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.", diff --git a/src/app/labs/[biomarkerId]/values/page.tsx b/src/app/labs/[biomarkerId]/values/page.tsx new file mode 100644 index 000000000..8b1f964ad --- /dev/null +++ b/src/app/labs/[biomarkerId]/values/page.tsx @@ -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 ``. + */ +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(`/api/biomarkers/${biomarkerId}`), + enabled: isAuthenticated && biomarkerId.length > 0, + }); + + if (!mounted || isLoading) { + return ( +
+ +
+ ); + } + + const markerName = marker?.name ?? ""; + + return ( + + } + > + + + ); +} diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index aa6c7c3b9..a5256b079 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -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( () => 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 (
@@ -270,6 +262,30 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { ) : null}
+ {/* v1.25.1 — "show all readings" rides the header cluster as an icon + button, mirroring the metric sub-pages' `` control. + The full reading feed lives on `/labs/[biomarkerId]/values`; the + detail page keeps the numbers-first spine. */} + {readings.length > 0 ? ( + + ) : null}
- {latest ? ( -
- - {latest.value !== null ? ( - <> - {formatLabValue(latest.value)}{" "} - - {marker?.unit ?? latest.unit} - - - ) : ( - // Qualitative latest reading — show the result text, no unit. - (latest.valueText ?? "") - )} - - -
- ) : 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. */} - - - - - {isLoading ? ( + {isLoading ? ( + + - ) : readings.length === 0 ? ( + + + ) : readings.length === 0 ? ( + + } title={t("labs.detail.emptyTitle")} @@ -371,55 +366,47 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { } /> - ) : ( - - )} - - - - {readings.length > 0 ? ( -
-

{t("labs.detail.history")}

- {truncated ? ( + + + ) : isQualitative ? ( + // Qualitative marker — no numeric strip / chart. Show the latest + // result text on its own simple line, no unit, no range badge. + +

- {t("labs.showingLatestOf", { - shown: readings.length, - total, - })} + {t("labs.detail.latestResult")}

- ) : null} +

{latest?.valueText ?? ""}

+
+
+ ) : ( + <> + {/* Numbers-first stat strip — Min / Max / Median / Mean over the + numeric readings, mirroring the metric sub-pages. */} + - - + + - {hasNextPage ? ( -
- -
- ) : null} -
- ) : 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 ? ( } diff --git a/src/components/labs/lab-values-list.tsx b/src/components/labs/lab-values-list.tsx new file mode 100644 index 000000000..c391ed8bf --- /dev/null +++ b/src/components/labs/lab-values-list.tsx @@ -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/` 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( + `/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 ( +

+ {t("labs.loadError")} +

+ ); + } + + if (isLoading) { + return ; + } + + if (readings.length === 0) { + return ( +

+ {t("labs.detail.emptyTitle")} +

+ ); + } + + return ( +
+ {truncated ? ( +

+ {t("labs.showingLatestOf", { + shown: readings.length, + total, + })} +

+ ) : null} + + + + + + {hasNextPage ? ( +
+ +
+ ) : null} +
+ ); +}