From fe7cccfd460e4203670f909a4f44ec20e5100812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 19:40:27 +0200 Subject: [PATCH 1/7] fix(medications): attribute doses to slots from before the medication existed A medication added at 16:05 with doses at 09:00, 14:00 and 21:00 showed its creation day wrongly in "Last 90 days". The 14:00 dose, recorded afterwards on its own slot, read as an off-schedule take "due" at 21:00, and a 09:00 row with no time appeared as off-schedule too. The history route minted slots only from the creation instant, so the 14:00 slot never existed for the dose to land on, while the write path, which mints the whole day, had filed it there. The 09:00 row was the pending placeholder the today projector mints for every slot of the day, which the auto-miss pass stamped a day later; with no slot to bind to it surfaced as an off-schedule row. The ledger now mints slots over the requested window and takes the creation instant as the floor of expected slots: a slot before it appears only when a recorded dose claims it (a take by its window or its pin, or a skip), and a pending or auto-missed placeholder on it is dropped. The first archived schedule era also covers the stretch before the creation, so an edited schedule behaves the same. Every adherence surface tallies the same ledger with the same floor, so the detail rates, the calendar and the ledger bar agree on that day. The doctor report, the Coach and the adherence chips previously read the slots before a medication existed as missed whenever their window started earlier; they now read nothing there. Refs #1028 --- docs/api/openapi.yaml | 11 +- .../[id]/dose-history/__tests__/route.test.ts | 144 ++++++++++++++ .../medications/[id]/dose-history/route.ts | 43 ++-- .../__tests__/compliance-bundle.test.ts | 10 +- .../compliance/__tests__/compliance.test.ts | 2 +- .../__tests__/pre-creation-slots.test.ts | 109 ++++++++++ src/lib/analytics/compliance/display.ts | 39 ++-- src/lib/analytics/compliance/ledger.ts | 5 +- .../compliance-payload-pre-creation.test.ts | 103 ++++++++++ src/lib/medications/compliance-payload.ts | 20 +- .../scheduling/__tests__/dose-history.test.ts | 143 +++++++++++++- .../__tests__/schedule-eras.test.ts | 19 ++ src/lib/medications/scheduling/cadence.ts | 7 +- src/lib/medications/scheduling/compliance.ts | 7 +- .../medications/scheduling/dose-history.ts | 30 ++- .../medications/scheduling/schedule-eras.ts | 21 +- src/lib/openapi/routes/medications/schemas.ts | 4 +- .../dose-history-pre-creation-slots.test.ts | 187 ++++++++++++++++++ .../intake-backfill-missed-slot.test.ts | 5 +- 19 files changed, 839 insertions(+), 70 deletions(-) create mode 100644 src/lib/analytics/compliance/__tests__/pre-creation-slots.test.ts create mode 100644 src/lib/medications/__tests__/compliance-payload-pre-creation.test.ts create mode 100644 tests/integration/dose-history-pre-creation-slots.test.ts 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: { From fa86c9e46ca32c0296c8c4b001b7532983b690e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 19:40:28 +0200 Subject: [PATCH 2/7] fix(medications): say what attributing a dose to a slot counts as The confirmation promised that an attributed dose always counts as taken late. It counts as on time when it falls inside the slot's on-time window, which also covers a dose taken shortly before the slot's clock time, and as late otherwise. The copy now says so in every locale, and a test pins the split it describes. Refs #1028 --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/ko.json | 2 +- messages/pl.json | 2 +- .../scheduling/__tests__/dose-history.test.ts | 24 +++++++++++++++++++ 8 files changed, 31 insertions(+), 7 deletions(-) diff --git a/messages/de.json b/messages/de.json index 3af8f13ca..bf5866849 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1795,7 +1795,7 @@ "unpinnedToast": "Zuordnung gelöst", "dueContext": "fällig gewesen: {time} ({delta})", "confirmTitle": "Slot {time} zuordnen?", - "confirmBody": "Die Einnahme zählt für diesen Slot als verspätet — die Quote wird nicht geschönt. Die Zuordnung lässt sich jederzeit wieder lösen.", + "confirmBody": "Die Einnahme zählt für diesen Slot: pünktlich, wenn sie im Zeitfenster des Slots lag, sonst als verspätet. Die Quote wird also nicht geschönt. Die Zuordnung lässt sich jederzeit wieder lösen.", "confirmCancel": "Abbrechen", "confirmAction": "Zuordnen" } diff --git a/messages/en.json b/messages/en.json index 9cd82bd03..b3fe58b4b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1795,7 +1795,7 @@ "unpinnedToast": "Attribution released", "dueContext": "was due: {time} ({delta})", "confirmTitle": "Attribute to slot {time}?", - "confirmBody": "The intake counts as taken late for this slot – the rate is never flattered. You can release the attribution at any time.", + "confirmBody": "The intake counts for this slot: on time if it was taken inside the slot's on-time window, otherwise as late, so the rate is never flattered. You can release the attribution at any time.", "confirmCancel": "Cancel", "confirmAction": "Attribute" } diff --git a/messages/es.json b/messages/es.json index b69c2d44f..981494fb9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1795,7 +1795,7 @@ "unpinnedToast": "Asignación quitada", "dueContext": "prevista: {time} ({delta})", "confirmTitle": "¿Asignar al horario de las {time}?", - "confirmBody": "La toma contará como tomada con retraso para este horario; el porcentaje no se maquilla. Puedes quitar la asignación en cualquier momento.", + "confirmBody": "La toma contará para este horario: a tiempo si se tomó dentro de su ventana puntual y, si no, con retraso, así que el porcentaje no se maquilla. Puedes quitar la asignación en cualquier momento.", "confirmCancel": "Cancelar", "confirmAction": "Asignar" } diff --git a/messages/fr.json b/messages/fr.json index e832e094e..809d70873 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1795,7 +1795,7 @@ "unpinnedToast": "Prise détachée du créneau", "dueContext": "prévue à {time} ({delta})", "confirmTitle": "Rattacher au créneau de {time} ?", - "confirmBody": "La prise comptera comme prise en retard pour ce créneau – le taux n'est pas embelli. Le rattachement peut être retiré à tout moment.", + "confirmBody": "La prise comptera pour ce créneau : à l'heure si elle a eu lieu dans la fenêtre à l'heure du créneau, sinon en retard. Le taux n'est donc pas embelli. Le rattachement peut être retiré à tout moment.", "confirmCancel": "Annuler", "confirmAction": "Rattacher" } diff --git a/messages/it.json b/messages/it.json index 237f44644..3c0616fe5 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1795,7 +1795,7 @@ "unpinnedToast": "Attribuzione rimossa", "dueContext": "prevista alle {time} ({delta})", "confirmTitle": "Attribuire allo slot delle {time}?", - "confirmBody": "L'assunzione conterà come in ritardo per questo slot – la percentuale non viene abbellita. L'attribuzione può essere rimossa in qualsiasi momento.", + "confirmBody": "L'assunzione conterà per questo slot: puntuale se è avvenuta nella finestra puntuale dello slot, altrimenti in ritardo, quindi la percentuale non viene abbellita. L'attribuzione può essere rimossa in qualsiasi momento.", "confirmCancel": "Annulla", "confirmAction": "Attribuisci" } diff --git a/messages/ko.json b/messages/ko.json index 91b12f477..b09497d07 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -1795,7 +1795,7 @@ "unpinnedToast": "배정을 해제했어요", "dueContext": "예정 시각: {time} ({delta})", "confirmTitle": "{time} 시간대에 배정할까요?", - "confirmBody": "이 복용은 해당 시간대에 늦게 복용한 것으로 계산돼요 – 순응도가 부풀려지지 않아요. 배정은 언제든 해제할 수 있어요.", + "confirmBody": "이 복용은 해당 시간대로 계산돼요. 시간대의 정시 범위 안에 복용했다면 제시간, 아니면 늦게 복용한 것으로 계산되어 순응도가 부풀려지지 않아요. 배정은 언제든 해제할 수 있어요.", "confirmCancel": "취소", "confirmAction": "배정" } diff --git a/messages/pl.json b/messages/pl.json index ad7f00f51..ffd3b387f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1795,7 +1795,7 @@ "unpinnedToast": "Przypisanie usunięte", "dueContext": "termin: {time} ({delta})", "confirmTitle": "Przypisać do slotu {time}?", - "confirmBody": "Dawka zostanie policzona jako przyjęta z opóźnieniem dla tego slotu – wskaźnik nie jest zawyżany. Przypisanie można w każdej chwili usunąć.", + "confirmBody": "Dawka zostanie policzona dla tego slotu: jako przyjęta na czas, jeśli mieści się w oknie punktualności slotu, a w przeciwnym razie jako przyjęta z opóźnieniem, więc wskaźnik nie jest zawyżany. Przypisanie można w każdej chwili usunąć.", "confirmCancel": "Anuluj", "confirmAction": "Przypisz" } diff --git a/src/lib/medications/scheduling/__tests__/dose-history.test.ts b/src/lib/medications/scheduling/__tests__/dose-history.test.ts index 0603860fc..ac5196362 100644 --- a/src/lib/medications/scheduling/__tests__/dose-history.test.ts +++ b/src/lib/medications/scheduling/__tests__/dose-history.test.ts @@ -284,6 +284,30 @@ describe("reconstructDoseHistory", () => { expect(morning?.pinned).toBe(true); }); + // The pin confirm dialog promises exactly this split: on time inside the + // slot's on-time window (also ahead of the slot's clock time), late + // otherwise. + it("an early pin inside the on-time window reads on time, one before it reads late", () => { + const early = reconstructDoseHistory( + bands, + [intake({ takenAt: at(6, 15), scheduledFor: at(7, 0), pinned: true })], + nowEvening, + null, + ); + expect(early.find((r) => r.timeOfDay === "07:00")?.status).toBe( + "taken_on_time", + ); + const tooEarly = reconstructDoseHistory( + bands, + [intake({ takenAt: at(5, 30), scheduledFor: at(7, 0), pinned: true })], + nowEvening, + null, + ); + expect(tooEarly.find((r) => r.timeOfDay === "07:00")?.status).toBe( + "taken_late", + ); + }); + it("a pin inside the late tail reads taken_late, never flattered", () => { const rows = reconstructDoseHistory( bands, From a6b8c61a82efa8c331c0759c93995c2285041ed0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 19:46:24 +0200 Subject: [PATCH 3/7] fix(medications): count only a recorded dose as taken in the calendar The adherence calendar counted every off-schedule ledger row as a dose taken on time. An orphaned skip or auto-miss, a row on an instant that is no slot of the schedule, carries no taken time, yet coloured its day as taken. Such a row now counts as taken only with a taken time; an orphaned skip reads as skipped, and an orphaned auto-miss counts nothing, as it already does in the rate. Refs #1028 --- .../compliance-payload-heatmap.test.ts | 86 +++++++++++++++++++ src/lib/medications/compliance-payload.ts | 31 ++++--- 2 files changed, 107 insertions(+), 10 deletions(-) create mode 100644 src/lib/medications/__tests__/compliance-payload-heatmap.test.ts diff --git a/src/lib/medications/__tests__/compliance-payload-heatmap.test.ts b/src/lib/medications/__tests__/compliance-payload-heatmap.test.ts new file mode 100644 index 000000000..0ded6d91f --- /dev/null +++ b/src/lib/medications/__tests__/compliance-payload-heatmap.test.ts @@ -0,0 +1,86 @@ +/** + * The compliance heatmap counts a day's ledger rows. An off-schedule row + * (one that matched no slot) is a real dose only when it carries a taken + * time; an orphaned skip or auto-miss on an instant that is no slot of the + * schedule records no dose and must never colour the day as taken. + */ +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"; + +const TZ = "Europe/Berlin"; + +afterEach(() => { + vi.useRealTimers(); + vi.resetAllMocks(); +}); + +it("counts an off-schedule row as taken only when it has a taken time", async () => { + const day = new Date("2026-06-10T12:00:00Z"); + const at = (h: number, m = 0) => localHmAsUtc(day, TZ, h, m); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(at(23, 0)); + + const row = ( + scheduledFor: Date, + takenAt: Date | null, + flags: { skipped?: boolean; autoMissed?: boolean } = {}, + ) => ({ + scheduledFor, + takenAt, + skipped: flags.skipped ?? false, + autoMissed: flags.autoMissed ?? false, + attributionSource: "AUTO", + }); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + // The 08:00 slot, taken on time. + row(at(8), at(8)), + // A real off-schedule take in the afternoon. + row(at(15), at(15)), + // Orphans on instants that are no slot of the schedule. + row(at(11), null, { skipped: true }), + row(at(12), null, { autoMissed: true }), + ] as never); + + const payload = await buildCompliancePayload( + { + id: "med-1", + createdAt: new Date("2026-01-01T00:00:00Z"), + startsOn: null, + endsOn: null, + oneShot: false, + asNeeded: false, + schedules: [ + { + windowStart: "08:00", + windowEnd: "08:00", + timesOfDay: ["08:00"], + daysOfWeek: null, + rrule: null, + rollingIntervalDays: null, + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }, + ], + }, + "user-1", + TZ, + ); + + expect(payload.dailyCompliance[userDayKey(at(12), TZ)]).toMatchObject({ + // The slot, the real off-schedule take and the orphaned skip. + expected: 3, + taken: 2, + onTime: 2, + skipped: 1, + }); +}); diff --git a/src/lib/medications/compliance-payload.ts b/src/lib/medications/compliance-payload.ts index 363335b94..d201ca1aa 100644 --- a/src/lib/medications/compliance-payload.ts +++ b/src/lib/medications/compliance-payload.ts @@ -107,10 +107,14 @@ export function complianceCacheKey( * Slot rows contribute to `expected` / `expectedCount` (the day's due-slot * count): a taken slot adds to `taken` and its timing bucket; a missed slot * stays uncounted in `taken`; a skipped slot lands in `skipped`; an upcoming - * slot is still due but not yet acted on. An ad-hoc row is a real off-schedule - * take — it counts as `taken` AND adds its own `expected` slot (so the - * heatmap's `missed = expected − taken − skipped` math stays non-negative) and - * reads on-time (a logged dose colours green). + * slot is still due but not yet acted on. An ad-hoc row with a taken time is + * a real off-schedule take — it counts as `taken` AND adds its own `expected` + * slot (so the heatmap's `missed = expected − taken − skipped` math stays + * non-negative) and reads on-time (a logged dose colours green). An ad-hoc row + * without a taken time is an orphaned skip or auto-miss on an instant that is + * no slot of the schedule: it records no dose, so it never counts as taken. A + * skip still reads as skipped; an orphaned auto-miss has no slot to have + * missed and counts nothing, as it does in the rate. */ function bucketLedgerRow( entry: DailyComplianceEntry, @@ -145,12 +149,19 @@ function bucketLedgerRow( entry.expectedCount++; break; case "ad_hoc": - // An off-schedule take: a real taken dose with no scheduled slot. Count - // it as taken + its own expected slot so the heatmap missed math holds. - entry.expected++; - entry.expectedCount++; - entry.taken++; - entry.onTime++; + if (row.intake?.takenAt) { + // An off-schedule take: a real taken dose with no scheduled slot. + // Count it as taken + its own expected slot so the heatmap missed + // math holds. + entry.expected++; + entry.expectedCount++; + entry.taken++; + entry.onTime++; + } else if (row.intake?.skipped) { + entry.expected++; + entry.expectedCount++; + entry.skipped++; + } break; } } From 8642d63b82362a7538cf9f37ad028cdc43976d55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 19:48:01 +0200 Subject: [PATCH 4/7] fix(backup): keep a medication's creation date through a portable restore A portable export left the medication's creation instant out, so a restore stamped the restore time instead. The creation instant is where a medication's expected doses start, so the whole restored history then read as predating the medication: its missed doses dropped out of every adherence rate. The portable file now carries the creation instant as the disaster-recovery file already did, and the restore, which already honoured it, brings it back. A round trip pins both file purposes. Refs #1028 --- src/lib/export/full-backup-payload.ts | 6 ++- tests/integration/backup-round-trip.test.ts | 59 +++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 89f63c045..55c0d83b4 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -998,13 +998,17 @@ export async function buildFullBackupPayload( reorderLeadDays: m.reorderLeadDays, externalSource: m.externalSource, externalId: m.externalId, - createdAt: m.createdAt.toISOString(), updatedAt: m.updatedAt.toISOString(), } : {}), name: m.name, dose: m.dose, active: m.active, + // Both purposes carry the creation instant: it is the floor of the + // medication's expected slots, so a restore stamped with the restore + // time would read the whole restored history as predating the + // medication and drop its misses from every rate (#1028). + createdAt: m.createdAt.toISOString(), schedules: m.schedules.map((s) => ({ ...(disasterRecovery ? { diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 8376beb3e..516d48541 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -2890,4 +2890,63 @@ describe("every model the plan claims two-ended survives a real restore", () => UNREADABLE_EXPORT_MARKER, ); }); + + /** + * #1028 — a medication's creation instant is the floor of its expected + * slots: every adherence surface counts a slot before it only when a + * recorded dose claims it. A restore that stamped the restore time as the + * creation turned the whole restored history into "before the medication + * existed", so its misses dropped out of the rate. Both file purposes must + * bring the creation instant back. + */ + for (const purpose of ["portable-export", "disaster-recovery"] as const) { + it(`restores a medication's creation instant from a ${purpose} file`, async () => { + const prisma = getPrismaClient(); + await seedAdminSession(prisma); + await createOwner(prisma); + const createdAt = AT("2025-11-03T16:05:00.000Z"); + await prisma.medication.create({ + data: { + userId: OWNER_ID, + name: "Metformin", + dose: "500mg", + createdAt, + schedules: { + create: { + windowStart: "08:00", + windowEnd: "08:00", + timesOfDay: ["08:00"], + }, + }, + }, + }); + + const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { + purpose, + }); + await prisma.user.delete({ where: { id: OWNER_ID } }); + await createOwner(prisma); + const backup = await prisma.dataBackup.create({ + data: { + userId: OWNER_ID, + type: "TWO_ENDED_ROUND_TRIP", + data: encrypt(JSON.stringify(payload)), + }, + }); + const response = await POST( + new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as never, + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(response.status, JSON.stringify(await response.json())).toBe(200); + + const restored = await prisma.medication.findFirstOrThrow({ + where: { userId: OWNER_ID, name: "Metformin" }, + }); + expect(restored.createdAt.toISOString()).toBe(createdAt.toISOString()); + }); + } }); From f028d9c55bd56305b5b5a4f5f14a6c71a92b1f7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 19:52:35 +0200 Subject: [PATCH 5/7] fix(medications): never stamp a miss on a slot before the medication existed The today projector mints a pending placeholder for every slot of the current day, so a medication added in the afternoon carries placeholders for that day's earlier slots. The hourly auto-miss pass stamped them as forgotten doses a day later, and every reader that counts raw rows saw a miss on a day before the medication existed: the miss-free streak broke on the creation day. The dose history, the rates and the Coach already ignore such a placeholder; the streak read the row as it was. The pass now stamps a miss only on slots from the medication's creation on. A placeholder before it stays pending, so the web and the app can still offer the slot for logging and a dose recorded for it converges onto it. A migration returns the placeholders an earlier pass already stamped. It touches only rows that can be nothing else: server-minted, never acted on, anchored before the creation but within the day before it, which is where the projector's placeholders sit. An older auto-miss carried in with restored history keeps its state. Refs #1028 --- .../migration.sql | 27 +++++ .../compliance-block-pre-creation.test.ts | 91 +++++++++++++++ .../jobs/__tests__/intake-auto-skip.test.ts | 51 ++++++++- src/lib/jobs/intake-auto-skip.ts | 27 ++++- .../dose-history-pre-creation-slots.test.ts | 108 ++++++++++++++++++ 5 files changed, 295 insertions(+), 9 deletions(-) create mode 100644 prisma/migrations/0346_release_pre_creation_placeholder_misses/migration.sql create mode 100644 src/lib/ai/coach/snapshot-blocks/__tests__/compliance-block-pre-creation.test.ts diff --git a/prisma/migrations/0346_release_pre_creation_placeholder_misses/migration.sql b/prisma/migrations/0346_release_pre_creation_placeholder_misses/migration.sql new file mode 100644 index 000000000..4b71eaa02 --- /dev/null +++ b/prisma/migrations/0346_release_pre_creation_placeholder_misses/migration.sql @@ -0,0 +1,27 @@ +-- The today projector mints a pending placeholder for every slot of the +-- current day, and the hourly auto-miss pass used to stamp every stale one a +-- miss. For a medication added in the afternoon that turned the placeholders +-- of that day's earlier slots, which were never expected doses, into +-- forgotten doses: the miss-free streak broke and the Coach read a miss on a +-- day before the medication existed. The pass no longer stamps them; this +-- returns the ones it already stamped to pending. +-- +-- Only rows that can be nothing but such a placeholder are touched: minted by +-- the server (source REMINDER), never acted on (no take, no skip, live), and +-- anchored before the medication's creation but on its creation day. The +-- projector mints for the current local day only, which begins less than a +-- day before the creation, so the 24-hour bound is exact for placeholders and +-- leaves any older auto-miss (history carried in by a restore) as it was. +-- `sync_version` bumps so sync clients pick the change up. +UPDATE "medication_intake_events" AS e +SET "auto_missed" = false, + "sync_version" = e."sync_version" + 1 +FROM "medications" AS m +WHERE e."medication_id" = m."id" + AND e."auto_missed" = true + AND e."taken_at" IS NULL + AND e."skipped" = false + AND e."deleted_at" IS NULL + AND e."source" = 'REMINDER' + AND e."scheduled_for" < m."created_at" + AND e."scheduled_for" >= m."created_at" - INTERVAL '24 hours'; diff --git a/src/lib/ai/coach/snapshot-blocks/__tests__/compliance-block-pre-creation.test.ts b/src/lib/ai/coach/snapshot-blocks/__tests__/compliance-block-pre-creation.test.ts new file mode 100644 index 000000000..cf920a749 --- /dev/null +++ b/src/lib/ai/coach/snapshot-blocks/__tests__/compliance-block-pre-creation.test.ts @@ -0,0 +1,91 @@ +/** + * #1028 — the Coach's adherence over a medication added mid-day. + * + * The Coach reads a 30-day window, which starts long before a medication + * added two days ago. Neither the days before it existed nor the creation + * day's earlier slot (holding only the projector's auto-missed placeholder) + * may read as missed, and the creation day's 14:00 dose, recorded afterwards + * on its own slot, counts as taken: the Coach quotes what the medication + * card and the dose history show. + */ +import { expect, it, vi } from "vitest"; + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +import { buildComplianceBlock } from "../compliance-block"; +import { localHmAsUtc } from "@/lib/tz/local-day"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +for (const tz of ["Asia/Kolkata", "America/New_York"]) { + it(`quotes full adherence for a medication added mid-day (${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 now = d2(22, 0); + const take = (slot: Date, takenAt: Date) => ({ + scheduledFor: slot, + takenAt, + skipped: false, + autoMissed: false, + }); + + const snapshot: Record = {}; + buildComplianceBlock({ + complianceMeds: [ + { + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: d1(16, 5), + 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, + }, + ], + intakeEvents: [ + { + 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)), + ], + }, + ], + userTz: tz, + cutoff: new Date(now.getTime() - 30 * DAY_MS), + recentCutoff: new Date(now.getTime() - 7 * DAY_MS), + now, + snapshot, + metrics: new Set(), + counts: {}, + registerBlock: () => {}, + }); + + const compliance = snapshot.compliance as { + rate: number; + timeline: { recent: Array<{ taken: number; total: number }> }; + }; + expect(compliance.rate).toBe(100); + expect(compliance.timeline.recent.map((r) => [r.taken, r.total])).toEqual([ + [2, 2], + [3, 3], + ]); + }); +} diff --git a/src/lib/jobs/__tests__/intake-auto-skip.test.ts b/src/lib/jobs/__tests__/intake-auto-skip.test.ts index 5055eda6a..4073e96d1 100644 --- a/src/lib/jobs/__tests__/intake-auto-skip.test.ts +++ b/src/lib/jobs/__tests__/intake-auto-skip.test.ts @@ -54,6 +54,8 @@ interface FakeScheduleRow { interface FakeMedication { id: string; + /** Defaults to long before every fixture row when omitted. */ + createdAt?: Date; schedules: FakeScheduleRow[]; } @@ -62,8 +64,9 @@ interface FakeWhere { autoMissed?: boolean; takenAt?: null; deletedAt?: null; - medicationId?: { in: string[] }; - scheduledFor?: { lt: Date }; + medicationId?: string | { in: string[] }; + scheduledFor?: { lt?: Date; gte?: Date }; + OR?: FakeWhere[]; } function matches(row: FakeIntakeRow, where: FakeWhere): boolean { @@ -74,10 +77,22 @@ function matches(row: FakeIntakeRow, where: FakeWhere): boolean { if (where.takenAt === null && row.takenAt !== null) return false; if (where.deletedAt === null && row.deletedAt !== null) return false; if ( - where.medicationId !== undefined && + typeof where.medicationId === "string" && + row.medicationId !== where.medicationId + ) + return false; + if ( + typeof where.medicationId === "object" && !where.medicationId.in.includes(row.medicationId) ) return false; + if ( + where.scheduledFor?.gte !== undefined && + row.scheduledFor.getTime() < where.scheduledFor.gte.getTime() + ) + return false; + if (where.OR !== undefined && !where.OR.some((w) => matches(row, w))) + return false; if ( where.scheduledFor?.lt !== undefined && row.scheduledFor.getTime() >= where.scheduledFor.lt.getTime() @@ -128,7 +143,12 @@ function makeFakePrisma(state: { }, medication: { findMany: vi.fn(async ({ where }: { where: { id: { in: string[] } } }) => - state.medications.filter((m) => where.id.in.includes(m.id)), + state.medications + .filter((m) => where.id.in.includes(m.id)) + .map((m) => ({ + createdAt: new Date("2020-01-01T00:00:00Z"), + ...m, + })), ), }, } as unknown as PrismaClient; @@ -445,6 +465,29 @@ describe("runIntakeAutoSkipPass", () => { expect(rows[0].autoMissed).toBe(false); }); + // #1028 — the today projector mints a pending placeholder for every slot + // of the day, including the slots before the medication was added. Such a + // slot was never expected, so its placeholder must never become a miss: + // the miss-free streak, the Coach and every other raw-row reader would + // count it. + it("never flips a placeholder whose slot predates the medication", async () => { + const createdAt = new Date(NOW_MS - 30 * HOUR_MS); + const rows = [ + pendingRow("i-before", "med-new", new Date(NOW_MS - 37 * HOUR_MS)), + pendingRow("i-after", "med-new", new Date(NOW_MS - 25 * HOUR_MS)), + pendingRow("i-old-med", "med-daily", new Date(NOW_MS - 37 * HOUR_MS)), + ]; + const prisma = makeFakePrisma({ + rows, + medications: [{ ...DAILY_MED, id: "med-new", createdAt }, DAILY_MED], + }); + const result = await runIntakeAutoSkipPass(prisma, { nowMs: NOW_MS }); + expect(rows.find((r) => r.id === "i-before")?.autoMissed).toBe(false); + expect(rows.find((r) => r.id === "i-after")?.autoMissed).toBe(true); + expect(rows.find((r) => r.id === "i-old-med")?.autoMissed).toBe(true); + expect(result.skippedCount).toBe(2); + }); + it("uses Date.now() when no nowMs override is supplied", async () => { vi.useFakeTimers(); vi.setSystemTime(NOW); diff --git a/src/lib/jobs/intake-auto-skip.ts b/src/lib/jobs/intake-auto-skip.ts index 07ea45f72..60123634a 100644 --- a/src/lib/jobs/intake-auto-skip.ts +++ b/src/lib/jobs/intake-auto-skip.ts @@ -34,7 +34,17 @@ * * `auto_missed = false` AND * * `takenAt IS NULL` AND * * `deletedAt IS NULL` AND - * * `scheduledFor < NOW() - ` + * * `scheduledFor < NOW() - ` AND + * * `scheduledFor >= medication.createdAt` + * + * The last clause (#1028): the today projector mints a pending placeholder + * for every slot of the current day, so a medication added at 16:00 carries + * placeholders for its 09:00 and 14:00 slots. Those slots were never + * expected, and a placeholder on one records nothing. Stamping it a miss + * put a forgotten dose into every reader that counts raw rows (the miss-free + * streak, the Coach snapshot) for a day before the medication existed. It + * stays pending instead: the dose history drops it, and a dose the person + * does record for that slot still converges onto it. * * The 24 h floor is intentional: a slightly late mark (user took the * morning dose at noon when the schedule was 09:00) shouldn't be flipped @@ -228,6 +238,7 @@ export async function runIntakeAutoSkipPass( where: { id: { in: [...usersByMedication.keys()] } }, select: { id: true, + createdAt: true, schedules: { select: { rrule: true, rollingIntervalDays: true, doseWindows: true }, }, @@ -236,22 +247,28 @@ export async function runIntakeAutoSkipPass( // Group medications by their derived delay so one `updateMany` covers // each distinct cutoff instead of one query per medication. - const medsByDelay = new Map(); + const medsByDelay = new Map>(); for (const medication of medications) { const delayMs = medicationAutoMissDelayMs(medication.schedules); const group = medsByDelay.get(delayMs) ?? []; - group.push(medication.id); + group.push({ id: medication.id, createdAt: medication.createdAt }); medsByDelay.set(delayMs, group); } let skippedCount = 0; const invalidatedUserIds = new Set(); - for (const [delayMs, medicationIds] of medsByDelay) { + for (const [delayMs, group] of medsByDelay) { + const medicationIds = group.map((m) => m.id); const { count } = await prisma.medicationIntakeEvent.updateMany({ where: { ...pendingWhere, - medicationId: { in: medicationIds }, scheduledFor: { lt: new Date(nowMs - delayMs) }, + // Per medication, only slots from its creation on: a placeholder + // for a slot before it was never an expected dose (see above). + OR: group.map((m) => ({ + medicationId: m.id, + scheduledFor: { gte: m.createdAt }, + })), }, // `syncVersion` bumps so delta-sync clients pick up the terminal // state instead of holding a stale pending row forever. diff --git a/tests/integration/dose-history-pre-creation-slots.test.ts b/tests/integration/dose-history-pre-creation-slots.test.ts index 9c4866972..dbde5f42a 100644 --- a/tests/integration/dose-history-pre-creation-slots.test.ts +++ b/tests/integration/dose-history-pre-creation-slots.test.ts @@ -20,6 +20,11 @@ import { NextRequest } from "next/server"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { getMissFreeDayKeys } from "@/lib/gamification/care-metrics"; +import { runIntakeAutoSkipPass } from "@/lib/jobs/intake-auto-skip"; import { localHmAsUtc } from "@/lib/tz/local-day"; import { userDayKey } from "@/lib/tz/format"; @@ -185,3 +190,106 @@ describe("a medication added mid-day (#1028)", () => { }); } }); + +/** + * The placeholder itself. The auto-miss pass must leave a placeholder on a + * slot before the medication existed pending, so the raw-row readers (the + * miss-free streak first) never see a forgotten dose on the creation day; + * and the migration returns the ones an earlier pass already stamped, + * without touching an auto-miss that history carried in from before. + */ +describe("placeholders on slots before the medication existed (#1028)", () => { + const tz = "Asia/Kolkata"; + + async function seed() { + const prisma = getPrismaClient(); + const user = await signedInUser(tz); + 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", + createdAt: at(16, 5), + schedules: { + create: { + windowStart: "09:00", + windowEnd: "09:00", + timesOfDay: ["09:00", "14:00", "21:00"], + scheduleType: "SCHEDULED", + }, + }, + }, + }); + const row = ( + scheduledFor: Date, + extra: { takenAt?: Date; autoMissed?: boolean } = {}, + ) => + prisma.medicationIntakeEvent.create({ + data: { + userId: user.id, + medicationId: med.id, + scheduledFor, + takenAt: extra.takenAt ?? null, + skipped: false, + autoMissed: extra.autoMissed ?? false, + source: "REMINDER", + }, + }); + return { prisma, user, med, at, row }; + } + + it("the auto-miss pass leaves it pending and the miss-free streak keeps the day", async () => { + const { prisma, user, at, row } = await seed(); + const placeholder = await row(at(9)); + await row(at(14), { takenAt: at(14) }); + await row(at(21), { takenAt: at(21) }); + + await runIntakeAutoSkipPass(prisma, { nowMs: Date.now() }); + + const after = await prisma.medicationIntakeEvent.findUniqueOrThrow({ + where: { id: placeholder.id }, + }); + expect(after.autoMissed).toBe(false); + + const events = await prisma.medicationIntakeEvent.findMany({ + where: { userId: user.id, deletedAt: null }, + select: { + scheduledFor: true, + takenAt: true, + skipped: true, + autoMissed: true, + }, + }); + expect(getMissFreeDayKeys(events, tz)).toContain(userDayKey(at(12), tz)); + }); + + it("the migration returns an already stamped placeholder and nothing else", async () => { + const { prisma, at, row } = await seed(); + const placeholder = await row(at(9), { autoMissed: true }); + // An auto-miss from well before the creation (history a restore carried + // in) and one after it are real misses and stay. + const carried = await row(new Date(at(9).getTime() - 3 * DAY_MS), { + autoMissed: true, + }); + const afterCreation = await row(at(21), { autoMissed: true }); + + const sql = readFileSync( + join( + process.cwd(), + "prisma/migrations/0346_release_pre_creation_placeholder_misses/migration.sql", + ), + "utf8", + ); + await prisma.$executeRawUnsafe(sql); + + const read = (id: string) => + prisma.medicationIntakeEvent.findUniqueOrThrow({ where: { id } }); + const released = await read(placeholder.id); + expect(released.autoMissed).toBe(false); + expect(released.syncVersion).toBe(placeholder.syncVersion + 1); + expect((await read(carried.id)).autoMissed).toBe(true); + expect((await read(afterCreation.id)).autoMissed).toBe(true); + }); +}); From eafef5adebbe58c908303025b2ecb9af3e7272ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 19:53:42 +0200 Subject: [PATCH 6/7] fix(medications): keep a recorded miss from before the medication's creation The history view treated every auto-miss anchored before the medication's creation as the today projector's placeholder and dropped it. That is true only on the creation day. History that arrives from before the creation, a restore of an older file for one, can carry real misses, and those vanished from the history and the rates. The creation stays the boundary of expected doses: an earlier one, the first recorded dose or the course start date, would read every day between it and the creation that no row names as missed, and an import carries takes and skips only, so those misses would be invented. Before it, every recorded take, skip and miss claims its slot. Only rows that record nothing are dropped: a pending row, and an auto-miss within the day before the creation, which is where the projector's placeholders sit. Refs #1028 --- docs/api/openapi.yaml | 10 ++-- .../scheduling/__tests__/dose-history.test.ts | 52 ++++++++++++++++ .../medications/scheduling/dose-history.ts | 59 +++++++++++++++---- src/lib/openapi/routes/medications/schemas.ts | 4 +- 4 files changed, 107 insertions(+), 18 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 32f386400..b407f67b8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -5985,13 +5985,15 @@ paths: 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. 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. + recorded row (a take, a skip, or a miss carried in with restored history) claims it; the server's own + placeholder for 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. 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. + row (a take, a skip, or a miss carried in with restored history) claims it; the server's own placeholder for + such a slot is not returned. - in: query name: to schema: @@ -43266,8 +43268,8 @@ components: minimum: 0 maximum: 9007199254740991 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." + medication's `createdAt` counts only when a recorded row (a take, a skip, or a miss carried in with restored + history) claims it, so the days before the medication existed are never missed." taken: type: integer minimum: 0 diff --git a/src/lib/medications/scheduling/__tests__/dose-history.test.ts b/src/lib/medications/scheduling/__tests__/dose-history.test.ts index ac5196362..56eb094ce 100644 --- a/src/lib/medications/scheduling/__tests__/dose-history.test.ts +++ b/src/lib/medications/scheduling/__tests__/dose-history.test.ts @@ -657,3 +657,55 @@ describe("reconstructDoseHistory — slots before the medication existed (#1028) expect(rows.map((r) => r.timeOfDay)).toEqual(["07:00", "19:00"]); }); }); + +/** + * #1028 — history recorded before the medication was created (an import, a + * restore from an older file). Only what the history records counts: a slot + * no row names stays absent rather than being invented as missed, and a miss + * the history does record is not hidden. The projector's placeholders on the + * creation day are the one pre-creation auto-miss that records nothing. + */ +describe("reconstructDoseHistory — history from before the creation (#1028)", () => { + // Created two days after `day`, so both of `day`'s slots predate it. + const createdAt = new Date(at(12, 0).getTime() + 2 * 24 * 60 * MIN); + const statusRows = (rows: ReturnType) => + rows.map((r) => [r.kind, r.timeOfDay, r.status, r.intake?.id ?? null]); + + it("keeps a carried-in auto-miss as the miss it records", () => { + const rows = reconstructDoseHistory( + bands, + [ + intake({ id: "taken", scheduledFor: at(7, 0), takenAt: at(7, 10) }), + intake({ id: "missed", scheduledFor: at(19, 0), autoMissed: true }), + ], + nowEvening, + createdAt, + ); + expect(statusRows(rows)).toEqual([ + ["slot", "07:00", "taken_on_time", "taken"], + ["slot", "19:00", "missed", "missed"], + ]); + }); + + it("never invents a miss for a slot the history does not name", () => { + const rows = reconstructDoseHistory( + bands, + [intake({ id: "taken", scheduledFor: at(7, 0), takenAt: at(7, 10) })], + nowEvening, + createdAt, + ); + expect(statusRows(rows)).toEqual([ + ["slot", "07:00", "taken_on_time", "taken"], + ]); + }); + + it("still drops a pending row carried in from before the creation", () => { + const rows = reconstructDoseHistory( + bands, + [intake({ id: "pending", scheduledFor: at(19, 0) })], + nowEvening, + createdAt, + ); + expect(rows).toEqual([]); + }); +}); diff --git a/src/lib/medications/scheduling/dose-history.ts b/src/lib/medications/scheduling/dose-history.ts index f496350a1..357c679bf 100644 --- a/src/lib/medications/scheduling/dose-history.ts +++ b/src/lib/medications/scheduling/dose-history.ts @@ -32,11 +32,9 @@ * - each slot is claimed by at most one intake (first/best wins); extra * 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). + * never expected: it appears only when a recorded row claims it (#1028). + * See `expectedFrom` below for why the boundary is the creation and + * which pre-creation rows count as recorded. * * Pure / synchronous / instant-based — the caller mints the bands DST-correctly * via `localHmAsUtc` and supplies the per-dose window. @@ -131,6 +129,13 @@ export interface DoseHistoryRow { nearestSlot?: NearestSlotContext; } +/** + * How far before a medication's creation the today projector's placeholders + * can sit: it mints the creation's local day only, which starts less than a + * day before the creation. + */ +const PLACEHOLDER_REACH_MS = 24 * 60 * 60 * 1000; + /** Sub-minute slop for binding an anchored (skip/pending) row to its slot. */ const ANCHOR_EPSILON_MS = 60_000; @@ -143,15 +148,45 @@ export function reconstructDoseHistory( 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). + * The instant from which slots are expected: the medication's creation. + * `null` treats every band as expected (a pure-math fixture). + * + * Before it, a slot appears only when a recorded row claims it. Rows get + * there three ways: a dose logged for earlier on the creation day, history + * imported from another app, and history restored from a backup. The + * boundary is chosen so that nothing is invented and nothing recorded is + * hidden: + * + * - An earlier boundary (the first recorded row, or the course start + * date) would read every slot between it and the creation that no row + * names as missed. Nothing supports that reading: an import carries + * takes and skips only (reminder and "no dose information" rows are + * dropped on the way in), a partial or single back-dated entry names a + * few days, and a course start says when the person began, not that + * the unlogged days were missed. Those misses would be invented. + * - With the creation as the boundary, every recorded take (by its + * window or its pin) and skip still claims its slot, and a recorded + * miss does too: an auto-miss anchored more than a day before the + * creation came in with the history (a restore), and it keeps its + * slot as missed. + * - The rows that record nothing are dropped: a pending row, and an + * auto-miss within the day before the creation. The today projector + * mints a placeholder for every slot of the creation day, and a + * placeholder anchored before the creation is the only pre-creation + * auto-miss the server itself can have produced; the day's start is + * less than 24 hours before the creation, which bounds them. */ expectedFrom: Date | null, ): DoseHistoryRow[] { const expectedFromMs = expectedFrom?.getTime() ?? -Infinity; const beforeExpected = (at: Date): boolean => at.getTime() < expectedFromMs; + // A pre-creation row that records nothing: pending, or an auto-miss inside + // the creation day's placeholder reach (see `expectedFrom`). + const recordsNothing = (i: HistoryIntake): boolean => { + if (i.skipped || !beforeExpected(i.scheduledFor)) return false; + if (!i.autoMissed) return true; + return i.scheduledFor.getTime() >= expectedFromMs - PLACEHOLDER_REACH_MS; + }; // Per-band claim: the intake attributed to it + the resolved status. const claim = new Map< SlotBand, @@ -170,9 +205,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; + // A pre-creation placeholder records nothing: drop it whether or not + // its slot was minted. + if (recordsNothing(i)) continue; const band = nearestAnchorBand(i.scheduledFor, bands); if (band && !claim.has(band)) { // Status is time-aware for a pending row: the projector / reminder diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index 8d42e217c..3390b68a2 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. 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.", + "Full denominator over the window: `taken + skipped + missed`. Cadence-aware. A slot from before the medication's `createdAt` counts only when a recorded row (a take, a skip, or a miss carried in with restored history) 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. 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.", + "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 row (a take, a skip, or a miss carried in with restored history) claims it; the server's own placeholder for such a slot is not returned.", ), to: z.iso .datetime({ offset: true }) From 47c7c71802bc70b9165ce3aaa8bcb49439bcf983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 19:54:37 +0200 Subject: [PATCH 7/7] fix(medications): read a pinned dose the same as the dose on its own Automatic matching counts a dose taken up to an hour before a slot's on-time window as on time. A dose pinned to the slot was judged from the window's start instead, so the same dose read on time unpinned and late once pinned. A pinned dose now uses the same early grace, and the confirmation says so plainly: the dose counts as if it had been logged for the slot, on time or late depending on when it was taken. Refs #1028 --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/ko.json | 2 +- messages/pl.json | 2 +- .../scheduling/__tests__/dose-history.test.ts | 58 +++++++++++-------- .../medications/scheduling/dose-history.ts | 13 +++-- 9 files changed, 50 insertions(+), 35 deletions(-) diff --git a/messages/de.json b/messages/de.json index bf5866849..7c7a8da86 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1795,7 +1795,7 @@ "unpinnedToast": "Zuordnung gelöst", "dueContext": "fällig gewesen: {time} ({delta})", "confirmTitle": "Slot {time} zuordnen?", - "confirmBody": "Die Einnahme zählt für diesen Slot: pünktlich, wenn sie im Zeitfenster des Slots lag, sonst als verspätet. Die Quote wird also nicht geschönt. Die Zuordnung lässt sich jederzeit wieder lösen.", + "confirmBody": "Die Einnahme zählt für diesen Slot genau so, als wäre sie dort erfasst worden: pünktlich oder verspätet, je nachdem, wann sie genommen wurde. Die Quote wird also nicht geschönt. Die Zuordnung lässt sich jederzeit wieder lösen.", "confirmCancel": "Abbrechen", "confirmAction": "Zuordnen" } diff --git a/messages/en.json b/messages/en.json index b3fe58b4b..b84e3eb1d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1795,7 +1795,7 @@ "unpinnedToast": "Attribution released", "dueContext": "was due: {time} ({delta})", "confirmTitle": "Attribute to slot {time}?", - "confirmBody": "The intake counts for this slot: on time if it was taken inside the slot's on-time window, otherwise as late, so the rate is never flattered. You can release the attribution at any time.", + "confirmBody": "The intake counts for this slot just as it would if it had been logged there: on time or late depending on when it was taken, so the rate is never flattered. You can release the attribution at any time.", "confirmCancel": "Cancel", "confirmAction": "Attribute" } diff --git a/messages/es.json b/messages/es.json index 981494fb9..57961bd23 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1795,7 +1795,7 @@ "unpinnedToast": "Asignación quitada", "dueContext": "prevista: {time} ({delta})", "confirmTitle": "¿Asignar al horario de las {time}?", - "confirmBody": "La toma contará para este horario: a tiempo si se tomó dentro de su ventana puntual y, si no, con retraso, así que el porcentaje no se maquilla. Puedes quitar la asignación en cualquier momento.", + "confirmBody": "La toma contará para este horario igual que si se hubiera registrado en él: a tiempo o con retraso según cuándo se tomó, así que el porcentaje no se maquilla. Puedes quitar la asignación en cualquier momento.", "confirmCancel": "Cancelar", "confirmAction": "Asignar" } diff --git a/messages/fr.json b/messages/fr.json index 809d70873..a5cebbec3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1795,7 +1795,7 @@ "unpinnedToast": "Prise détachée du créneau", "dueContext": "prévue à {time} ({delta})", "confirmTitle": "Rattacher au créneau de {time} ?", - "confirmBody": "La prise comptera pour ce créneau : à l'heure si elle a eu lieu dans la fenêtre à l'heure du créneau, sinon en retard. Le taux n'est donc pas embelli. Le rattachement peut être retiré à tout moment.", + "confirmBody": "La prise comptera pour ce créneau exactement comme si elle y avait été enregistrée : à l'heure ou en retard selon le moment où elle a été prise. Le taux n'est donc pas embelli. Le rattachement peut être retiré à tout moment.", "confirmCancel": "Annuler", "confirmAction": "Rattacher" } diff --git a/messages/it.json b/messages/it.json index 3c0616fe5..b41bc95c7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1795,7 +1795,7 @@ "unpinnedToast": "Attribuzione rimossa", "dueContext": "prevista alle {time} ({delta})", "confirmTitle": "Attribuire allo slot delle {time}?", - "confirmBody": "L'assunzione conterà per questo slot: puntuale se è avvenuta nella finestra puntuale dello slot, altrimenti in ritardo, quindi la percentuale non viene abbellita. L'attribuzione può essere rimossa in qualsiasi momento.", + "confirmBody": "L'assunzione conterà per questo slot esattamente come se fosse stata registrata lì: puntuale o in ritardo a seconda di quando è stata presa, quindi la percentuale non viene abbellita. L'attribuzione può essere rimossa in qualsiasi momento.", "confirmCancel": "Annulla", "confirmAction": "Attribuisci" } diff --git a/messages/ko.json b/messages/ko.json index b09497d07..3c418574d 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -1795,7 +1795,7 @@ "unpinnedToast": "배정을 해제했어요", "dueContext": "예정 시각: {time} ({delta})", "confirmTitle": "{time} 시간대에 배정할까요?", - "confirmBody": "이 복용은 해당 시간대로 계산돼요. 시간대의 정시 범위 안에 복용했다면 제시간, 아니면 늦게 복용한 것으로 계산되어 순응도가 부풀려지지 않아요. 배정은 언제든 해제할 수 있어요.", + "confirmBody": "이 복용은 처음부터 이 시간대에 기록한 것과 똑같이 계산돼요. 복용한 시각에 따라 제시간 또는 늦게 복용한 것으로 계산되어 순응도가 부풀려지지 않아요. 배정은 언제든 해제할 수 있어요.", "confirmCancel": "취소", "confirmAction": "배정" } diff --git a/messages/pl.json b/messages/pl.json index ffd3b387f..765f55ccc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1795,7 +1795,7 @@ "unpinnedToast": "Przypisanie usunięte", "dueContext": "termin: {time} ({delta})", "confirmTitle": "Przypisać do slotu {time}?", - "confirmBody": "Dawka zostanie policzona dla tego slotu: jako przyjęta na czas, jeśli mieści się w oknie punktualności slotu, a w przeciwnym razie jako przyjęta z opóźnieniem, więc wskaźnik nie jest zawyżany. Przypisanie można w każdej chwili usunąć.", + "confirmBody": "Dawka zostanie policzona dla tego slotu tak, jakby została w nim zapisana: jako przyjęta na czas lub z opóźnieniem, zależnie od tego, kiedy ją przyjęto, więc wskaźnik nie jest zawyżany. Przypisanie można w każdej chwili usunąć.", "confirmCancel": "Anuluj", "confirmAction": "Przypisz" } diff --git a/src/lib/medications/scheduling/__tests__/dose-history.test.ts b/src/lib/medications/scheduling/__tests__/dose-history.test.ts index 56eb094ce..fcd1a047f 100644 --- a/src/lib/medications/scheduling/__tests__/dose-history.test.ts +++ b/src/lib/medications/scheduling/__tests__/dose-history.test.ts @@ -15,7 +15,11 @@ */ import { describe, expect, it } from "vitest"; -import { buildSlotBands, type SlotWindowInput } from "../attribution"; +import { + attributeIntakeToSlot, + buildSlotBands, + type SlotWindowInput, +} from "../attribution"; import { reconstructDoseHistory, suggestNearestSlot, @@ -284,28 +288,36 @@ describe("reconstructDoseHistory", () => { expect(morning?.pinned).toBe(true); }); - // The pin confirm dialog promises exactly this split: on time inside the - // slot's on-time window (also ahead of the slot's clock time), late - // otherwise. - it("an early pin inside the on-time window reads on time, one before it reads late", () => { - const early = reconstructDoseHistory( - bands, - [intake({ takenAt: at(6, 15), scheduledFor: at(7, 0), pinned: true })], - nowEvening, - null, - ); - expect(early.find((r) => r.timeOfDay === "07:00")?.status).toBe( - "taken_on_time", - ); - const tooEarly = reconstructDoseHistory( - bands, - [intake({ takenAt: at(5, 30), scheduledFor: at(7, 0), pinned: true })], - nowEvening, - null, - ); - expect(tooEarly.find((r) => r.timeOfDay === "07:00")?.status).toBe( - "taken_late", - ); + // A pinned take reads exactly as the same take attributed on its own: + // on time from the early grace before the on-time window through the + // window's end, late otherwise. Pinning never changes the timing. + it("a pinned early take reads the same as the unpinned take", () => { + for (const [h, m] of [ + [6, 15], + [5, 30], + [4, 30], + ] as const) { + const takenAt = at(h, m); + const pinned = reconstructDoseHistory( + bands, + [intake({ takenAt, scheduledFor: at(7, 0), pinned: true })], + nowEvening, + null, + ).find((r) => r.timeOfDay === "07:00")?.status; + const unpinned = attributeIntakeToSlot(takenAt, bands); + const expected = + unpinned?.band.timeOfDay === "07:00" + ? unpinned.status === "on_time" + ? "taken_on_time" + : "taken_late" + : // Outside every capture band on its own: a pin can only make + // it late, never on time. + "taken_late"; + expect(pinned, `${h}:${m}`).toBe(expected); + } + // 05:30 lies in the early grace (on time either way), 04:30 before it. + expect(attributeIntakeToSlot(at(5, 30), bands)?.status).toBe("on_time"); + expect(attributeIntakeToSlot(at(4, 30), bands)).toBeNull(); }); it("a pin inside the late tail reads taken_late, never flattered", () => { diff --git a/src/lib/medications/scheduling/dose-history.ts b/src/lib/medications/scheduling/dose-history.ts index 357c679bf..e1f0cd52b 100644 --- a/src/lib/medications/scheduling/dose-history.ts +++ b/src/lib/medications/scheduling/dose-history.ts @@ -22,8 +22,9 @@ * `attributionSource = USER_PIN`) binds by its `scheduledFor` anchor like * a skip, NOT by takenAt-band membership, so a pin outside the late tail * never degrades back to ad-hoc. Status is `taken_late` unless the - * takenAt happens to sit inside the slot's on-time band — a pin can - * never flatter the timing; + * takenAt sits where the take would read on time on its own (the + * on-time band plus its bounded early grace) — a pin never changes the + * timing; * - a RELEASED pin (v1.16.0 — "Zuordnung lösen" persists `USER_PIN` with * `scheduledFor === takenAt`) is a deliberately ad-hoc take: it never * anchor-binds (not even when its instant sits within epsilon of a slot @@ -237,8 +238,9 @@ export function reconstructDoseHistory( // v1.15.20 — pinned takes bind by their stored slot anchor, NOT by // takenAt-band membership: the whole point of a pin is that the take sits // outside (or past the tail of) the band it belongs to. Status never - // flatters: taken_late, unless the takenAt happens to sit inside the - // slot's own on-time band anyway. A pin whose slot is gone (schedule + // flatters: taken_late, unless the takenAt sits where band attribution + // would read it on time anyway, the early grace included, so the same + // take reads the same pinned or not. A pin whose slot is gone (schedule // changed) or already claimed falls through to ad-hoc so nothing vanishes. for (const i of pinnedTaken) { // v1.16.0 — a released pin ("Zuordnung lösen") persists USER_PIN with @@ -254,7 +256,8 @@ export function reconstructDoseHistory( if (band && !claim.has(band)) { const t = (i.takenAt as Date).getTime(); const onTime = - t >= band.onTimeStart.getTime() && t <= band.onTimeEnd.getTime(); + t >= (band.earlyStart ?? band.onTimeStart).getTime() && + t <= band.onTimeEnd.getTime(); claim.set(band, { intake: i, status: onTime ? "taken_on_time" : "taken_late",