fix(labs): paginate the biomarker reading feed

The detail page fetched a single limit=500 window and sorted client-side,
silently truncating any marker that crossed 500 readings. Switch to an
offset-paginated useInfiniteQuery: it loads a page at a time, a 'Load more'
control reveals the rest, and the chart and stat strip render the
accumulated set so loading more extends the trend.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 18:30:39 +02:00
1 parent e264f096ed
commit 13ee5e2df4
3 files changed
+69 -24

No files matched your search

@@ -87,17 +87,14 @@ function renderWithCache() {
defaultOptions: { queries: { retry: false } },
});
queryClient.setQueryData(queryKeys.biomarkerDetail(BIOMARKER_ID), MARKER);
// The reading feed is an offset-paginated `useInfiniteQuery` (v1.25) — seed
// the accumulated-pages cache shape under its key.
queryClient.setQueryData(
queryKeys.labResultsList({
queryKeys.labResultsInfinite({
biomarkerId: BIOMARKER_ID,
analyte: undefined,
panel: undefined,
from: undefined,
to: undefined,
page: 0,
sortDir: "desc",
}),
LIST,
{ pages: [LIST], pageParams: [0] },
);
queryClient.setQueryData(
queryKeys.insightsBiomarkerAssessment(BIOMARKER_ID, "en"),
+51 -17
View File
@@ -2,7 +2,12 @@
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { FlaskConical, Pencil, Plus, SlidersHorizontal } from "lucide-react";
import { toast } from "sonner";
@@ -63,6 +68,11 @@ function LabBiomarkerChart(
);
}
// 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.18.1 — per-biomarker detail: heading + current-value badge, the proper
* dashboard-style chart with the reference band, and the editable reading
@@ -102,30 +112,40 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
queryFn: () => apiGet<BiomarkerDto>(`/api/biomarkers/${biomarkerId}`),
});
// v1.25 — offset-paginated reading feed. The page no longer fetches a single
// `limit=500` window (which silently truncated a marker once it crossed 500
// readings); it loads a page at a time and a "Load more" control reveals the
// rest. The chart + stat strip render the accumulated set, so loading more
// also extends the trend rather than re-fetching the whole history.
const {
data: list,
isLoading,
isError: listError,
} = useQuery({
queryKey: queryKeys.labResultsList({
biomarkerId,
analyte: undefined,
panel: undefined,
from: undefined,
to: undefined,
page: 0,
sortDir: "desc",
}),
queryFn: () =>
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: queryKeys.labResultsInfinite({ biomarkerId, sortDir: "desc" }),
initialPageParam: 0,
queryFn: ({ pageParam }) =>
apiGet<LabResultListResponse>(
`/api/labs?biomarkerId=${encodeURIComponent(biomarkerId)}&limit=500&sortDir=desc`,
`/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?.results ?? [], [list]);
// The reading feed caps at 500 server-side; surface a hint when truncated.
const total = list?.meta?.total ?? 0;
const truncated = total > readings.length;
const readings: LabResultDto[] = useMemo(
() => 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(
@@ -378,6 +398,20 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
<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>
) : null}
+14
View File
@@ -29,6 +29,20 @@ export const labKeys = {
params.sortDir,
] as const,
// v1.25 — paginated (offset) reading feed for the biomarker detail page.
// Distinct from `labResultsList` (single-page key) so the infinite-query
// accumulation never collides with a single-page read. Shares the
// `["lab-results", …]` prefix so a result mutation's `labResults()`
// invalidation evicts it too.
labResultsInfinite: (params: { biomarkerId: string; sortDir: string }) =>
[
"lab-results",
"list",
"infinite",
params.biomarkerId,
params.sortDir,
] as const,
// v1.18.1 — user-scoped Biomarker catalog. The list feeds the picker and
// the manager; both invalidate `biomarkers()` after a mutation.
biomarkers: () => ["biomarkers"] as const,