Merge v1.25.1: biomarker detail mirrors the Insights metric sub-page

This commit is contained in:
Marc-André Bombeck committed 2026-06-28 21:30:48 +02:00
commit 0109fbbc4c
9 files changed
+301 -109

No files matched your search

+5 -2
View File
@@ -7063,9 +7063,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
@@ -7063,9 +7063,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
@@ -7063,9 +7063,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
@@ -7063,9 +7063,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
@@ -7063,9 +7063,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
@@ -7063,9 +7063,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>
);
}