diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 009d20da6..32f386400 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -5984,12 +5984,14 @@ paths: name: from schema: description: Window start (inclusive). Defaults to 90 days before `to`; clamped to a 366-day span floor. Recorded doses - are returned from this instant; expected slots are not minted before the medication's `createdAt`. + are returned from this instant. A slot from before the medication's `createdAt` appears only when a + recorded dose (a take or a skip) claims it; the pending placeholder of such a slot is not returned. type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: Window start (inclusive). Defaults to 90 days before `to`; clamped to a 366-day span floor. Recorded doses - are returned from this instant; expected slots are not minted before the medication's `createdAt`. + are returned from this instant. A slot from before the medication's `createdAt` appears only when a recorded + dose (a take or a skip) claims it; the pending placeholder of such a slot is not returned. - in: query name: to schema: @@ -43263,8 +43265,9 @@ components: type: integer minimum: 0 maximum: 9007199254740991 - description: "Full denominator over the window: `taken + skipped + missed`. Cadence-aware and clamped to the - medication's `createdAt`." + description: "Full denominator over the window: `taken + skipped + missed`. Cadence-aware. A slot from before the + medication's `createdAt` counts only when a recorded dose (a take or a skip) claims it, so the days before + the medication existed are never missed." taken: type: integer minimum: 0 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 1cccc72ad..fe998e2a6 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 @@ -426,3 +426,147 @@ describe("GET /api/medications/[id]/dose-history — early take (#1028 class)", } }); }); + +/** + * #1028 follow-up — a medication added in the afternoon, dosed 09:00 / 14:00 + * / 21:00. The 14:00 dose of the creation day was recorded afterwards on its + * own slot, the 21:00 dose came in through the reminder, and the 09:00 slot + * of that day only ever held the pending placeholder the today projector + * mints for every slot of the day, which the auto-miss pass later stamped. + * + * The 14:00 dose must read as its own slot, taken on time, not as an + * off-schedule take "due" at 21:00; the 09:00 slot, which predates the + * medication and holds no recorded dose, must not appear at all. + */ +describe("GET /api/medications/[id]/dose-history — slots before creation (#1028)", () => { + const cases = [ + { tz: "Asia/Kolkata", edited: false }, + { tz: "America/New_York", edited: false }, + // The schedule was saved again shortly after the medication was added, + // so the creation day lives in an archived schedule era. + { tz: "Asia/Kolkata", edited: true }, + ]; + for (const { tz, edited } of cases) { + it(`attributes a recorded dose on a pre-creation slot and hides the unrecorded one (${tz}${edited ? ", edited schedule" : ""})`, async () => { + const day1 = new Date("2026-09-23T12:00:00Z"); + const day2 = new Date("2026-09-24T12:00:00Z"); + const createdAt = localHmAsUtc(day1, tz, 16, 5); + const now = localHmAsUtc(day2, tz, 22, 0); + const d1 = (h: number, m = 0) => localHmAsUtc(day1, tz, h, m); + const d2 = (h: number, m = 0) => localHmAsUtc(day2, tz, h, m); + + 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: new Date("2026-09-23T00:00:00Z"), + endsOn: new Date("2026-09-28T00:00:00Z"), + oneShot: false, + createdAt, + scheduleRevisions: edited + ? [ + { + id: "rev-1", + validFrom: createdAt, + validUntil: localHmAsUtc(day1, tz, 16, 30), + supersededByRevisionId: null, + payload: [ + { + timesOfDay: ["09:00", "14:00", "21:00"], + windowStart: "09:00", + windowEnd: "09:00", + daysOfWeek: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + label: null, + dose: "1 tablet", + reminderGraceMinutes: null, + }, + ], + }, + ] + : [], + schedules: [ + { + id: "sched-1", + windowStart: "09:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: ["09:00", "14:00", "21:00"], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }, + ], + } as never); + const row = ( + id: string, + scheduledFor: Date, + takenAt: Date | null, + extra: Partial<{ autoMissed: boolean; source: string }> = {}, + ) => ({ + id, + scheduledFor, + takenAt, + skipped: false, + autoMissed: extra.autoMissed ?? false, + attributionSource: "AUTO", + doseTaken: null, + source: extra.source ?? "REMINDER", + }); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + row("d2-2100", d2(21), d2(21, 30)), + row("d2-1400", d2(14), d2(14)), + row("d2-0900", d2(9), d2(10)), + row("d1-2100", d1(21), d1(21)), + row("d1-1400", d1(14), d1(14), { source: "WEB" }), + // The projector's placeholder for the pre-creation 09:00 slot, + // stamped by the auto-miss pass a day later. + row("d1-0900-placeholder", d1(9), null, { autoMissed: true }), + ] as never); + + const from = new Date(now.getTime() - 90 * 86_400_000).toISOString(); + const res = await GET( + getReq( + `?from=${encodeURIComponent(from)}&to=${encodeURIComponent(now.toISOString())}`, + ), + ROUTE_PARAMS, + ); + expect(res.status).toBe(200); + const json = await res.json(); + const rows = json.data.rows as Array<{ + kind: string; + at: string; + timeOfDay: string | null; + status: string; + nearestSlot?: unknown; + intake: { id: string | null } | null; + }>; + + expect( + rows.map((r) => [r.at, r.kind, r.status, r.intake?.id ?? null]), + ).toEqual([ + [d1(14).toISOString(), "slot", "taken_on_time", "d1-1400"], + [d1(21).toISOString(), "slot", "taken_on_time", "d1-2100"], + [d2(9).toISOString(), "slot", "taken_on_time", "d2-0900"], + [d2(14).toISOString(), "slot", "taken_on_time", "d2-1400"], + [d2(21).toISOString(), "slot", "taken_on_time", "d2-2100"], + ]); + } 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 8b1dae088..82aa5a5e2 100644 --- a/src/app/api/medications/[id]/dose-history/route.ts +++ b/src/app/api/medications/[id]/dose-history/route.ts @@ -134,26 +134,23 @@ export const GET = apiHandler( return apiError("Medication not found", 404); } - // Resolve the window. Default to the trailing 90 days; clamp the floor to - // the medication's creation so pre-existence days never mint phantom - // slots, and cap the span so a pathological `from=1970` can't expand a - // year-plus of bands. `to` defaults to `now`. + // Resolve the window. Default to the trailing 90 days and cap the span + // so a pathological `from=1970` can't expand a year-plus of bands. `to` + // defaults to `now`. const to = parsed.data.to ?? now; 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. + // The window the caller asked for, capped only by the span limit. Both + // recorded doses and minted slots use it: a dose logged for a slot + // before the medication was added (created at 16:20, the 14:00 dose + // recorded afterwards) is a real record and must meet its slot, or it + // reads as an off-schedule take due at the next slot (#1028). Slots + // before the creation that no recorded dose claims stay out of the + // ledger: `reconstructDoseHistory` receives the creation instant as the + // floor of expected slots, so pre-existence days never read as missed. const from = new Date( - Math.max(intakeFrom.getTime(), medication.createdAt.getTime()), + Math.max(requestedFrom.getTime(), spanFloor.getTime()), ); const events = await prisma.medicationIntakeEvent.findMany({ @@ -264,7 +261,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 >= intakeFrom && e.scheduledFor <= horizon) + .filter((e) => e.scheduledFor >= from && e.scheduledFor <= horizon) .map((e) => ({ id: e.id, scheduledFor: e.scheduledFor, @@ -276,7 +273,12 @@ export const GET = apiHandler( source: e.source, })); - const rows = reconstructDoseHistory(bands, historyIntakes, now); + const rows = reconstructDoseHistory( + bands, + historyIntakes, + now, + medication.createdAt, + ); const serialized: SerializedDoseHistoryRow[] = rows.map((row) => ({ kind: row.kind, @@ -314,11 +316,10 @@ export const GET = apiHandler( }); // The window the recorded doses were read over (#1028): from the - // requested start (slots additionally start at the medication's - // creation) to the read horizon, which reaches past `to` only for a dose - // recorded ahead of its slot. + // requested start to the read horizon, which reaches past `to` only for + // a dose recorded ahead of its slot. return apiSuccess({ - from: intakeFrom.toISOString(), + from: from.toISOString(), to: horizon.toISOString(), family, hasExpectedSlots: bands.length > 0, diff --git a/src/lib/analytics/compliance/__tests__/compliance-bundle.test.ts b/src/lib/analytics/compliance/__tests__/compliance-bundle.test.ts index df88d73e3..49b82e255 100644 --- a/src/lib/analytics/compliance/__tests__/compliance-bundle.test.ts +++ b/src/lib/analytics/compliance/__tests__/compliance-bundle.test.ts @@ -159,10 +159,14 @@ describe("buildMedicationComplianceBundle — parity with the per-window composi buildComplianceDisplay(events, schedules, ctx, { now: NOW }), ); - // The ledger window clamps to the medication's creation. - expect(bundle.ledgerFrom).toEqual(createdAt); - // One band per daily slot over the 40-day life → 40 or 41 slot rows + // The ledger mints over the whole 365-day period (#1028); the slots + // before the creation hold no recorded dose, so none of them survive: + // one band per daily slot over the 40-day life → 40 or 41 slot rows // (the boundary day depends on the creation instant vs slot time). + expect(bundle.ledgerFrom).toEqual(new Date(NOW.getTime() - 365 * DAY_MS)); + expect( + bundle.ledgerRows.some((r) => r.at.getTime() < createdAt.getTime()), + ).toBe(false); const slotRows = bundle.ledgerRows.filter((r) => r.kind === "slot"); expect(slotRows.length).toBeGreaterThanOrEqual(39); expect(slotRows.length).toBeLessThanOrEqual(41); diff --git a/src/lib/analytics/compliance/__tests__/compliance.test.ts b/src/lib/analytics/compliance/__tests__/compliance.test.ts index e7bebd2d5..4246d8ece 100644 --- a/src/lib/analytics/compliance/__tests__/compliance.test.ts +++ b/src/lib/analytics/compliance/__tests__/compliance.test.ts @@ -3053,7 +3053,7 @@ describe("tallyComplianceFromLedger — the unified % keystone", () => { skipped: e.skipped, autoMissed: false, })); - const rows = reconstructDoseHistory(bands, intakes, nowEvening); + const rows = reconstructDoseHistory(bands, intakes, nowEvening, null); const fromLedger = { onTime: rows.filter((r) => r.status === "taken_on_time").length, diff --git a/src/lib/analytics/compliance/__tests__/pre-creation-slots.test.ts b/src/lib/analytics/compliance/__tests__/pre-creation-slots.test.ts new file mode 100644 index 000000000..1d666d92e --- /dev/null +++ b/src/lib/analytics/compliance/__tests__/pre-creation-slots.test.ts @@ -0,0 +1,109 @@ +/** + * #1028 — slots before a medication's creation, on the compliance side. + * + * The dose-history ledger keeps a slot that predates the medication only + * when a recorded dose claims it. Every adherence surface tallies that same + * ledger, so the rate, the per-window counts and the heatmap must agree with + * the history view: the creation day's 14:00 dose, recorded afterwards on its + * own slot, counts as taken on time, and the 09:00 slot before the medication + * existed (holding only the projector's auto-missed placeholder) counts as + * nothing. A caller whose window starts before the creation (the doctor + * report, the Coach) must not read the pre-existence slots as missed either. + */ +import { describe, expect, it } from "vitest"; + +import { + buildMedicationComplianceBundle, + tallyComplianceFromLedger, + type ComplianceMedicationContext, + type ComplianceSchedule, + type IntakeEvent, +} from "@/lib/analytics/compliance"; +import { localHmAsUtc } from "@/lib/tz/local-day"; +import { userDayKey } from "@/lib/tz/resolver"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +const schedule: ComplianceSchedule = { + windowStart: "09:00", + windowEnd: "09:00", + timesOfDay: ["09:00", "14:00", "21:00"], + daysOfWeek: null, + rrule: null, + rollingIntervalDays: null, + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, +}; + +for (const tz of ["Asia/Kolkata", "America/New_York"]) { + describe(`compliance over a medication added mid-day (#1028, ${tz})`, () => { + const day1 = new Date("2026-09-23T12:00:00Z"); + const day2 = new Date("2026-09-24T12:00:00Z"); + const d1 = (h: number, m = 0) => localHmAsUtc(day1, tz, h, m); + const d2 = (h: number, m = 0) => localHmAsUtc(day2, tz, h, m); + const createdAt = d1(16, 5); + const now = d2(22, 0); + + const ctx: ComplianceMedicationContext = { + startsOn: new Date("2026-09-23T00:00:00Z"), + endsOn: new Date("2026-09-28T00:00:00Z"), + oneShot: false, + createdAt, + lastIntakeAt: d2(21, 30), + timeZone: tz, + }; + const take = (slot: Date, takenAt: Date): IntakeEvent => ({ + scheduledFor: slot, + takenAt, + skipped: false, + autoMissed: false, + }); + const events: IntakeEvent[] = [ + { scheduledFor: d1(9), takenAt: null, skipped: false, autoMissed: true }, + take(d1(14), d1(14)), + take(d1(21), d1(21)), + take(d2(9), d2(10)), + take(d2(14), d2(14)), + take(d2(21), d2(21, 30)), + ]; + + it("counts the recorded pre-creation dose and ignores the placeholder", () => { + const bundle = buildMedicationComplianceBundle( + events, + [schedule], + ctx, + now, + ); + expect(bundle.compliance7).toMatchObject({ + taken: 5, + missed: 0, + skipped: 0, + rate: 100, + }); + const creationDay = bundle.ledgerRows.filter( + (r) => userDayKey(r.at, tz) === userDayKey(d1(12), tz), + ); + expect(creationDay.map((r) => [r.kind, r.timeOfDay, r.status])).toEqual([ + ["slot", "14:00", "taken_on_time"], + ["slot", "21:00", "taken_on_time"], + ]); + }); + + it("never reads pre-existence slots as missed in a wider window", () => { + // The doctor report and the Coach tally a window that starts before + // the medication existed. + const tally = tallyComplianceFromLedger( + [], + [schedule], + { ...ctx, lastIntakeAt: null }, + new Date(now.getTime() - 30 * DAY_MS), + d2(3, 0), + d2(3, 0), + ); + // Only the creation day's 21:00 slot was expected, and never taken. + expect(tally).toMatchObject({ taken: 0, missed: 1, denominator: 1 }); + }); + }); +} diff --git a/src/lib/analytics/compliance/display.ts b/src/lib/analytics/compliance/display.ts index 47f7feb00..10c60c9c6 100644 --- a/src/lib/analytics/compliance/display.ts +++ b/src/lib/analytics/compliance/display.ts @@ -317,11 +317,15 @@ export function calculateCompliance( rate: number; } | null = null; if (ledgerCtx) { + // The tally reads the whole period, not only the part after the + // medication's creation: the ledger itself keeps a slot before the + // creation only when a recorded dose claims it (#1028), so the rate + // matches the history view on the creation day. const tally = tallyComplianceFromLedger( events, schedules, ledgerCtx, - effectiveStart, + periodStart, now, now, ); @@ -484,7 +488,10 @@ export interface MedicationComplianceBundle { complianceDisplay: ComplianceDisplay; /** Unified ledger rows over `[ledgerFrom, now]`, chronological. */ ledgerRows: DoseHistoryRow[]; - /** Lower bound of the mint window (clamped to the medication's creation). */ + /** + * Lower bound of the mint window. Not clamped to the medication's + * creation: the ledger drops pre-creation slots no recorded dose claims. + */ ledgerFrom: Date; } @@ -498,7 +505,8 @@ export interface MedicationComplianceBundle { * mint — five-plus full band expansions per request. This builder instead: * * 1. mints the bands + reconstructs the ledger ONCE over - * `[max(createdAt, now − 365 d), now]` (every served window is a + * `[now − 365 d, now]` (slots before the medication's creation survive + * only when a recorded dose claims them; every served window is a * suffix of that range and ends at `now`, so a sub-window tally is a * filter over `row.at`, not a re-expansion); * 2. builds ONE cadence timeline over the same range for the per-window @@ -520,7 +528,12 @@ export function buildMedicationComplianceBundle( const ledgerPeriodStart = new Date( now.getTime() - COMPLIANCE_LEDGER_WINDOW_DAYS * ONE_DAY_MS, ); - const ledgerFrom = + // The ledger mints over the whole period: a slot before the medication's + // creation stays in it only when a recorded dose claims it (#1028), so no + // pre-existence day reads as missed. The streak timeline keeps its + // creation floor. + const ledgerFrom = ledgerPeriodStart; + const timelineFrom = ctx.createdAt.getTime() > ledgerPeriodStart.getTime() ? ctx.createdAt : ledgerPeriodStart; @@ -535,10 +548,17 @@ export function buildMedicationComplianceBundle( // the wide timeline reproduces the per-window builds. const fullDays = Math.max( 1, - Math.ceil((now.getTime() - ledgerFrom.getTime()) / ONE_DAY_MS), + Math.ceil((now.getTime() - timelineFrom.getTime()) / ONE_DAY_MS), ); const timeline = hasSchedules - ? buildTimelineForWindow(events, schedules, ctx, now, ledgerFrom, fullDays) + ? buildTimelineForWindow( + events, + schedules, + ctx, + now, + timelineFrom, + fullDays, + ) : []; const resultForWindow = (days: number): ComplianceResult => { @@ -563,7 +583,7 @@ export function buildMedicationComplianceBundle( Math.ceil((now.getTime() - effectiveStart.getTime()) / ONE_DAY_MS), ); const tally = tallyLedgerRows(ledgerRows, { - from: effectiveStart, + from: periodStart, to: now, }); const { current: streak } = streaksFromTimeline( @@ -590,10 +610,7 @@ export function buildMedicationComplianceBundle( const expectedOver = (days: number): number => { const hit = expectedCache.get(days); if (hit !== undefined) return hit; - const from = Math.max( - ctx.createdAt.getTime(), - now.getTime() - days * ONE_DAY_MS, - ); + const from = now.getTime() - days * ONE_DAY_MS; let count = 0; for (const row of ledgerRows) { if (row.kind !== "slot") continue; diff --git a/src/lib/analytics/compliance/ledger.ts b/src/lib/analytics/compliance/ledger.ts index 308f6b2de..0d40c6336 100644 --- a/src/lib/analytics/compliance/ledger.ts +++ b/src/lib/analytics/compliance/ledger.ts @@ -185,7 +185,10 @@ export function buildComplianceLedgerRows( pinned: e.attributionSource === "USER_PIN", })); - const rows = reconstructDoseHistory(bands, intakes, now); + // Slots before the medication's creation count only when a recorded dose + // claims them, so a caller's window reaching past the creation never + // mints phantom misses (#1028). + const rows = reconstructDoseHistory(bands, intakes, now, ctx.createdAt); // v1.25 H-MED1 — drop expected dose slots whose anchor falls inside a // pause interval. While a medication is paused no dose is expected, so a diff --git a/src/lib/medications/__tests__/compliance-payload-pre-creation.test.ts b/src/lib/medications/__tests__/compliance-payload-pre-creation.test.ts new file mode 100644 index 000000000..b07243f49 --- /dev/null +++ b/src/lib/medications/__tests__/compliance-payload-pre-creation.test.ts @@ -0,0 +1,103 @@ +/** + * #1028 — the compliance payload (the detail page's rates and heatmap) + * over a medication added mid-day. The creation day's 14:00 dose was + * recorded afterwards on its own slot; the 09:00 slot before the medication + * existed only ever held the projector's auto-missed placeholder. The + * payload must read the same day the dose-history view does: two slots, both + * taken on time, nothing missed. + */ +import { afterEach, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { medicationIntakeEvent: { findMany: vi.fn() } }, +})); + +import { prisma } from "@/lib/db"; +import { buildCompliancePayload } from "@/lib/medications/compliance-payload"; +import { localHmAsUtc } from "@/lib/tz/local-day"; +import { userDayKey } from "@/lib/tz/format"; + +afterEach(() => { + vi.useRealTimers(); + vi.resetAllMocks(); +}); + +for (const tz of ["Asia/Kolkata", "America/New_York"]) { + it(`reads the creation day like the history view (${tz})`, async () => { + const day1 = new Date("2026-09-23T12:00:00Z"); + const day2 = new Date("2026-09-24T12:00:00Z"); + const d1 = (h: number, m = 0) => localHmAsUtc(day1, tz, h, m); + const d2 = (h: number, m = 0) => localHmAsUtc(day2, tz, h, m); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(d2(22, 0)); + + const take = (slot: Date, takenAt: Date) => ({ + scheduledFor: slot, + takenAt, + skipped: false, + autoMissed: false, + attributionSource: "AUTO", + }); + const rows = [ + take(d2(21), d2(21, 30)), + take(d2(14), d2(14)), + take(d2(9), d2(10)), + take(d1(21), d1(21)), + take(d1(14), d1(14)), + { + scheduledFor: d1(9), + takenAt: null, + skipped: false, + autoMissed: true, + attributionSource: "AUTO", + }, + ]; + vi.mocked(prisma.medicationIntakeEvent.findMany).mockImplementation( + (async (args: { + where: { scheduledFor: { gte: Date } }; + }): Promise => + rows.filter( + (r) => r.scheduledFor >= args.where.scheduledFor.gte, + )) as never, + ); + + const payload = await buildCompliancePayload( + { + id: "med-1", + createdAt: d1(16, 5), + startsOn: new Date("2026-09-23T00:00:00Z"), + endsOn: new Date("2026-09-28T00:00:00Z"), + oneShot: false, + asNeeded: false, + schedules: [ + { + windowStart: "09:00", + windowEnd: "09:00", + timesOfDay: ["09:00", "14:00", "21:00"], + daysOfWeek: null, + rrule: null, + rollingIntervalDays: null, + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }, + ], + }, + "user-1", + tz, + ); + + expect(payload.compliance7).toMatchObject({ + taken: 5, + missed: 0, + rate: 100, + }); + expect(payload.dailyCompliance[userDayKey(d1(12), tz)]).toMatchObject({ + expected: 2, + taken: 2, + onTime: 2, + skipped: 0, + }); + }); +} diff --git a/src/lib/medications/compliance-payload.ts b/src/lib/medications/compliance-payload.ts index 10944c561..363335b94 100644 --- a/src/lib/medications/compliance-payload.ts +++ b/src/lib/medications/compliance-payload.ts @@ -184,14 +184,10 @@ export async function buildCompliancePayload( // v1.15.9 — pin a single `now` and thread it into every cadence // computation so no block can straddle a day boundary on a slow request. const now = new Date(); - const createdAt = medication.createdAt; - const fetchFrom = new Date( - Math.max( - createdAt.getTime(), - now.getTime() - EVENT_FETCH_WINDOW_DAYS * DAY_MS, - ), - ); + // Not clamped to the medication's creation: a dose recorded for a slot + // before the creation still claims that slot in the ledger (#1028). + const fetchFrom = new Date(now.getTime() - EVENT_FETCH_WINDOW_DAYS * DAY_MS); const events = await prisma.medicationIntakeEvent.findMany({ // v1.7.0 sync — exclude tombstoned rows from the compliance read. // Bounded to the 366-day fetch window: every served block is a suffix @@ -242,12 +238,10 @@ export async function buildCompliancePayload( // unified dose-history ledger (the same bands the % + the history view // read), so the per-day timing split (on-time / late) and the per-day // missed marks can never disagree with the headline rate. The 90-day - // window is carved out of the shared ledger by `row.at`; the window - // floor clamps to the medication's creation so pre-existence days never - // mint phantom slots. - const heatmapFrom = new Date( - Math.max(now.getTime() - 90 * DAY_MS, createdAt.getTime()), - ); + // window is carved out of the shared ledger by `row.at`. The ledger + // already drops pre-existence slots no recorded dose claims, so the floor + // is the plain 90-day window, the same one the history view reads (#1028). + const heatmapFrom = new Date(now.getTime() - 90 * DAY_MS); const dailyCompliance: Record = {}; const byDay = new Map(); diff --git a/src/lib/medications/scheduling/__tests__/dose-history.test.ts b/src/lib/medications/scheduling/__tests__/dose-history.test.ts index e5aa42f37..0603860fc 100644 --- a/src/lib/medications/scheduling/__tests__/dose-history.test.ts +++ b/src/lib/medications/scheduling/__tests__/dose-history.test.ts @@ -60,7 +60,7 @@ const nowEvening = at(23, 59); describe("reconstructDoseHistory", () => { it("emits one row per expected slot even with no intakes", () => { - const rows = reconstructDoseHistory(bands, [], nowEvening); + const rows = reconstructDoseHistory(bands, [], nowEvening, null); expect(rows.map((r) => r.timeOfDay)).toEqual(["07:00", "19:00"]); expect(rows.every((r) => r.kind === "slot")).toBe(true); expect(rows.map((r) => r.status)).toEqual(["missed", "missed"]); @@ -71,6 +71,7 @@ describe("reconstructDoseHistory", () => { bands, [intake({ takenAt: at(7, 0), scheduledFor: at(7, 0) })], nowEvening, + null, ); const morning = rows.find((r) => r.timeOfDay === "07:00"); expect(morning?.status).toBe("taken_on_time"); @@ -84,6 +85,7 @@ describe("reconstructDoseHistory", () => { bands, [intake({ takenAt: at(8, 30), scheduledFor: at(7, 0) })], nowEvening, + null, ); expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe( "taken_late", @@ -97,6 +99,7 @@ describe("reconstructDoseHistory", () => { bands, [intake({ takenAt: at(11, 29), scheduledFor: at(7, 0) })], nowEvening, + null, ); const adhoc = rows.find((r) => r.kind === "ad_hoc"); expect(adhoc?.status).toBe("ad_hoc"); @@ -109,6 +112,7 @@ describe("reconstructDoseHistory", () => { bands, [intake({ skipped: true, scheduledFor: at(7, 0) })], nowEvening, + null, ); expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("skipped"); }); @@ -118,13 +122,14 @@ describe("reconstructDoseHistory", () => { bands, [intake({ autoMissed: true, scheduledFor: at(7, 0) })], nowEvening, + null, ); expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("missed"); }); it("marks a future slot upcoming, not missed", () => { const earlyMorning = at(5, 0); // before every slot's miss cutoff - const rows = reconstructDoseHistory(bands, [], earlyMorning); + const rows = reconstructDoseHistory(bands, [], earlyMorning, null); expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("upcoming"); expect(rows.find((r) => r.timeOfDay === "19:00")?.status).toBe("upcoming"); }); @@ -137,6 +142,7 @@ describe("reconstructDoseHistory", () => { intake({ id: "b", takenAt: at(7, 20), scheduledFor: at(7, 0) }), ], nowEvening, + null, ); expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe( "taken_on_time", @@ -159,6 +165,7 @@ describe("reconstructDoseHistory", () => { bands, [intake({ scheduledFor: at(7, 0) })], at(8, 30), + null, ); const morning = rows.find((r) => r.timeOfDay === "07:00"); expect(morning?.status).toBe("upcoming"); @@ -172,6 +179,7 @@ describe("reconstructDoseHistory", () => { bands, [intake({ scheduledFor: at(7, 0) })], at(12, 0), + null, ); expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("missed"); }); @@ -195,6 +203,7 @@ describe("reconstructDoseHistory", () => { weeklyBands, [intake({ scheduledFor: weeklyAt })], twoDaysLater, + null, ); expect(rows).toHaveLength(1); expect(rows[0].status).toBe("upcoming"); @@ -210,6 +219,7 @@ describe("reconstructDoseHistory", () => { morningOnly, [intake({ scheduledFor: at(19, 0) })], at(14, 0), + null, ); expect(rows.filter((r) => r.kind === "ad_hoc")).toHaveLength(0); expect(rows.map((r) => r.timeOfDay)).toEqual(["07:00"]); @@ -221,6 +231,7 @@ describe("reconstructDoseHistory", () => { morningOnly, [intake({ skipped: true, scheduledFor: at(19, 0) })], at(14, 0), + null, ); expect(rows.filter((r) => r.kind === "ad_hoc")).toHaveLength(1); }); @@ -231,6 +242,7 @@ describe("reconstructDoseHistory", () => { bands, [intake({ autoMissed: true, scheduledFor: at(7, 0) })], at(8, 30), + null, ); expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("missed"); }); @@ -249,6 +261,7 @@ describe("reconstructDoseHistory", () => { bands, [intake({ takenAt: at(13, 0), scheduledFor: at(7, 0), pinned: true })], nowEvening, + null, ); const morning = rows.find((r) => r.timeOfDay === "07:00"); expect(morning?.status).toBe("taken_late"); @@ -264,6 +277,7 @@ describe("reconstructDoseHistory", () => { bands, [intake({ takenAt: at(7, 30), scheduledFor: at(7, 0), pinned: true })], nowEvening, + null, ); const morning = rows.find((r) => r.timeOfDay === "07:00"); expect(morning?.status).toBe("taken_on_time"); @@ -275,6 +289,7 @@ describe("reconstructDoseHistory", () => { bands, [intake({ takenAt: at(9, 0), scheduledFor: at(7, 0), pinned: true })], nowEvening, + null, ); expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe( "taken_late", @@ -294,6 +309,7 @@ describe("reconstructDoseHistory", () => { }), ], nowEvening, + null, ); const morning = rows.find((r) => r.timeOfDay === "07:00"); expect(morning?.intake?.id).toBe("pin"); @@ -315,6 +331,7 @@ describe("reconstructDoseHistory", () => { }), ], nowEvening, + null, ); expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("skipped"); const adhoc = rows.filter((r) => r.kind === "ad_hoc"); @@ -332,6 +349,7 @@ describe("reconstructDoseHistory", () => { }), ], nowEvening, + null, ); const adhoc = rows.filter((r) => r.kind === "ad_hoc"); expect(adhoc).toHaveLength(1); @@ -345,6 +363,7 @@ describe("reconstructDoseHistory", () => { bands, [intake({ takenAt: at(9, 0), scheduledFor: at(9, 0), pinned: true })], nowEvening, + null, ); const adhoc = rows.filter((r) => r.kind === "ad_hoc"); expect(adhoc).toHaveLength(1); @@ -364,6 +383,7 @@ describe("reconstructDoseHistory", () => { bands, [intake({ takenAt: t, scheduledFor: t, pinned: true })], nowEvening, + null, ); const adhoc = rows.filter((r) => r.kind === "ad_hoc"); expect(adhoc).toHaveLength(1); @@ -381,6 +401,7 @@ describe("reconstructDoseHistory", () => { bands, [intake({ takenAt: at(11, 29), scheduledFor: at(7, 0) })], nowEvening, + null, ); const adhoc = rows.find((r) => r.kind === "ad_hoc"); expect(adhoc?.nearestSlot?.timeOfDay).toBe("07:00"); @@ -394,6 +415,7 @@ describe("reconstructDoseHistory", () => { bands, [intake({ takenAt: at(12, 45), scheduledFor: at(7, 0) })], nowEvening, + null, ); const adhoc = rows.find((r) => r.kind === "ad_hoc"); expect(adhoc?.nearestSlot?.timeOfDay).toBe("07:00"); @@ -408,6 +430,7 @@ describe("reconstructDoseHistory", () => { intake({ id: "b", takenAt: at(7, 20), scheduledFor: at(7, 0) }), ], nowEvening, + null, ); const adhoc = rows.find((r) => r.kind === "ad_hoc"); expect(adhoc?.intake?.id).toBe("b"); @@ -421,6 +444,7 @@ describe("reconstructDoseHistory", () => { morningOnly, [intake({ skipped: true, scheduledFor: at(19, 0) })], at(14, 0), + null, ); const adhoc = rows.find((r) => r.kind === "ad_hoc"); expect(adhoc).toBeDefined(); @@ -458,6 +482,7 @@ describe("reconstructDoseHistory", () => { bands, [intake({ id: "x", takenAt: at(13, 0), scheduledFor: at(13, 0) })], nowEvening, + null, ); const times = rows.map((r) => r.at.getTime()); expect(times).toEqual([...times].sort((a, b) => a - b)); @@ -494,3 +519,117 @@ describe("suggestNearestSlot fallback cap", () => { expect(near?.timeOfDay).toBe("09:00"); }); }); + +/** + * #1028 — a medication created between its 07:00 and 19:00 slots. The 07:00 + * slot predates it: it shows only when a recorded dose claims it, and a + * pending or auto-missed placeholder on it never reads as missed or as an + * off-schedule row. + */ +describe("reconstructDoseHistory — slots before the medication existed (#1028)", () => { + const createdAt = at(12, 0); + const statusRows = (rows: ReturnType) => + rows.map((r) => [r.kind, r.timeOfDay, r.status, r.intake?.id ?? null]); + + it("omits an unclaimed pre-creation slot and keeps the later one", () => { + const rows = reconstructDoseHistory(bands, [], nowEvening, createdAt); + expect(statusRows(rows)).toEqual([["slot", "19:00", "missed", null]]); + }); + + it("drops an auto-missed placeholder on a pre-creation slot", () => { + const rows = reconstructDoseHistory( + bands, + [intake({ id: "placeholder", scheduledFor: at(7, 0), autoMissed: true })], + nowEvening, + createdAt, + ); + expect(statusRows(rows)).toEqual([["slot", "19:00", "missed", null]]); + }); + + it("drops a pending placeholder on a pre-creation slot", () => { + const rows = reconstructDoseHistory( + bands, + [intake({ id: "pending", scheduledFor: at(7, 0) })], + nowEvening, + createdAt, + ); + expect(statusRows(rows)).toEqual([["slot", "19:00", "missed", null]]); + }); + + it("drops an auto-missed placeholder whose pre-creation slot was not minted", () => { + const rows = reconstructDoseHistory( + bands, + [intake({ id: "orphan", scheduledFor: at(3, 0), autoMissed: true })], + nowEvening, + createdAt, + ); + expect(rows.some((r) => r.intake?.id === "orphan")).toBe(false); + }); + + it("materialises a pre-creation slot a take claims by its stored slot instant", () => { + const rows = reconstructDoseHistory( + bands, + [intake({ id: "t", scheduledFor: at(7, 0), takenAt: at(7, 0) })], + nowEvening, + createdAt, + ); + expect(statusRows(rows)).toEqual([ + ["slot", "07:00", "taken_on_time", "t"], + ["slot", "19:00", "missed", null], + ]); + }); + + it("materialises a pre-creation slot a take claims by band membership", () => { + // Stored as an unanchored take (scheduledFor === takenAt), late. + const rows = reconstructDoseHistory( + bands, + [intake({ id: "t", scheduledFor: at(9, 30), takenAt: at(9, 30) })], + nowEvening, + createdAt, + ); + expect(statusRows(rows)[0]).toEqual(["slot", "07:00", "taken_late", "t"]); + }); + + it("materialises a pre-creation slot a pin or a skip claims", () => { + const pinned = reconstructDoseHistory( + bands, + [ + intake({ + id: "p", + scheduledFor: at(7, 0), + takenAt: at(11, 30), + pinned: true, + }), + ], + nowEvening, + createdAt, + ); + expect(statusRows(pinned)[0]).toEqual(["slot", "07:00", "taken_late", "p"]); + + const skipped = reconstructDoseHistory( + bands, + [intake({ id: "s", scheduledFor: at(7, 0), skipped: true })], + nowEvening, + createdAt, + ); + expect(statusRows(skipped)[0]).toEqual(["slot", "07:00", "skipped", "s"]); + }); + + it("does not offer an absent pre-creation slot as due-context", () => { + // A take far outside every band, before the creation. + const rows = reconstructDoseHistory( + bands, + [intake({ id: "x", scheduledFor: at(1, 0), takenAt: at(1, 0) })], + nowEvening, + createdAt, + ); + const adHoc = rows.find((r) => r.intake?.id === "x"); + expect(adHoc?.kind).toBe("ad_hoc"); + expect(adHoc?.nearestSlot?.timeOfDay).not.toBe("07:00"); + }); + + it("treats every band as expected when no floor is given", () => { + const rows = reconstructDoseHistory(bands, [], nowEvening, null); + expect(rows.map((r) => r.timeOfDay)).toEqual(["07:00", "19:00"]); + }); +}); diff --git a/src/lib/medications/scheduling/__tests__/schedule-eras.test.ts b/src/lib/medications/scheduling/__tests__/schedule-eras.test.ts index 43dacab74..7d02534df 100644 --- a/src/lib/medications/scheduling/__tests__/schedule-eras.test.ts +++ b/src/lib/medications/scheduling/__tests__/schedule-eras.test.ts @@ -189,6 +189,24 @@ describe("segmentRangeIntoEras", () => { }); }); +describe("segmentRangeIntoEras — before the first revision (#1028)", () => { + it("covers the stretch before the medication's creation with the first era", () => { + // A slot before the creation must still mint so a dose recorded on it + // meets its slot; without a revision it mints against the live rows, + // with one it mints against the schedule that was live first. + const range = { + from: new Date("2026-04-30T00:00:00.000Z"), + to: new Date("2026-06-10T00:00:00.000Z"), + }; + const eras = segmentRangeIntoEras(range, [oldEraRevision], [liveSchedule]); + expect(eras).toHaveLength(2); + expect(eras[0].live).toBe(false); + expect(eras[0].from).toEqual(range.from); + expect(eras[0].schedules[0].timesOfDay).toEqual(["07:00", "19:00"]); + expect(eras[1].from).toEqual(replaceAt); + }); +}); + describe("buildBandsForSchedulesWithEras", () => { const range = { from: new Date("2026-05-20T00:00:00.000Z"), @@ -255,6 +273,7 @@ describe("buildBandsForSchedulesWithEras", () => { bands, [{ scheduledFor: takenAt, takenAt, skipped: false }], range.to, + null, ); const takenRows = rows.filter((r) => r.status === "taken_on_time"); expect(takenRows).toHaveLength(1); diff --git a/src/lib/medications/scheduling/cadence.ts b/src/lib/medications/scheduling/cadence.ts index d4d6bf0cf..19d6aec93 100644 --- a/src/lib/medications/scheduling/cadence.ts +++ b/src/lib/medications/scheduling/cadence.ts @@ -921,6 +921,11 @@ function missedFromLedger( skipped: e.skipped, autoMissed: e.autoMissed ?? false, })); - const rows = reconstructDoseHistory(bands, intakes, asOf); + const rows = reconstructDoseHistory( + bands, + intakes, + asOf, + engineCtx.createdAt, + ); return rows.filter((r) => r.status === "missed").length; } diff --git a/src/lib/medications/scheduling/compliance.ts b/src/lib/medications/scheduling/compliance.ts index e3478aaf8..283c74a8e 100644 --- a/src/lib/medications/scheduling/compliance.ts +++ b/src/lib/medications/scheduling/compliance.ts @@ -249,7 +249,12 @@ function ledgerChipCounts( pinned: e.attributionSource === "USER_PIN", })); - const rows = reconstructDoseHistory(bands, intakes, asOf); + const rows = reconstructDoseHistory( + bands, + intakes, + asOf, + engineCtx.createdAt, + ); let taken = 0; let missed = 0; for (const row of rows) { diff --git a/src/lib/medications/scheduling/dose-history.ts b/src/lib/medications/scheduling/dose-history.ts index 7a44ffe56..f496350a1 100644 --- a/src/lib/medications/scheduling/dose-history.ts +++ b/src/lib/medications/scheduling/dose-history.ts @@ -30,7 +30,13 @@ * anchor) and surfaces as an ad-hoc row carrying `pinned`, never as * `taken_late`; * - each slot is claimed by at most one intake (first/best wins); extra - * intakes near a filled slot fall through to ad-hoc. + * intakes near a filled slot fall through to ad-hoc; + * - a slot anchored before `expectedFrom` (the medication's creation) was + * never expected: it appears only when a recorded dose claims it (a + * take by band or pin, or a skip). A pending or auto-missed row on such + * a slot is the placeholder the today projector mints for every slot of + * the creation day, not a user action, so it is dropped rather than + * read as a miss or an off-schedule row (#1028). * * Pure / synchronous / instant-based — the caller mints the bands DST-correctly * via `localHmAsUtc` and supplies the per-dose window. @@ -136,7 +142,16 @@ export function reconstructDoseHistory( bands: SlotBand[], intakes: HistoryIntake[], now: Date, + /** + * The instant from which slots are expected, the medication's creation. + * Bands anchored earlier appear only when a recorded dose claims them. + * `null` treats every band as expected (a caller that already minted from + * the creation instant, or a pure-math fixture). + */ + expectedFrom: Date | null, ): DoseHistoryRow[] { + const expectedFromMs = expectedFrom?.getTime() ?? -Infinity; + const beforeExpected = (at: Date): boolean => at.getTime() < expectedFromMs; // Per-band claim: the intake attributed to it + the resolved status. const claim = new Map< SlotBand, @@ -155,6 +170,9 @@ export function reconstructDoseHistory( const taken = intakes.filter((i) => i.takenAt !== null && !i.pinned); for (const i of anchored) { + // A pending or auto-missed row before the medication existed records + // nothing: drop it whether or not its slot was minted. + if (!i.skipped && beforeExpected(i.scheduledFor)) continue; const band = nearestAnchorBand(i.scheduledFor, bands); if (band && !claim.has(band)) { // Status is time-aware for a pending row: the projector / reminder @@ -224,7 +242,13 @@ export function reconstructDoseHistory( } } - const rows: DoseHistoryRow[] = bands.map((band) => { + // A slot before the medication existed stays absent unless a recorded + // dose claimed it above. + const visibleBands = bands.filter( + (band) => claim.has(band) || !beforeExpected(band.at), + ); + + const rows: DoseHistoryRow[] = visibleBands.map((band) => { const c = claim.get(band); if (c) { return { @@ -257,7 +281,7 @@ export function reconstructDoseHistory( // Orphaned skips (no takenAt) carry no take to attribute, so no context. const nearestSlot = i.takenAt !== null - ? suggestNearestSlot(i.takenAt, bands, (band) => claim.has(band)) + ? suggestNearestSlot(i.takenAt, visibleBands, (band) => claim.has(band)) : null; rows.push({ kind: "ad_hoc", diff --git a/src/lib/medications/scheduling/schedule-eras.ts b/src/lib/medications/scheduling/schedule-eras.ts index 095e58a9b..481f5f5dc 100644 --- a/src/lib/medications/scheduling/schedule-eras.ts +++ b/src/lib/medications/scheduling/schedule-eras.ts @@ -14,7 +14,9 @@ * Era rules: * - revisions are chained: `validFrom` = previous revision's `validUntil` * (or `medication.createdAt` for the first), `validUntil` = the replace - * instant. The LIVE rows cover `[newest validUntil, ∞)`. + * instant. The LIVE rows cover `[newest validUntil, ∞)`. The first era + * also covers any range before its `validFrom`, so a pre-creation slot + * mints against the schedule that was live first. * - boundary: a slot belongs to the era its ANCHOR lies in. Eras mint * with an inclusive sub-range capped 1 ms short of `validUntil`, so an * anchor exactly at the boundary mints in the NEXT era only. On-time / @@ -171,10 +173,19 @@ export function segmentRangeIntoEras( const sorted = revisions .filter((r) => r.supersededByRevisionId == null) .sort((a, b) => a.validFrom.getTime() - b.validFrom.getTime()); - for (const revision of sorted) { - const from = new Date( - Math.max(revision.validFrom.getTime(), range.from.getTime()), - ); + for (const [index, revision] of sorted.entries()) { + // The earliest era also covers the time before its `validFrom` (the + // medication's creation): with no revision that stretch mints against + // the live rows, so with revisions it mints against the schedule that + // was live first. A dose recorded for a slot before the creation then + // meets its slot either way (#1028); unclaimed pre-creation slots are + // dropped by the ledger. + const from = + index === 0 + ? range.from + : new Date( + Math.max(revision.validFrom.getTime(), range.from.getTime()), + ); // Inclusive sub-range capped 1 ms short of `validUntil` — the anchor // boundary rule: a slot exactly at the boundary belongs to the era // that begins there, never to the one ending there. diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index 47a2bd6e3..8d42e217c 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -571,7 +571,7 @@ export const complianceResult = z .int() .nonnegative() .describe( - "Full denominator over the window: `taken + skipped + missed`. Cadence-aware and clamped to the medication's `createdAt`.", + "Full denominator over the window: `taken + skipped + missed`. Cadence-aware. A slot from before the medication's `createdAt` counts only when a recorded dose (a take or a skip) claims it, so the days before the medication existed are never missed.", ), taken: z.number().int().nonnegative(), skipped: z @@ -911,7 +911,7 @@ export const doseHistoryQuery = z.object({ .datetime({ offset: true }) .optional() .describe( - "Window start (inclusive). Defaults to 90 days before `to`; clamped to a 366-day span floor. Recorded doses are returned from this instant; expected slots are not minted before the medication's `createdAt`.", + "Window start (inclusive). Defaults to 90 days before `to`; clamped to a 366-day span floor. Recorded doses are returned from this instant. A slot from before the medication's `createdAt` appears only when a recorded dose (a take or a skip) claims it; the pending placeholder of such a slot is not returned.", ), to: z.iso .datetime({ offset: true }) diff --git a/tests/integration/dose-history-pre-creation-slots.test.ts b/tests/integration/dose-history-pre-creation-slots.test.ts new file mode 100644 index 000000000..9c4866972 --- /dev/null +++ b/tests/integration/dose-history-pre-creation-slots.test.ts @@ -0,0 +1,187 @@ +/** + * #1028 — a medication added in the afternoon, dosed 09:00 / 14:00 / 21:00. + * + * The 14:00 dose of the creation day is recorded afterwards through the real + * intake route, the 21:00 dose likewise, and the 09:00 slot of that day holds + * only the pending placeholder the today projector mints for every slot of + * the day, stamped auto-missed by the hourly pass. The Verlauf ledger and the + * compliance payload must both read that day as two slots taken on time: the + * 14:00 dose on its own slot (not an off-schedule take "due" at 21:00), and + * no 09:00 row at all, since the medication did not exist yet. + * + * Everything goes through the shipped `POST` / `GET` exports against Postgres, + * so the write-side slot resolution, the stored rows and both read models are + * exercised in one pipe. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { NextRequest } from "next/server"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +import { localHmAsUtc } from "@/lib/tz/local-day"; +import { userDayKey } from "@/lib/tz/format"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const DAY_MS = 24 * 60 * 60 * 1000; + +type Handler = ( + request: NextRequest, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context: { params: Promise }, +) => Promise; + +let counter = 0; + +async function signedInUser(tz: string) { + const suffix = `precreation-${counter++}`; + const user = await getPrismaClient().user.create({ + data: { + username: `pc-${suffix}`, + email: `pc-${suffix}@example.test`, + role: "USER", + timezone: tz, + }, + }); + const session = await getPrismaClient().session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return user; +} + +async function postIntake(medicationId: string, body: unknown) { + const { POST } = await import("@/app/api/medications/[id]/intake/route"); + return (POST as Handler)( + new NextRequest(`http://localhost/api/medications/${medicationId}/intake`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ id: medicationId }) }, + ); +} + +async function getJson(path: string, medicationId: string) { + const route = + path === "dose-history" + ? await import("@/app/api/medications/[id]/dose-history/route") + : await import("@/app/api/medications/[id]/compliance/route"); + const from = new Date(Date.now() - 90 * DAY_MS).toISOString(); + const response = await (route.GET as Handler)( + new NextRequest( + `http://localhost/api/medications/${medicationId}/${path}?from=${encodeURIComponent(from)}`, + { method: "GET" }, + ), + { params: Promise.resolve({ id: medicationId }) }, + ); + expect(response.status).toBe(200); + return (await response.json()).data; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("a medication added mid-day (#1028)", () => { + for (const tz of ["Asia/Kolkata", "America/New_York"]) { + it(`reads the creation day as two slots taken on time (${tz})`, async () => { + const prisma = getPrismaClient(); + const user = await signedInUser(tz); + // Two days back, so every slot of the creation day is long settled + // whatever the clock says when the suite runs. + const day = new Date(Date.now() - 2 * DAY_MS); + const at = (h: number, m = 0) => localHmAsUtc(day, tz, h, m); + + const med = await prisma.medication.create({ + data: { + userId: user.id, + name: "Amoxicillin", + dose: "500mg", + active: true, + createdAt: at(16, 5), + schedules: { + create: { + windowStart: "09:00", + windowEnd: "09:00", + timesOfDay: ["09:00", "14:00", "21:00"], + daysOfWeek: null, + scheduleType: "SCHEDULED", + }, + }, + }, + }); + + // The placeholder the today projector minted for the 09:00 slot, + // stamped by the auto-miss pass. + await prisma.medicationIntakeEvent.create({ + data: { + userId: user.id, + medicationId: med.id, + scheduledFor: at(9), + takenAt: null, + skipped: false, + autoMissed: true, + source: "REMINDER", + }, + }); + + for (const h of [14, 21]) { + const response = await postIntake(med.id, { + skipped: false, + scheduledFor: at(h).toISOString(), + takenAt: at(h).toISOString(), + }); + expect(response.status).toBe(201); + } + + const dayKey = userDayKey(at(12), tz); + const ledger = await getJson("dose-history", med.id); + const creationDay = ( + ledger.rows as Array<{ + kind: string; + at: string; + status: string; + }> + ).filter((r) => userDayKey(new Date(r.at), tz) === dayKey); + expect(creationDay.map((r) => [r.at, r.kind, r.status])).toEqual([ + [at(14).toISOString(), "slot", "taken_on_time"], + [at(21).toISOString(), "slot", "taken_on_time"], + ]); + + const compliance = await getJson("compliance", med.id); + expect(compliance.dailyCompliance[dayKey]).toMatchObject({ + expected: 2, + taken: 2, + onTime: 2, + }); + }); + } +}); diff --git a/tests/integration/intake-backfill-missed-slot.test.ts b/tests/integration/intake-backfill-missed-slot.test.ts index 9df84f04f..c4b613ffa 100644 --- a/tests/integration/intake-backfill-missed-slot.test.ts +++ b/tests/integration/intake-backfill-missed-slot.test.ts @@ -100,8 +100,9 @@ async function seedTwiceDaily(userId: string, hours: { a: number; b: number }) { dose: "5mg", active: true, startsOn: new Date(Date.now() - 30 * DAY_MS), - // The ledger clamps its window floor to the medication's creation, so a - // medication minted at test time would have no yesterday to read back. + // The ledger shows an unrecorded slot only from the medication's + // creation on, so a medication minted at test time would have no + // missed yesterday to read back. createdAt: new Date(Date.now() - 30 * DAY_MS), schedules: { create: {