mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(medications): page the side-effect list and name every GLP-1 medication in the timeline
This commit is contained in:
1 parent
53e60f43f1
commit
7c905249fb
13 files changed
+228
-33
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1386,6 +1386,7 @@
|
||||
"emptyState": "아직 기록한 부작용이 없어요. 기록해 두면 진료에 도움이 되고 패턴도 드러나요.",
|
||||
"recentTitle": "최근 30일",
|
||||
"loading": "불러오는 중…",
|
||||
"loadMore": "더 불러오기",
|
||||
"deleteCta": "삭제",
|
||||
"deleteError": "기록을 삭제하지 못했어요",
|
||||
"errorSeverity": "심각도는 1에서 5 사이여야 해요",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user