fix(medications): give back the doses an End took when the End is undone

End course tombstones the pending placeholders at or after its instant.
Moving or removing the end clears the instant, but the tombstoned rows
kept their unique slots, so the today projector treated them as present
and minted nothing: the later dose of that day was neither listed,
reminded nor missed.

The window's one writer now revives the placeholders the End stamped
with its instant once that instant is gone, when the slot is inside the
courses as they stand and no live row holds it.

Refs #1280
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 00:37:35 +02:00
1 parent f0b6854a91
commit 5cfaf4869d
4 files changed
+199 -9

No files matched your search

@@ -74,6 +74,8 @@ const INTAKE_MAY_SEE_EVERY_ROW: Record<string, string> = {
"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",
@@ -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<void> {
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 } },
});
}
+30 -9
View File
@@ -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<ProjectedWindow> {
async function project(
tx: Tx,
medicationId: string,
timeZone: string,
): Promise<ProjectedWindow> {
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<ProjectedWindow> {
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,
@@ -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);