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
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 07:22:06 +02:00
1 parent aa58495cbe
commit 01cd755de6
5 files changed
+301 -26

No files matched your search

@@ -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();
}
});
});
@@ -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,
@@ -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<unknown>; queryKey?: unknown } = {};
vi.mock("@tanstack/react-query", async (orig) => {
const actual = await orig<typeof import("@tanstack/react-query")>();
return {
...actual,
useQuery: (opts: {
queryFn: () => Promise<unknown>;
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(
<I18nProvider initialLocale="en">
<DoseHistoryLedger
medicationId="med-1"
medicationName="Test"
schedules={[]}
/>
</I18nProvider>,
);
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,
]);
});
});
@@ -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<LedgerPayload>({
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<LedgerPayload>(
`/api/medications/${medicationId}/dose-history?${search.toString()}`,
);
+2 -2
View File
@@ -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,
/**