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); + }); +});