fix(medications): page the side-effect list and name every GLP-1 medication in the timeline

This commit is contained in:
Marc-André Bombeck committed 2026-10-15 15:05:51 +02:00
1 parent 53e60f43f1
commit 7c905249fb
13 files changed
+228 -33

No files matched your search

+17
View File
@@ -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.
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -1386,6 +1386,7 @@
"emptyState": "아직 기록한 부작용이 없어요. 기록해 두면 진료에 도움이 되고 패턴도 드러나요.",
"recentTitle": "최근 30일",
"loading": "불러오는 중…",
"loadMore": "더 불러오기",
"deleteCta": "삭제",
"deleteError": "기록을 삭제하지 못했어요",
"errorSeverity": "심각도는 1에서 5 사이여야 해요",
+1
View File
@@ -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",
+9 -2
View File
@@ -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 })),
});
});
@@ -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<Glp1TimelineResponse>("/api/insights/glp1-timeline?limit=200"),
apiGet<Glp1TimelineResponse>("/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;
@@ -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<void> {
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("<SideEffectsSection> — timeline rows", () => {
expect(html).toContain("Mittel");
});
});
describe("<SideEffectsSection> — 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(<SideEffectsSection medicationId="med-paged" />, client);
await executeSideEffectsQuery(client, "med-paged");
await waitForQueryStatus(client, "med-paged", "success");
const firstHtml = render(
<SideEffectsSection medicationId="med-paged" />,
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(
<SideEffectsSection medicationId="med-paged" />,
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);
});
});
@@ -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<string>();
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) {
</li>
))}
</ul>
{hasNextPage ? (
<div className="flex justify-center">
<Button
variant="outline"
size="sm"
disabled={isFetchingNextPage}
onClick={() => void fetchNextPage()}
>
{isFetchingNextPage
? t("common.loading")
: t("medications.sideEffects.loadMore")}
</Button>
</div>
) : null}
</div>
)}
@@ -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",