fix(medications): walk intakes to the drug-level horizon, read every symptom in a window, and guard fixed client fetch limits

This commit is contained in:
Marc-André Bombeck committed 2026-10-15 15:10:53 +02:00
1 parent 7c905249fb
commit 648bb0d985
5 files changed
+431 -8

No files matched your search

@@ -81,6 +81,7 @@ vi.mock("@/lib/medications/glp1-pk", async () => {
import {
DrugLevelChart,
loadTakenIntakesSince,
parseDoseMg,
resolveDoseMg,
} from "../drug-level-chart";
@@ -362,3 +363,54 @@ describe("parseDoseMg / resolveDoseMg helpers", () => {
expect(resolveDoseMg("not-an-iso", [], 42)).toBe(42);
});
});
describe("loadTakenIntakesSince", () => {
const DAY = 24 * 60 * 60 * 1000;
// 400 daily taken doses, newest first, the newest one a day ago.
const now = Date.parse("2026-06-01T08:00:00.000Z");
const all = Array.from({ length: 400 }, (_, i) => ({
id: `i-${i}`,
takenAt: new Date(now - (i + 1) * DAY).toISOString(),
skipped: false,
scheduledFor: new Date(now - (i + 1) * DAY).toISOString(),
}));
function pager() {
const urls: string[] = [];
const fetchPage = async (url: string) => {
urls.push(url);
const params = new URL(url, "http://localhost").searchParams;
const limit = Number(params.get("limit"));
const offset = Number(params.get("offset"));
return {
events: all.slice(offset, offset + limit),
meta: { total: all.length },
};
};
return { urls, fetchPage };
}
it("walks back past a single page to the horizon, and no further", async () => {
const { urls, fetchPage } = pager();
// 21-day window + 5 half-lives of 7 days = 56 days, then 150 to cross a page.
const since = new Date(now - 150.5 * DAY);
const { events } = await loadTakenIntakesSince("med-1", since, fetchPage);
expect(events).toHaveLength(150);
expect(urls).toHaveLength(2);
for (const url of urls) {
expect(url).toContain("status=taken");
expect(url).toContain("sortBy=takenAt");
}
});
it("stops at the end of the history", async () => {
const { urls, fetchPage } = pager();
const { events } = await loadTakenIntakesSince(
"med-1",
new Date(now - 10_000 * DAY),
fetchPage,
);
expect(events).toHaveLength(400);
expect(urls).toHaveLength(4);
});
});
@@ -56,6 +56,7 @@ import {
} from "@/lib/medications/glp1-pk";
import {
findDrugIdByBrand,
GLP1_DRUGS,
type Glp1DrugId,
} from "@/lib/medications/glp1-knowledge";
import { parseDoseMg } from "@/lib/medications/dose-string";
@@ -74,6 +75,53 @@ const HOURS_PER_DAY = 24;
const WINDOW_HOURS_BEFORE = 21 * HOURS_PER_DAY;
const WINDOW_HOURS_AFTER = 0;
/** Taken intakes per request while walking back to the model's horizon. */
const INTAKE_PAGE_SIZE = 100;
/**
* Half-lives before the chart's left edge whose doses still count. After five
* half-lives a dose holds about 3% of its peak, below what the chart can show.
*/
const HORIZON_HALF_LIVES = 5;
/**
* Every taken intake from `since` on, newest first, walked page by page. The
* curve is a sum over every dose still in the body, so a fixed count of
* recent intakes was wrong in both directions: twenty daily doses of a
* long-acting drug stop well short of steady state, and planned or skipped
* rows used up the count without being doses at all.
*/
export async function loadTakenIntakesSince(
medicationId: string,
since: Date,
fetchPage: (url: string) => Promise<{
events: IntakeEvent[];
meta: { total: number };
}> = (url) => apiGet(url),
): Promise<{ events: IntakeEvent[] }> {
const events: IntakeEvent[] = [];
for (let offset = 0; ; offset += INTAKE_PAGE_SIZE) {
const page = await fetchPage(
`/api/medications/${medicationId}/intake?status=taken&sortBy=takenAt&sortDir=desc&limit=${INTAKE_PAGE_SIZE}&offset=${offset}`,
);
events.push(...page.events);
const oldest = page.events[page.events.length - 1];
const reachedHorizon =
!oldest?.takenAt || Date.parse(oldest.takenAt) < since.getTime();
if (
reachedHorizon ||
page.events.length < INTAKE_PAGE_SIZE ||
offset + INTAKE_PAGE_SIZE >= page.meta.total
) {
break;
}
}
return {
events: events.filter(
(ev) => ev.takenAt && Date.parse(ev.takenAt) >= since.getTime(),
),
};
}
interface IntakeEvent {
id: string;
takenAt: string | null;
@@ -147,9 +195,16 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) {
queryKey: queryKeys.medicationIntakeDrugLevelChart(medication.id),
queryFn: async () => {
try {
return await apiGet<{ events: IntakeEvent[] }>(
`/api/medications/${medication.id}/intake?limit=20&sortBy=takenAt&sortDir=desc`,
const halfLifeHours =
GLP1_DRUGS[drugId!].pharmacology.halfLifeDays * HOURS_PER_DAY;
const since = new Date(
Date.now() -
(WINDOW_HOURS_BEFORE + HORIZON_HALF_LIVES * halfLifeHours) *
60 *
60 *
1000,
);
return await loadTakenIntakesSince(medication.id, since);
} catch {
return null;
}
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import type { SymptomEventDTO } from "@/lib/symptoms/shared";
import { loadSymptomEvents, type SymptomEventList } from "../use-symptoms";
/**
* A fake of `GET /api/symptoms/events`: newest first, `to` inclusive, at most
* `limit` rows, `hasMore` when the page is full.
*/
function fakeRoute(all: SymptomEventDTO[]) {
const urls: string[] = [];
const fetchPage = async (url: string): Promise<SymptomEventList> => {
urls.push(url);
const params = new URL(url, "http://localhost").searchParams;
const to = params.get("to")!;
const limit = Number(params.get("limit"));
const rows = all
.filter((e) => e.occurredAt <= to)
.sort((a, b) => (a.occurredAt < b.occurredAt ? 1 : -1))
.slice(0, limit);
return { events: rows, limit, hasMore: rows.length === limit };
};
return { urls, fetchPage };
}
function event(i: number, occurredAt: string): SymptomEventDTO {
return { id: `e-${i}`, occurredAt } as SymptomEventDTO;
}
describe("loadSymptomEvents", () => {
it("reads past the first page until the window is exhausted", async () => {
const base = Date.parse("2026-06-01T00:00:00.000Z");
// 1,200 occurrences an hour apart, two of them sharing a page boundary.
const all = Array.from({ length: 1_200 }, (_, i) =>
event(i, new Date(base - i * 3_600_000).toISOString()),
);
all.push(event(9_999, all[499].occurredAt));
const { urls, fetchPage } = fakeRoute(all);
const out = await loadSymptomEvents(
"1900-01-01T00:00:00.000Z",
"2026-06-02T00:00:00.000Z",
"ep-1",
fetchPage,
);
expect(out.events).toHaveLength(1_201);
expect(new Set(out.events.map((e) => e.id)).size).toBe(1_201);
expect(out.hasMore).toBe(false);
expect(urls.length).toBeGreaterThan(2);
expect(urls.every((u) => u.includes("episodeId=ep-1"))).toBe(true);
});
it("stops after one request when the first page is not full", async () => {
const { urls, fetchPage } = fakeRoute([
event(1, "2026-05-01T00:00:00.000Z"),
]);
const out = await loadSymptomEvents(
"2026-04-01T00:00:00.000Z",
"2026-06-01T00:00:00.000Z",
null,
fetchPage,
);
expect(out.events).toHaveLength(1);
expect(urls).toHaveLength(1);
});
});
+48 -6
View File
@@ -55,9 +55,55 @@ export function useSymptomDefinitions(includeHidden = false, enabled = true) {
});
}
/** Occurrences per request while walking a window. */
const SYMPTOM_EVENTS_PAGE_SIZE = 500;
/**
* Every occurrence inside `[from, to]` (ISO instants), newest first, read page
* by page. The route answers at most one page and says `hasMore`; each
* further page asks up to the oldest instant already read (`to` is
* inclusive, so the boundary instant comes back and is dropped by id). A
* chronic episode can hold more than one page, and a single capped read
* showed only its newest part.
*/
export async function loadSymptomEvents(
from: string,
to: string,
episodeId: string | null,
fetchPage: (url: string) => Promise<SymptomEventList> = (url) =>
apiGet<SymptomEventList>(url),
): Promise<SymptomEventList> {
const seen = new Set<string>();
const events: SymptomEventDTO[] = [];
let upTo = to;
for (;;) {
const params = new URLSearchParams({
from,
to: upTo,
limit: String(SYMPTOM_EVENTS_PAGE_SIZE),
});
if (episodeId) params.set("episodeId", episodeId);
const page = await fetchPage(`/api/symptoms/events?${params}`);
let added = 0;
for (const event of page.events) {
if (seen.has(event.id)) continue;
seen.add(event.id);
events.push(event);
added += 1;
}
const oldest = page.events[page.events.length - 1];
// A page that adds nothing (a full page of one instant) cannot move the
// bound, so it ends the walk rather than repeating forever.
if (!page.hasMore || !oldest || added === 0) {
return { events, limit: SYMPTOM_EVENTS_PAGE_SIZE, hasMore: false };
}
upTo = oldest.occurredAt;
}
}
/**
* Occurrences inside `[from, to]` (ISO instants), optionally only those filed
* against one episode. At most 500, newest first.
* against one episode. Every one of them, newest first.
*/
export function useSymptomEvents(
from: string,
@@ -68,11 +114,7 @@ export function useSymptomEvents(
return useQuery({
queryKey: queryKeys.symptomEvents(from, to, episodeId),
enabled: options.enabled ?? true,
queryFn: () => {
const params = new URLSearchParams({ from, to, limit: "500" });
if (episodeId) params.set("episodeId", episodeId);
return apiGet<SymptomEventList>(`/api/symptoms/events?${params}`);
},
queryFn: () => loadSymptomEvents(from, to, episodeId),
});
}