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, /**