fix(medications): never stamp a miss on a slot before the medication existed

The today projector mints a pending placeholder for every slot of the
current day, so a medication added in the afternoon carries placeholders
for that day's earlier slots. The hourly auto-miss pass stamped them as
forgotten doses a day later, and every reader that counts raw rows saw a
miss on a day before the medication existed: the miss-free streak broke on
the creation day. The dose history, the rates and the Coach already ignore
such a placeholder; the streak read the row as it was.

The pass now stamps a miss only on slots from the medication's creation on.
A placeholder before it stays pending, so the web and the app can still
offer the slot for logging and a dose recorded for it converges onto it. A
migration returns the placeholders an earlier pass already stamped. It
touches only rows that can be nothing else: server-minted, never acted on,
anchored before the creation but within the day before it, which is where
the projector's placeholders sit. An older auto-miss carried in with
restored history keeps its state.

Refs #1028
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 19:52:39 +02:00
1 parent 8642d63b82
commit f028d9c55b
5 files changed
+295 -9

No files matched your search

@@ -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';
@@ -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<string, unknown> = {};
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],
]);
});
}
@@ -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);
+22 -5
View File
@@ -34,7 +34,17 @@
* * `auto_missed = false` AND
* * `takenAt IS NULL` AND
* * `deletedAt IS NULL` AND
* * `scheduledFor < NOW() - <per-medication auto-miss delay>`
* * `scheduledFor < NOW() - <per-medication auto-miss delay>` 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<number, string[]>();
const medsByDelay = new Map<number, Array<{ id: string; createdAt: Date }>>();
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<string>();
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.
@@ -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);
});
});