From 01cd755de65788318e2a3b87f6591900e4b79262 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 07:22:06 +0200 Subject: [PATCH] fix(medications): keep recent doses in the trailing history window The Verlauf ledger dropped doses the full history showed. Three windows ended too early or started too late: - The server clamped the window floor to the medication's creation instant and filtered recorded doses by the same floor, so a dose logged for earlier the same day on a medication added afterwards fell out of "Last 90 days". The creation floor now bounds minted slots only; recorded doses use the requested window. - A dose taken inside its window before the slot's own time is stored on the slot anchor, which still lay past the window end. The read horizon now reaches the latest recorded anchor within a day, without minting later upcoming slots. - The client pinned from/to at mount and reused them on every refetch, so a dose added after the tab opened stayed hidden until a reload. The window is now resolved when the request fires and only "from" travels; the query key carries the window length instead of instants. Refs #1028 --- .../[id]/dose-history/__tests__/route.test.ts | 156 ++++++++++++++++++ .../medications/[id]/dose-history/route.ts | 37 ++++- .../dose-history-ledger-window.test.tsx | 105 ++++++++++++ .../medications/dose-history-ledger.tsx | 25 +-- src/lib/query-keys/medications.ts | 4 +- 5 files changed, 301 insertions(+), 26 deletions(-) create mode 100644 src/components/medications/__tests__/dose-history-ledger-window.test.tsx diff --git a/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts b/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts index 4a5c8fed8..23e01838e 100644 --- a/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts @@ -264,3 +264,159 @@ describe("GET /api/medications/[id]/dose-history", () => { expect(res.status).toBe(422); }); }); + +/** + * Issue #1028 — a dose logged for earlier today on a medication created later + * the same day vanished from the trailing-window ledger while the full + * history (which has no window) showed it. The window floor was clamped to + * the medication's creation instant and the same clamped floor filtered the + * intakes, so any dose recorded with a time before the moment the medication + * was added fell out of "Last 90 days". The creation floor exists to keep + * the ledger from minting phantom slots before the medication existed; it + * says nothing about which recorded doses belong in the window. + * + * Pinned in a positive and a negative UTC offset so a zone-dependent floor + * cannot hide behind one of them. + */ +describe("GET /api/medications/[id]/dose-history — intakes recorded before creation (#1028)", () => { + for (const tz of ["Asia/Kolkata", "America/Los_Angeles"]) { + it(`keeps a same-day dose logged before the medication was added (${tz})`, async () => { + const today = new Date("2026-09-22T12:00:00Z"); + const now = localHmAsUtc(today, tz, 16, 30); + const createdAt = localHmAsUtc(today, tz, 16, 20); + const takenAt = localHmAsUtc(today, tz, 14, 0); + + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(now); + try { + vi.mocked(getSession).mockResolvedValue({ + ...SESSION_OK, + user: { ...SESSION_OK.user, timezone: tz }, + } as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt, + scheduleRevisions: [], + schedules: [ + { + id: "sched-1", + windowStart: "08:00", + windowEnd: "08:00", + daysOfWeek: null, + timesOfDay: ["08:00"], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }, + ], + } as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + { + id: "evt-earlier-today", + scheduledFor: takenAt, + takenAt, + skipped: false, + autoMissed: false, + attributionSource: "AUTO", + doseTaken: null, + source: "WEB", + }, + ] as never); + + // Exactly what the Verlauf tab sends: the trailing 90 days to now. + const from = new Date(now.getTime() - 90 * 86_400_000).toISOString(); + const to = now.toISOString(); + const res = await GET( + getReq( + `?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`, + ), + ROUTE_PARAMS, + ); + expect(res.status).toBe(200); + const json = await res.json(); + const ids = ( + json.data.rows as Array<{ intake: { id: string | null } | null }> + ).map((r) => r.intake?.id); + expect(ids).toContain("evt-earlier-today"); + } finally { + vi.useRealTimers(); + } + }); + } +}); + +describe("GET /api/medications/[id]/dose-history — early take (#1028 class)", () => { + it("keeps a dose taken inside its window before the slot's own time", async () => { + const tz = "Asia/Kolkata"; + const today = new Date("2026-09-22T12:00:00Z"); + const now = localHmAsUtc(today, tz, 19, 30); + const slot = localHmAsUtc(today, tz, 20, 0); + const takenAt = localHmAsUtc(today, tz, 19, 15); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(now); + try { + vi.mocked(getSession).mockResolvedValue({ + ...SESSION_OK, + user: { ...SESSION_OK.user, timezone: tz }, + } as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00Z"), + scheduleRevisions: [], + schedules: [ + { + id: "sched-1", + windowStart: "20:00", + windowEnd: "20:00", + daysOfWeek: null, + timesOfDay: ["20:00"], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }, + ], + } as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + { + id: "evt-early", + scheduledFor: slot, + takenAt, + skipped: false, + autoMissed: false, + attributionSource: "AUTO", + doseTaken: null, + source: "WEB", + }, + ] as never); + const from = new Date(now.getTime() - 90 * 86_400_000).toISOString(); + const res = await GET( + getReq(`?from=${encodeURIComponent(from)}`), + ROUTE_PARAMS, + ); + const json = await res.json(); + const rows = json.data.rows as Array<{ + status: string; + intake: { id: string | null } | null; + }>; + const early = rows.find((r) => r.intake?.id === "evt-early"); + expect(early?.status).toBe("taken_on_time"); + // The read horizon stretches only to the recorded anchor: no later + // slot is minted as upcoming. + expect(rows.filter((r) => r.status === "upcoming")).toHaveLength(0); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/app/api/medications/[id]/dose-history/route.ts b/src/app/api/medications/[id]/dose-history/route.ts index 11c60c9a0..11256b61f 100644 --- a/src/app/api/medications/[id]/dose-history/route.ts +++ b/src/app/api/medications/[id]/dose-history/route.ts @@ -142,12 +142,18 @@ export const GET = apiHandler( const requestedFrom = parsed.data.from ?? new Date(to.getTime() - 90 * DAY_MS); const spanFloor = new Date(to.getTime() - MAX_WINDOW_DAYS * DAY_MS); + // The window the caller asked for, capped only by the span limit. This is + // the floor for recorded doses: a dose logged with a time before the + // medication was added (created at 16:20, "taken at 14:00") is a real + // record and belongs in the window, exactly as the unwindowed full + // history shows it (#1028). + const intakeFrom = new Date( + Math.max(requestedFrom.getTime(), spanFloor.getTime()), + ); + // The floor for minted slots additionally stops at the medication's + // creation, so pre-existence days never mint phantom missed slots. const from = new Date( - Math.max( - requestedFrom.getTime(), - medication.createdAt.getTime(), - spanFloor.getTime(), - ), + Math.max(intakeFrom.getTime(), medication.createdAt.getTime()), ); const events = await prisma.medicationIntakeEvent.findMany({ @@ -182,6 +188,23 @@ export const GET = apiHandler( const lastIntakeAt = lastNonSkippedTakenAt(mapped); + // A dose may be recorded before its slot's own time: the dose window opens + // up to two hours early, and a skip can be logged ahead. Such a row is + // stored on the slot's anchor, which still lies after `to`, so a window + // that ends at `to` dropped a dose the user had just recorded until the + // slot's clock time passed (#1028). Extend the read horizon to the latest + // anchor of a recorded row (taken or skipped) within a day of `to`, and no + // further: pending reminder rows record nothing and never stretch it. + const horizon = new Date( + mapped.reduce((latest, e) => { + const t = e.scheduledFor.getTime(); + const recorded = e.skipped || (e.takenAt !== null && e.takenAt <= to); + return recorded && t > latest && t <= to.getTime() + DAY_MS + ? t + : latest; + }, to.getTime()), + ); + const bandMedication: BandMinterMedication = { id: medication.id, startsOn: medication.startsOn, @@ -223,7 +246,7 @@ export const GET = apiHandler( revisions: medication.scheduleRevisions ?? [], ctx, userTz, - range: { from, to }, + range: { from, to: horizon }, now, intakeInstants, }); @@ -241,7 +264,7 @@ export const GET = apiHandler( // partition, so filter on the stored anchor (a take snapped to an // out-of-window slot is excluded, matching the compliance read). const historyIntakes: HistoryIntake[] = mapped - .filter((e) => e.scheduledFor >= from && e.scheduledFor <= to) + .filter((e) => e.scheduledFor >= intakeFrom && e.scheduledFor <= horizon) .map((e) => ({ id: e.id, scheduledFor: e.scheduledFor, diff --git a/src/components/medications/__tests__/dose-history-ledger-window.test.tsx b/src/components/medications/__tests__/dose-history-ledger-window.test.tsx new file mode 100644 index 000000000..6f0884b52 --- /dev/null +++ b/src/components/medications/__tests__/dose-history-ledger-window.test.tsx @@ -0,0 +1,105 @@ +/** + * Issue #1028 — the Verlauf ledger's trailing window must be resolved when + * the request fires, not when the tab mounts. + * + * The ledger used to pin `from` and `to` in state at mount and reuse them for + * every fetch. A refetch after adding a dose therefore asked for rows up to + * the moment the tab opened, and a dose recorded afterwards stayed out of + * "Last 90 days" until a reload, while the unwindowed full history showed it. + * + * The test captures the query function the ledger hands to TanStack Query and + * runs it twice at two different clock readings: both requests must reach the + * present, which means no pinned `to` and a `from` that moves with the clock. + */ +import { describe, expect, it, vi, afterEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const captured: { queryFn?: () => Promise; queryKey?: unknown } = {}; + +vi.mock("@tanstack/react-query", async (orig) => { + const actual = await orig(); + return { + ...actual, + useQuery: (opts: { + queryFn: () => Promise; + queryKey: unknown; + }) => { + captured.queryFn = opts.queryFn; + captured.queryKey = opts.queryKey; + return { data: undefined, isLoading: true, isError: false }; + }, + useQueryClient: () => ({}), + }; +}); + +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: vi.fn().mockResolvedValue({ rows: [] }), + apiPost: vi.fn(), + apiPut: vi.fn(), + apiDelete: vi.fn(), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", username: "t", role: "USER", timezone: "Asia/Kolkata" }, + isAuthenticated: true, + isLoading: false, + }), +})); + +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ canWriteDomain: () => true }), + useActiveRecordName: () => null, +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { DoseHistoryLedger } from "@/components/medications/dose-history-ledger"; +import { apiGet } from "@/lib/api/api-fetch"; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("DoseHistoryLedger request window (#1028)", () => { + it("resolves the window at fetch time so a later refetch reaches the present", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + const mountedAt = new Date("2026-09-22T10:00:00Z"); + vi.setSystemTime(mountedAt); + + renderToStaticMarkup( + + + , + ); + expect(captured.queryFn).toBeTypeOf("function"); + + await captured.queryFn!(); + // An hour later the user adds a dose; the invalidation refetches. + const later = new Date(mountedAt.getTime() + 3_600_000); + vi.setSystemTime(later); + await captured.queryFn!(); + + const urls = vi.mocked(apiGet).mock.calls.map((c) => String(c[0])); + expect(urls).toHaveLength(2); + for (const url of urls) { + const params = new URL(url, "http://x").searchParams; + // No pinned upper bound: the server's `to` is its own now. + expect(params.get("to")).toBeNull(); + } + const secondFrom = new URL(urls[1], "http://x").searchParams.get("from"); + expect(secondFrom).toBe( + new Date(later.getTime() - 90 * 86_400_000).toISOString(), + ); + // The cache key carries no instant, so it stays stable across renders. + expect(captured.queryKey).toEqual([ + "medications", + "med-1", + "dose-history", + 90, + ]); + }); +}); diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx index d5ecabcb9..e80592a7f 100644 --- a/src/components/medications/dose-history-ledger.tsx +++ b/src/components/medications/dose-history-ledger.tsx @@ -200,27 +200,18 @@ export function DoseHistoryLedger({ slotAt: string; } | null>(null); - // Stable window captured once at mount (a lazy state initializer keeps the - // impure `Date.now()` out of render) so the query key + the `from`/`to` - // request stay byte-equal across renders rather than re-keying every paint. - const [{ fromIso, toIso }] = useState(() => { - const now = Date.now(); - return { - toIso: new Date(now).toISOString(), - fromIso: new Date(now - windowDays * DAY_MS).toISOString(), - }; - }); - - const queryKey = queryKeys.medicationDoseHistory( - medicationId, - fromIso, - toIso, - ); + // The window is resolved when the request fires, not when the ledger + // mounts. A window pinned at mount made every refetch (including the one + // after adding an entry) ask for rows up to the moment the tab opened, so a + // dose recorded afterwards stayed invisible until a reload (#1028). Only + // `from` travels: the server's `to` defaults to its own now. + const queryKey = queryKeys.medicationDoseHistory(medicationId, windowDays); const { data, isLoading, isError } = useQuery({ queryKey, queryFn: async () => { - const search = new URLSearchParams({ from: fromIso, to: toIso }); + const from = new Date(Date.now() - windowDays * DAY_MS).toISOString(); + const search = new URLSearchParams({ from }); return apiGet( `/api/medications/${medicationId}/dose-history?${search.toString()}`, ); diff --git a/src/lib/query-keys/medications.ts b/src/lib/query-keys/medications.ts index 979aefde5..b7f5b2f1a 100644 --- a/src/lib/query-keys/medications.ts +++ b/src/lib/query-keys/medications.ts @@ -83,8 +83,8 @@ export const medicationKeys = { * Übersicht headline % flip in the same paint, then reconciles on the * authoritative refetch. */ - medicationDoseHistory: (medicationId: string, from: string, to: string) => - ["medications", medicationId, "dose-history", from, to] as const, + medicationDoseHistory: (medicationId: string, windowDays: number) => + ["medications", medicationId, "dose-history", windowDays] as const, medicationIntakeDrugLevelChart: (medicationId: string) => ["medications", medicationId, "intake", "drug-level-chart"] as const, /**