diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f48efd348..449bb3d13 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -61229,9 +61229,26 @@ components: additionalProperties: false description: "Newest first, capped at `limit`. A heterogeneous union: which optional keys are present depends on `kind`, and no entry carries all of them." + medications: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + required: + - id + - name + additionalProperties: false + description: "Every medication classified GLP-1 in the record, whatever `limit` cut from `entries`. Read this, not the + entry names, to know which medications are GLP-1: a medication whose events are all older than the newest + `limit` entries has none in `entries`. Empty when `hasGlp1` is false." required: - hasGlp1 - entries + - medications additionalProperties: false description: "A chronological merge of the GLP-1-relevant events the record holds: titration steps, injections with their sites, legacy stock-ledger movements, and side-effect-tagged mood days from the trailing 90 days. diff --git a/messages/de.json b/messages/de.json index e7a8a1dd0..ba509f4b9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1386,6 +1386,7 @@ "emptyState": "Noch keine Nebenwirkungen erfasst. Tracking hilft dir und deiner Ärztin, Muster über die Zeit zu erkennen.", "recentTitle": "Letzte 30 Tage", "loading": "Wird geladen…", + "loadMore": "Weitere laden", "deleteCta": "Löschen", "deleteError": "Eintrag konnte nicht gelöscht werden", "errorSeverity": "Schweregrad muss zwischen 1 und 5 liegen", diff --git a/messages/en.json b/messages/en.json index 11a517a80..7577e51fb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1386,6 +1386,7 @@ "emptyState": "No side effects logged yet. Tracking can help your doctor and surface patterns over time.", "recentTitle": "Last 30 days", "loading": "Loading…", + "loadMore": "Load more", "deleteCta": "Delete", "deleteError": "Could not delete the entry", "errorSeverity": "Severity must be between 1 and 5", diff --git a/messages/es.json b/messages/es.json index 43c808b32..aaa14812a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1386,6 +1386,7 @@ "emptyState": "Aún no hay efectos secundarios registrados. Hacer seguimiento puede ayudar a tu médico y revelar patrones a lo largo del tiempo.", "recentTitle": "Últimos 30 días", "loading": "Cargando…", + "loadMore": "Cargar más", "deleteCta": "Eliminar", "deleteError": "No se pudo eliminar la entrada", "errorSeverity": "La gravedad debe estar entre 1 y 5", diff --git a/messages/fr.json b/messages/fr.json index 3f3de9ecd..24d926b6c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1386,6 +1386,7 @@ "emptyState": "Aucun effet secondaire consigné. Le suivi peut aider votre médecin et révéler des tendances dans le temps.", "recentTitle": "30 derniers jours", "loading": "Chargement…", + "loadMore": "Charger plus", "deleteCta": "Supprimer", "deleteError": "Impossible de supprimer l'entrée", "errorSeverity": "La sévérité doit être entre 1 et 5", diff --git a/messages/it.json b/messages/it.json index 4853184ed..71f3977a7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1386,6 +1386,7 @@ "emptyState": "Nessun effetto collaterale registrato. Tracciarli può aiutare il medico e far emergere schemi nel tempo.", "recentTitle": "Ultimi 30 giorni", "loading": "Caricamento…", + "loadMore": "Carica altri", "deleteCta": "Elimina", "deleteError": "Impossibile eliminare la voce", "errorSeverity": "La gravità deve essere tra 1 e 5", diff --git a/messages/ko.json b/messages/ko.json index 122c25d92..04eb45df2 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -1386,6 +1386,7 @@ "emptyState": "아직 기록한 부작용이 없어요. 기록해 두면 진료에 도움이 되고 패턴도 드러나요.", "recentTitle": "최근 30일", "loading": "불러오는 중…", + "loadMore": "더 불러오기", "deleteCta": "삭제", "deleteError": "기록을 삭제하지 못했어요", "errorSeverity": "심각도는 1에서 5 사이여야 해요", diff --git a/messages/pl.json b/messages/pl.json index 5907e3c21..68e21c4cd 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1386,6 +1386,7 @@ "emptyState": "Nie zarejestrowano jeszcze skutków ubocznych. Śledzenie może pomóc lekarzowi i ujawnić wzorce w czasie.", "recentTitle": "Ostatnie 30 dni", "loading": "Ładowanie…", + "loadMore": "Załaduj więcej", "deleteCta": "Usuń", "deleteError": "Nie udało się usunąć wpisu", "errorSeverity": "Nasilenie musi mieścić się w zakresie 1–5", diff --git a/src/app/api/insights/glp1-timeline/route.ts b/src/app/api/insights/glp1-timeline/route.ts index 67af7ff9c..2b59c53c9 100644 --- a/src/app/api/insights/glp1-timeline/route.ts +++ b/src/app/api/insights/glp1-timeline/route.ts @@ -68,7 +68,7 @@ export const GET = apiHandler(async (request: NextRequest) => { }); if (meds.length === 0) { - return apiSuccess({ hasGlp1: false, entries: [] }); + return apiSuccess({ hasGlp1: false, entries: [], medications: [] }); } const ninetyDaysAgo = new Date(); @@ -173,5 +173,12 @@ export const GET = apiHandler(async (request: NextRequest) => { }, }); - return apiSuccess({ hasGlp1: true, entries: entries.slice(0, limit) }); + // `medications` is every GLP-1 medication, independent of `limit`: a + // consumer that needs the set (the substance-curve summary) must not + // derive it from a capped page of entries. + return apiSuccess({ + hasGlp1: true, + entries: entries.slice(0, limit), + medications: meds.map((med) => ({ id: med.id, name: med.name })), + }); }); diff --git a/src/components/insights/glp1-substance-curve-summary.tsx b/src/components/insights/glp1-substance-curve-summary.tsx index 9fc178a08..80c6a7044 100644 --- a/src/components/insights/glp1-substance-curve-summary.tsx +++ b/src/components/insights/glp1-substance-curve-summary.tsx @@ -9,9 +9,12 @@ * * Gating: reads `GET /api/insights/glp1-timeline` — the server-authoritative * GLP-1 signal. When `hasGlp1` is false the block renders nothing. The set of - * GLP-1 medication names comes from the timeline's entries (every entry is - * from a `treatmentClass === "GLP1"` medication), and the passed medication - * list is filtered to that set so non-GLP-1 medications never render a curve. + * GLP-1 medications comes from the response's `medications` list, which the + * server fills from every `treatmentClass === "GLP1"` medication independent + * of the entry cap, and the passed medication list is filtered to that set so + * non-GLP-1 medications never render a curve. (It used to be derived from the + * names in a capped page of entries, which dropped a medication whose events + * were all older than that page.) * * The chart carries its own `MedicationDetailSection` card chrome + estimate * disclaimer, so it reads as one system with the surrounding Insights cards @@ -36,7 +39,7 @@ const DrugLevelChart = dynamic( interface Glp1TimelineResponse { hasGlp1: boolean; - entries: { medicationName?: string }[]; + medications: { id: string; name: string }[]; } export function Glp1SubstanceCurveSummary({ @@ -45,24 +48,17 @@ export function Glp1SubstanceCurveSummary({ medications: { id: string; name: string; dose: string }[]; }) { const { data } = useQuery({ - queryKey: queryKeys.insightsGlp1Timeline(200), + // Only `medications` is read, so the smallest page of entries will do. + queryKey: queryKeys.insightsGlp1Timeline(1), queryFn: () => - apiGet("/api/insights/glp1-timeline?limit=200"), + apiGet("/api/insights/glp1-timeline?limit=1"), staleTime: 60_000, }); if (!data?.hasGlp1) return null; - // Every timeline entry is from a GLP-1 medication (server filters on - // `treatmentClass: "GLP1"`). Medication names match the comprehensive - // list exactly — both derive from `medication.name` server-side. - const glp1Names = new Set( - data.entries - .map((e) => e.medicationName) - .filter((n): n is string => typeof n === "string" && n.length > 0), - ); - - const glp1Meds = medications.filter((m) => glp1Names.has(m.name)); + const glp1Ids = new Set(data.medications.map((m) => m.id)); + const glp1Meds = medications.filter((m) => glp1Ids.has(m.id)); if (glp1Meds.length === 0) return null; const showNameLabel = glp1Meds.length > 1; diff --git a/src/components/medications/__tests__/side-effects-section.test.tsx b/src/components/medications/__tests__/side-effects-section.test.tsx index 4555b07c6..7737bd290 100644 --- a/src/components/medications/__tests__/side-effects-section.test.tsx +++ b/src/components/medications/__tests__/side-effects-section.test.tsx @@ -87,8 +87,14 @@ function seedSideEffects( }>, ) { client.setQueryData(queryKeys.medicationSideEffects(medId), { - items: rows, - meta: { total: rows.length }, + pages: [ + { + items: rows, + meta: { total: rows.length }, + from: "2026-01-01T00:00:00.000Z", + }, + ], + pageParams: [null], }); } @@ -131,13 +137,24 @@ async function executeSideEffectsQuery( medicationId: string, ): Promise { const queryKey = queryKeys.medicationSideEffects(medicationId); - const queryFn = client.getQueryCache().find({ queryKey, exact: true }) - ?.options.queryFn; - if (typeof queryFn !== "function") { + const options = client.getQueryCache().find({ queryKey, exact: true }) + ?.options as + | { + queryFn?: unknown; + initialPageParam?: unknown; + getNextPageParam?: unknown; + } + | undefined; + if (typeof options?.queryFn !== "function") { throw new Error("Side-effects query function was not registered"); } try { - await client.fetchQuery({ queryKey, queryFn }); + await client.fetchInfiniteQuery({ + queryKey, + queryFn: options.queryFn as never, + initialPageParam: options.initialPageParam as never, + getNextPageParam: options.getNextPageParam as never, + }); } catch { // The settled error is the state under test. } @@ -420,3 +437,88 @@ describe(" — timeline rows", () => { expect(html).toContain("Mittel"); }); }); + +describe(" — paging", () => { + function sideEffectsResponse( + items: Array<{ id: string; occurredAt: string }>, + ): Response { + return new Response( + JSON.stringify({ + data: { + items: items.map((row) => ({ + ...row, + category: "GI", + entry: "NAUSEA", + severity: 2, + notes: null, + })), + meta: { total: items.length }, + }, + error: null, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + + it("offers a next page after a full one and asks for the entries before it", async () => { + const base = Date.parse("2026-05-20T12:00:00.000Z"); + const firstPage = Array.from({ length: 50 }, (_, i) => ({ + id: `se-${i}`, + occurredAt: new Date(base - i * 60_000).toISOString(), + })); + const oldest = firstPage[firstPage.length - 1]; + const fetchMock = vi + .fn() + .mockResolvedValueOnce(sideEffectsResponse(firstPage)) + // The boundary entry comes back once more and is shown once. + .mockResolvedValueOnce( + sideEffectsResponse([ + oldest, + { id: "se-older", occurredAt: "2026-05-01T08:00:00.000Z" }, + ]), + ); + vi.stubGlobal("fetch", fetchMock); + + const client = makeClient(); + render(, client); + await executeSideEffectsQuery(client, "med-paged"); + await waitForQueryStatus(client, "med-paged", "success"); + + const firstHtml = render( + , + client, + ); + expect(firstHtml).toContain(en.medications.sideEffects.loadMore); + + await client + .getQueryCache() + .find({ + queryKey: queryKeys.medicationSideEffects("med-paged"), + exact: true, + }) + ?.fetch(undefined, { meta: { fetchMore: { direction: "forward" } } }); + + const secondUrl = new URL( + String(fetchMock.mock.calls[1]?.[0]), + "http://localhost", + ); + expect(secondUrl.searchParams.get("to")).toBe( + new Date(Date.parse(oldest.occurredAt) + 1).toISOString(), + ); + const firstUrl = new URL( + String(fetchMock.mock.calls[0]?.[0]), + "http://localhost", + ); + expect(secondUrl.searchParams.get("from")).toBe( + firstUrl.searchParams.get("from"), + ); + + const secondHtml = render( + , + client, + ); + // 50 + 1 new, the repeated boundary entry dropped; a short page ends it. + expect(secondHtml).toContain("(51)"); + expect(secondHtml).not.toContain(en.medications.sideEffects.loadMore); + }); +}); diff --git a/src/components/medications/side-effects-section.tsx b/src/components/medications/side-effects-section.tsx index 7df4d2477..140fb8edb 100644 --- a/src/components/medications/side-effects-section.tsx +++ b/src/components/medications/side-effects-section.tsx @@ -3,7 +3,11 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useId, useMemo, useState } from "react"; import { createPortal } from "react-dom"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + useInfiniteQuery, + useMutation, + useQueryClient, +} from "@tanstack/react-query"; import { toast } from "sonner"; import { Loader2, Plus, Trash2 } from "lucide-react"; @@ -110,6 +114,16 @@ function entryI18nKey(entry: MedicationSideEffectEntry): string { .replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()); } +/** Entries per page of the side-effect list. */ +const SIDE_EFFECTS_PAGE_SIZE = 50; + +interface SideEffectPageParam { + /** The window start, fixed when the first page loaded. */ + from: string; + /** Exclusive upper bound: just after the oldest entry already shown. */ + to: string; +} + export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { const { t } = useTranslations(); const { canWriteDomain, canManageDomain } = useRecordCapabilities(); @@ -133,18 +147,40 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { const listKey = queryKeys.medicationSideEffects(medicationId); - const { data, isLoading, isError, refetch } = useQuery({ + // The last 30 days, newest first, one page at a time. The window is fixed + // when the first page loads; each further page asks for the entries before + // the oldest one shown (`to` is exclusive, so the boundary instant is + // re-asked one millisecond later and the overlap is dropped by id). + const { + data, + isLoading, + isError, + refetch, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteQuery({ queryKey: listKey, - queryFn: async () => { - // Last 30 days, newest-first. - const to = new Date(); - const from = new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000); - const url = `/api/medications/${medicationId}/side-effects?from=${encodeURIComponent(from.toISOString())}&to=${encodeURIComponent(to.toISOString())}&limit=100`; + initialPageParam: null as SideEffectPageParam | null, + queryFn: async ({ pageParam }) => { + const to = pageParam ? new Date(pageParam.to) : new Date(); + const from = pageParam + ? new Date(pageParam.from) + : new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000); + const url = `/api/medications/${medicationId}/side-effects?from=${encodeURIComponent(from.toISOString())}&to=${encodeURIComponent(to.toISOString())}&limit=${SIDE_EFFECTS_PAGE_SIZE}`; const { data: payload } = await apiFetchEnvelope<{ items: SideEffectRow[]; meta: { total: number }; }>(url); - return payload; + return { ...payload, from: from.toISOString() }; + }, + getNextPageParam: (lastPage): SideEffectPageParam | undefined => { + if (lastPage.items.length < SIDE_EFFECTS_PAGE_SIZE) return undefined; + const oldest = lastPage.items[lastPage.items.length - 1]; + return { + from: lastPage.from, + to: new Date(Date.parse(oldest.occurredAt) + 1).toISOString(), + }; }, staleTime: 30 * 1000, }); @@ -184,7 +220,18 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { }, }); - const items = data?.items ?? []; + const items = useMemo(() => { + const seen = new Set(); + const out: SideEffectRow[] = []; + for (const page of data?.pages ?? []) { + for (const row of page.items) { + if (seen.has(row.id)) continue; + seen.add(row.id); + out.push(row); + } + } + return out; + }, [data]); const filteredEntries = useMemo( () => entriesByCategory(category), @@ -363,6 +410,20 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { ))} + {hasNextPage ? ( +
+ +
+ ) : null} )} diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index f13be444c..6f77494fd 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -2878,6 +2878,11 @@ export const glp1TimelineResponse = z .describe( "Newest first, capped at `limit`. A heterogeneous union: which optional keys are present depends on `kind`, and no entry carries all of them.", ), + medications: z + .array(z.object({ id: z.string(), name: z.string() })) + .describe( + "Every medication classified GLP-1 in the record, whatever `limit` cut from `entries`. Read this, not the entry names, to know which medications are GLP-1: a medication whose events are all older than the newest `limit` entries has none in `entries`. Empty when `hasGlp1` is false.", + ), }) .meta({ id: "Glp1TimelineResponse",