mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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:
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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user