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} +
+ ); +}