mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
7c905249fb
commit
648bb0d985
5 files changed
+431
-8
No files matched your search
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* No client list or picker with a fixed row cap and nothing to reach past it.
|
||||
*
|
||||
* A client read that asks for "the newest N" and renders the answer as the
|
||||
* whole list loses everything beyond N without a word: the Labs list asked
|
||||
* for 500 raw rows and grouped them in the browser, so on a long history
|
||||
* every marker measured only long ago had no row at all. The visit-form lab
|
||||
* picker was the same class before it, and the GLP-1 curve, the side-effect
|
||||
* log and the symptom timeline each had a quieter copy.
|
||||
*
|
||||
* The rule: every literal `limit=<number>` a client fetch sends either sits
|
||||
* beside a pager (an `offset` / `cursor` / `pageParam` within a few lines, or
|
||||
* a `useInfiniteQuery` in the file), or is listed below with the reason the
|
||||
* cap cannot cut anything off.
|
||||
*
|
||||
* What it matches: `limit=<digits>` inside a URL string, and the two
|
||||
* `URLSearchParams` spellings, `.set("limit", "<digits>")` and
|
||||
* `limit: "<digits>"`. What it does not: a limit routed through a named
|
||||
* constant (`limit=${PAGE_SIZE}`), which is a deliberate page size in every
|
||||
* case today. It walks client code only (`components/`, `hooks/`, and `app/`
|
||||
* outside `app/api/`); a server-side `take` is not a fetch.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
interface Allowed {
|
||||
file: string;
|
||||
limit: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
const ALLOWLIST: readonly Allowed[] = [
|
||||
{
|
||||
file: "app/labs/page.tsx",
|
||||
limit: 1,
|
||||
reason:
|
||||
"Deep-link resolution: the newest reading under one analyte name, used only to find its marker page.",
|
||||
},
|
||||
{
|
||||
file: "components/onboarding/first-result-screen.tsx",
|
||||
limit: 1,
|
||||
reason: "Asks only whether a type has a reading, and which is newest.",
|
||||
},
|
||||
{
|
||||
file: "components/insights/glp1-substance-curve-summary.tsx",
|
||||
limit: 1,
|
||||
reason:
|
||||
"Reads only `medications`, which lists every GLP-1 medication whatever the entry cap; the entries are not used.",
|
||||
},
|
||||
{
|
||||
file: "components/charts/chart-series-request.ts",
|
||||
limit: 5000,
|
||||
reason:
|
||||
"Daily rollup buckets: one row per day, and the widest window (All, 3,650 days, plus a 365-day comparison) is 4,015 days.",
|
||||
},
|
||||
{
|
||||
file: "components/insights/trend-annotation.tsx",
|
||||
limit: 5000,
|
||||
reason: "Daily rollup buckets over a 30-day window: at most 31 rows.",
|
||||
},
|
||||
{
|
||||
file: "components/insights/coach-panel/attachment-picker.tsx",
|
||||
limit: 50,
|
||||
reason:
|
||||
"Search-backed picker: the query narrows the whole document vault server-side.",
|
||||
},
|
||||
{
|
||||
file: "components/settings/share-document-picker.tsx",
|
||||
limit: 50,
|
||||
reason:
|
||||
"Search-backed picker: the query narrows the whole document vault server-side.",
|
||||
},
|
||||
{
|
||||
file: "components/documents/document-link-picker.tsx",
|
||||
limit: 50,
|
||||
reason:
|
||||
"Search-backed picker: the query narrows the whole document vault server-side.",
|
||||
},
|
||||
{
|
||||
file: "hooks/use-mood-day.ts",
|
||||
limit: 50,
|
||||
reason:
|
||||
"One calendar day of mood entries; fifty check-ins in a day is beyond any cadence the app offers.",
|
||||
},
|
||||
];
|
||||
|
||||
const LIMIT_PATTERNS: readonly RegExp[] = [
|
||||
/[?&]limit=(\d+)/g,
|
||||
/\.set\(\s*["']limit["']\s*,\s*["'](\d+)["']\s*\)/g,
|
||||
/\blimit:\s*["'](\d+)["']/g,
|
||||
];
|
||||
|
||||
const PAGER_NEARBY = /\boffset\b|\bcursor\b|\bpageParam\b/;
|
||||
const PAGER_LINES = 3;
|
||||
|
||||
function clientFiles(): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter(
|
||||
(p) =>
|
||||
p.startsWith("components/") ||
|
||||
p.startsWith("hooks/") ||
|
||||
(p.startsWith("app/") && !p.startsWith("app/api/")),
|
||||
)
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !/\.test\.tsx?$/.test(p))
|
||||
.sort();
|
||||
}
|
||||
|
||||
interface Hit {
|
||||
file: string;
|
||||
line: number;
|
||||
limit: number;
|
||||
paged: boolean;
|
||||
}
|
||||
|
||||
/** Every literal limit in the given source, with whether a pager is beside it. */
|
||||
function findLimitHits(file: string, source: string): Hit[] {
|
||||
const lines = source.split("\n");
|
||||
const infinite = /\buseInfiniteQuery\b/.test(source);
|
||||
const hits: Hit[] = [];
|
||||
lines.forEach((text, index) => {
|
||||
// Comments describe old shapes; they send nothing.
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.startsWith("//") || trimmed.startsWith("*")) return;
|
||||
for (const pattern of LIMIT_PATTERNS) {
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
const near = lines
|
||||
.slice(Math.max(0, index - PAGER_LINES), index + PAGER_LINES + 1)
|
||||
.join("\n");
|
||||
hits.push({
|
||||
file,
|
||||
line: index + 1,
|
||||
limit: Number(match[1]),
|
||||
paged: infinite || PAGER_NEARBY.test(near),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return hits;
|
||||
}
|
||||
|
||||
function allHits(): Hit[] {
|
||||
return clientFiles().flatMap((file) =>
|
||||
findLimitHits(file, readFileSync(join(SRC, file), "utf8")),
|
||||
);
|
||||
}
|
||||
|
||||
function isAllowed(hit: Hit): boolean {
|
||||
return ALLOWLIST.some((a) => a.file === hit.file && a.limit === hit.limit);
|
||||
}
|
||||
|
||||
describe("client fetch limits", () => {
|
||||
it("finds the literal limits it is meant to police", () => {
|
||||
// An empty sweep would agree with any allowlist. Every allowlisted cap is
|
||||
// a live hit, so the hit set can never be smaller than the list.
|
||||
expect(allHits().length).toBeGreaterThanOrEqual(ALLOWLIST.length);
|
||||
});
|
||||
|
||||
it("puts every fixed limit beside a pager or on the allowlist", () => {
|
||||
const offenders = allHits()
|
||||
.filter((hit) => !hit.paged && !isAllowed(hit))
|
||||
.map((hit) => `${hit.file}:${hit.line} limit=${hit.limit}`);
|
||||
expect(
|
||||
offenders,
|
||||
"A client fetch with a fixed limit loses everything past it. Page it (offset / cursor / useInfiniteQuery), move the grouping server-side, or add an allowlist entry that says why the cap cannot cut anything off.",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps no allowlist entry that no longer matches anything", () => {
|
||||
const hits = allHits();
|
||||
const stale = ALLOWLIST.filter(
|
||||
(a) => !hits.some((h) => h.file === a.file && h.limit === a.limit),
|
||||
).map((a) => `${a.file} limit=${a.limit}`);
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
|
||||
it("gives every allowlist entry a reason", () => {
|
||||
for (const entry of ALLOWLIST) {
|
||||
expect(entry.reason.length, entry.file).toBeGreaterThan(20);
|
||||
}
|
||||
});
|
||||
|
||||
it("flags the shape that dropped markers from the Labs list", () => {
|
||||
const hits = findLimitHits(
|
||||
"components/labs/lab-list.tsx",
|
||||
'apiGet("/api/labs?limit=500&sortDir=desc")',
|
||||
);
|
||||
expect(hits).toHaveLength(1);
|
||||
expect(hits[0].paged).toBe(false);
|
||||
expect(isAllowed(hits[0])).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a limit that travels with an offset", () => {
|
||||
const hits = findLimitHits(
|
||||
"components/example.tsx",
|
||||
"fetch(`/api/x?status=taken&limit=100&offset=${offset}`)",
|
||||
);
|
||||
expect(hits).toHaveLength(1);
|
||||
expect(hits[0].paged).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user