diff --git a/src/__tests__/medication-intake-tracking-guard.test.ts b/src/__tests__/medication-intake-tracking-guard.test.ts index d84b41aae..cd58f79bd 100644 --- a/src/__tests__/medication-intake-tracking-guard.test.ts +++ b/src/__tests__/medication-intake-tracking-guard.test.ts @@ -74,6 +74,8 @@ const INTAKE_MAY_SEE_EVERY_ROW: Record = { "deletes rows the person chose", "lib/medications/update-medication/write.ts": "one medication's open slots, tombstoned on a schedule replace or the tracking switch", + "lib/medications/course-end-placeholders.ts": + "gives back the open slots one medication's End course took, no rate", "lib/medications/intake-bulk/ingest.ts": "writes recorded doses", "lib/medications/scheduling/slot-upsert.ts": "binds a recorded dose to its slot", diff --git a/src/lib/medications/course-end-placeholders.ts b/src/lib/medications/course-end-placeholders.ts new file mode 100644 index 000000000..557c65b98 --- /dev/null +++ b/src/lib/medications/course-end-placeholders.ts @@ -0,0 +1,77 @@ +/** + * #1280 — the pending placeholders an "End course" took, given back when the + * End is taken back. Called by the one writer of a medication's window + * (`project` in `courses.ts`) whenever the instant it carried goes away. + */ +import type { Prisma } from "@/generated/prisma/client"; +import { isInsideCourses } from "@/lib/medications/course-window"; +import { userDayKey } from "@/lib/tz/format"; + +/** + * #1280 — undo what an "End course" did to the pending placeholders. + * + * The End tombstones every pending placeholder at or after its instant, and + * stamps them with that instant (`deletedAt = endedAt`), so the rows it took + * are exactly the pending ones carrying it. Once the instant is gone, the + * slots those rows held are inside the course again, but a tombstoned row + * keeps its unique slot: the today projector treats it as present and never + * mints a new one, so the dose would be neither listed, reminded nor missed. + * + * So they come back, each one only while the slot is inside the courses as + * they now stand (an end moved to an earlier day keeps them gone) and while + * no live row already holds the slot (a dose logged for it after the End). + * `syncVersion` moves so the clients' sync sees them return. + */ +export async function reviveEndedPlaceholders( + tx: Prisma.TransactionClient, + args: { + userId: string; + medicationId: string; + endedAt: Date; + courses: readonly { + startsOn: Date; + endsOn: Date | null; + endedAt: Date | null; + }[]; + timeZone: string; + }, +): Promise { + const dropped = await tx.medicationIntakeEvent.findMany({ + where: { + userId: args.userId, + medicationId: args.medicationId, + deletedAt: args.endedAt, + takenAt: null, + skipped: false, + autoMissed: false, + scheduledFor: { gte: args.endedAt }, + }, + select: { id: true, scheduledFor: true }, + }); + if (dropped.length === 0) return; + const held = await tx.medicationIntakeEvent.findMany({ + where: { + medicationId: args.medicationId, + deletedAt: null, + scheduledFor: { in: dropped.map((r) => r.scheduledFor) }, + }, + select: { scheduledFor: true }, + }); + const taken = new Set(held.map((r) => r.scheduledFor.getTime())); + const ids = dropped + .filter( + (r) => + !taken.has(r.scheduledFor.getTime()) && + isInsideCourses( + userDayKey(r.scheduledFor, args.timeZone), + args.courses, + r.scheduledFor, + ), + ) + .map((r) => r.id); + if (ids.length === 0) return; + await tx.medicationIntakeEvent.updateMany({ + where: { id: { in: ids } }, + data: { deletedAt: null, syncVersion: { increment: 1 } }, + }); +} diff --git a/src/lib/medications/courses.ts b/src/lib/medications/courses.ts index 258adb02b..68f4ef1af 100644 --- a/src/lib/medications/courses.ts +++ b/src/lib/medications/courses.ts @@ -35,6 +35,7 @@ import { type CourseStatus, } from "@/lib/medications/course-window"; import { userDayKey } from "@/lib/tz/format"; +import { reviveEndedPlaceholders } from "@/lib/medications/course-end-placeholders"; import { moveAutoTakeFloorToNow, windowWidens, @@ -170,12 +171,20 @@ type ProjectedWindow = { * v1.42.2 — a window that now reaches further into the past than before * (an earlier start, a later or removed end) would let the auto-take pass * fill the days it reopens as taken; its floor moves to now instead. + * + * #1280 — a write that takes back an "End course" (the end moved, removed, + * or the ended course deleted) brings back the placeholders the End + * tombstoned (`reviveEndedPlaceholders`). */ -async function project(tx: Tx, medicationId: string): Promise { +async function project( + tx: Tx, + medicationId: string, + timeZone: string, +): Promise { const [before, courses] = await Promise.all([ tx.medication.findUnique({ where: { id: medicationId }, - select: { startsOn: true, endsOn: true }, + select: { userId: true, startsOn: true, endsOn: true, endedAt: true }, }), tx.medicationCourse.findMany({ where: { medicationId }, @@ -194,6 +203,18 @@ async function project(tx: Tx, medicationId: string): Promise { if (before && windowWidens(before, window)) { await moveAutoTakeFloorToNow(tx, medicationId); } + if ( + before?.endedAt && + before.endedAt.getTime() !== window.endedAt?.getTime() + ) { + await reviveEndedPlaceholders(tx, { + userId: before.userId, + medicationId, + endedAt: before.endedAt, + courses, + timeZone, + }); + } return window; } @@ -281,7 +302,7 @@ export async function createCourse( }, select: COURSE_ROW_SELECT, }); - await project(tx, args.medicationId); + await project(tx, args.medicationId, args.timeZone); return created; }); } @@ -326,7 +347,7 @@ export async function updateCourse( }, select: COURSE_ROW_SELECT, }); - await project(tx, args.medicationId); + await project(tx, args.medicationId, args.timeZone); return updated; }); } @@ -345,7 +366,7 @@ export async function deleteCourse( throw new CourseWriteError("notFound"); } await tx.medicationCourse.delete({ where: { id: args.courseId } }); - await project(tx, args.medicationId); + await project(tx, args.medicationId, args.timeZone); }); } @@ -408,10 +429,10 @@ async function writeCurrentWindow( const latest = sortCourses(courses).at(-1); if (args.startsOn === null && args.endsOn === null) { - if (!latest) return project(tx, args.medicationId); + if (!latest) return project(tx, args.medicationId, args.timeZone); if (courses.length > 1) throw new CourseWriteError("windowRequired"); await tx.medicationCourse.delete({ where: { id: latest.id } }); - return project(tx, args.medicationId); + return project(tx, args.medicationId, args.timeZone); } const next = windowAsCourse( @@ -443,7 +464,7 @@ async function writeCurrentWindow( }, }); } - return project(tx, args.medicationId); + return project(tx, args.medicationId, args.timeZone); } /** @@ -545,7 +566,7 @@ export async function endCurrentCourse(args: WriteContext): Promise<{ data: { deletedAt: now, syncVersion: { increment: 1 } }, }); return { - window: await project(tx, args.medicationId), + window: await project(tx, args.medicationId, args.timeZone), previous: { startsOn: medication.startsOn, endsOn: medication.endsOn, diff --git a/tests/integration/medication-course-end.test.ts b/tests/integration/medication-course-end.test.ts index 32cbacbac..714e9e4f8 100644 --- a/tests/integration/medication-course-end.test.ts +++ b/tests/integration/medication-course-end.test.ts @@ -593,6 +593,96 @@ describe("End follows what the caller may do in a shared record", () => { }); }); +describe("undoing an End the same day brings the later doses back", () => { + const today = () => + call( + "@/app/api/medications/intake/route", + "GET", + "/api/medications/intake?scope=today", + {}, + ); + + it.each([ + [ + "the course editor", + async (id: string) => { + const course = + await getPrismaClient().medicationCourse.findFirstOrThrow({ + where: { medicationId: id }, + }); + return call( + "@/app/api/medications/[id]/courses/[courseId]/route", + "PATCH", + `/api/medications/${id}/courses/${course.id}`, + { id, courseId: course.id }, + { endsOn: "2026-06-12" }, + ); + }, + ], + [ + "the medication's PUT", + (id: string) => putMedication(id, { endsOn: null }), + ], + ] as const)("through %s", async (_label, undo) => { + at("2026-06-10T09:00:00.000Z"); + const userId = await makeUser(); + const id = await seedTwiceDaily(userId); + expect((await today()).status).toBe(200); + expect(await liveRowsOn(id, EVENING)).toHaveLength(1); + + at(ENDED_AT); + expect((await endCourse(id)).status).toBe(200); + expect(await liveRowsOn(id, EVENING)).toHaveLength(0); + + at("2026-06-10T12:00:00.000Z"); + const res = await undo(id); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect((await detail(id)).endedAt).toBeNull(); + + // The evening placeholder is live again, on the list and in sync. + const evening = await liveRowsOn(id, EVENING); + expect(evening).toHaveLength(1); + expect(evening[0]).toMatchObject({ takenAt: null, skipped: false }); + expect((await today()).status).toBe(200); + expect(await liveRowsOn(id, EVENING)).toHaveLength(1); + + // Left untaken, it is missed like any dose of a running course. + await runIntakeAutoSkipPass(getPrismaClient(), { + nowMs: Date.parse("2026-06-12T12:00:00.000Z"), + }); + const missed = await getPrismaClient().medicationIntakeEvent.findMany({ + where: { medicationId: id, autoMissed: true, deletedAt: null }, + select: { scheduledFor: true }, + orderBy: { scheduledFor: "asc" }, + }); + expect(missed.map((r) => r.scheduledFor.toISOString())).toContain(EVENING); + }); + + it("leaves a slot dropped by the End alone when a dose already holds it", async () => { + at("2026-06-10T09:00:00.000Z"); + const userId = await makeUser(); + const id = await seedTwiceDaily(userId); + expect((await today()).status).toBe(200); + at(ENDED_AT); + expect((await endCourse(id)).status).toBe(200); + // A dose logged for that slot afterwards, from another source. + await getPrismaClient().medicationIntakeEvent.create({ + data: { + userId, + medicationId: id, + scheduledFor: new Date(EVENING), + takenAt: new Date("2026-06-10T18:05:00.000Z"), + source: "WEB", + }, + }); + at("2026-06-10T19:00:00.000Z"); + expect((await putMedication(id, { endsOn: null })).status).toBe(200); + const rows = await liveRowsOn(id, EVENING); + expect(rows).toHaveLength(1); + expect(rows[0].source).toBe("WEB"); + }); +}); + describe("an edit that moves the end clears the instant", () => { it("reopens the course to the end of its new last day", async () => { at(ENDED_AT);