diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 009d20da6..b407f67b8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -5984,12 +5984,16 @@ 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 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; 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 + 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: @@ -43263,8 +43267,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 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/messages/de.json b/messages/de.json index 3af8f13ca..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 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 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 9cd82bd03..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 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 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 b69c2d44f..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á 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 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 e832e094e..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 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 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 237f44644..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à 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 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 91b12f477..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 ad7f00f51..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 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 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/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/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/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/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/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/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/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/__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..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; } } @@ -184,14 +195,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 +249,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..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, @@ -60,7 +64,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 +75,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 +89,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 +103,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 +116,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 +126,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 +146,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 +169,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 +183,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 +207,7 @@ describe("reconstructDoseHistory", () => { weeklyBands, [intake({ scheduledFor: weeklyAt })], twoDaysLater, + null, ); expect(rows).toHaveLength(1); expect(rows[0].status).toBe("upcoming"); @@ -210,6 +223,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 +235,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 +246,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 +265,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,17 +281,51 @@ 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"); expect(morning?.pinned).toBe(true); }); + // 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", () => { const rows = 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 +345,7 @@ describe("reconstructDoseHistory", () => { }), ], nowEvening, + null, ); const morning = rows.find((r) => r.timeOfDay === "07:00"); expect(morning?.intake?.id).toBe("pin"); @@ -315,6 +367,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 +385,7 @@ describe("reconstructDoseHistory", () => { }), ], nowEvening, + null, ); const adhoc = rows.filter((r) => r.kind === "ad_hoc"); expect(adhoc).toHaveLength(1); @@ -345,6 +399,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 +419,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 +437,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 +451,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 +466,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 +480,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 +518,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 +555,169 @@ 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"]); + }); +}); + +/** + * #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/__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..e1f0cd52b 100644 --- a/src/lib/medications/scheduling/dose-history.ts +++ b/src/lib/medications/scheduling/dose-history.ts @@ -22,15 +22,20 @@ * `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 * 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 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. @@ -125,6 +130,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; @@ -136,7 +148,46 @@ export function reconstructDoseHistory( bands: SlotBand[], intakes: HistoryIntake[], now: Date, + /** + * 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, @@ -155,6 +206,9 @@ export function reconstructDoseHistory( const taken = intakes.filter((i) => i.takenAt !== null && !i.pinned); for (const i of anchored) { + // 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 @@ -184,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 @@ -201,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", @@ -224,7 +280,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 +319,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..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 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 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; 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 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 }) 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()); + }); + } }); 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..dbde5f42a --- /dev/null +++ b/tests/integration/dose-history-pre-creation-slots.test.ts @@ -0,0 +1,295 @@ +/** + * #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 { 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"; + +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, + }); + }); + } +}); + +/** + * 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); + }); +}); 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: {