From 13ee5e2df4a56d27b485108006b9f6d2f0ba0b16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 18:30:39 +0200 Subject: [PATCH] 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. --- .../__tests__/lab-biomarker-detail.test.tsx | 11 ++- src/components/labs/lab-biomarker-detail.tsx | 68 ++++++++++++++----- src/lib/query-keys/labs.ts | 14 ++++ 3 files changed, 69 insertions(+), 24 deletions(-) diff --git a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx index 8c6b6ab0b..7da3247d5 100644 --- a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx +++ b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx @@ -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"), diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index f4ddab48a..aa6c7c3b9 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -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(`/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( - `/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 }) { + {hasNextPage ? ( +
+ +
+ ) : null} ) : null} diff --git a/src/lib/query-keys/labs.ts b/src/lib/query-keys/labs.ts index 28e777a3d..6607231d5 100644 --- a/src/lib/query-keys/labs.ts +++ b/src/lib/query-keys/labs.ts @@ -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,