fix(medications): never backfill days an edit reopens under auto-take

A start moved earlier, an end moved later or removed, or a medication
made active again reopened past slots, and the auto-take pass filled up
to a week of them as taken. The course window writer and the
re-activation paths now move the auto-take floor to the moment of the
edit.
This commit is contained in:
Marc-André Bombeck committed 2026-10-11 15:07:06 +02:00
1 parent 23e0cc35af
commit 0ff1340ff4
6 files changed
+202 -7

No files matched your search

+7 -1
View File
@@ -32,7 +32,10 @@ import {
toRevisionPayloadEntry,
} from "@/lib/medications/scheduling/schedule-eras";
import type { Prisma } from "@/generated/prisma/client";
import { loadAutoTakeRollingAnchors } from "@/lib/medications/auto-take";
import {
loadAutoTakeRollingAnchors,
moveAutoTakeFloorToNow,
} from "@/lib/medications/auto-take";
import {
computeDisplayDue,
OVERDUE_LOOKBACK_MS,
@@ -1022,6 +1025,9 @@ export const PUT = apiHandler(
where: { medicationId: id, userId: user.id, resumedAt: null },
data: { resumedAt: new Date() },
});
// v1.42.2 — made active again: the auto-take pass starts from
// now, never filling the inactive days as taken.
await moveAutoTakeFloorToNow(tx, id);
}
// v1.40 (#1024) — write the window onto the latest course and
@@ -0,0 +1,58 @@
/**
* v1.42.2 — which window edits reopen past days for the auto-take pass.
*/
import { describe, expect, it, vi } from "vitest";
import {
moveAutoTakeFloorToNow,
windowWidens,
} from "@/lib/medications/auto-take";
const d = (iso: string) => new Date(`${iso}T00:00:00.000Z`);
describe("windowWidens", () => {
it("an earlier or removed start, a later or removed end, reopens time", () => {
const before = { startsOn: d("2026-06-10"), endsOn: d("2026-06-20") };
expect(windowWidens(before, { ...before, startsOn: d("2026-06-01") })).toBe(
true,
);
expect(windowWidens(before, { ...before, startsOn: null })).toBe(true);
expect(windowWidens(before, { ...before, endsOn: d("2026-06-30") })).toBe(
true,
);
expect(windowWidens(before, { ...before, endsOn: null })).toBe(true);
});
it("a later start, an earlier end, or no change reopens nothing", () => {
const before = { startsOn: d("2026-06-10"), endsOn: d("2026-06-20") };
expect(windowWidens(before, before)).toBe(false);
expect(windowWidens(before, { ...before, startsOn: d("2026-06-12") })).toBe(
false,
);
expect(windowWidens(before, { ...before, endsOn: d("2026-06-15") })).toBe(
false,
);
expect(
windowWidens(
{ startsOn: null, endsOn: null },
{ startsOn: d("2026-06-01"), endsOn: d("2026-06-30") },
),
).toBe(false);
});
});
describe("moveAutoTakeFloorToNow", () => {
it("moves only an auto-taken medication's earlier floor", async () => {
const updateMany = vi.fn(async () => ({ count: 1 }));
const now = new Date("2026-06-10T10:00:00.000Z");
await moveAutoTakeFloorToNow({ medication: { updateMany } }, "m1", now);
expect(updateMany).toHaveBeenCalledWith({
where: {
id: "m1",
autoMarkTaken: true,
autoMarkTakenSince: { lt: now },
},
data: { autoMarkTakenSince: now },
});
});
});
+54
View File
@@ -236,6 +236,60 @@ export function autoTakeEligibleWhere(now: Date) {
};
}
/**
* True when an edit to a medication's window reopens time before now: the
* start moves earlier (or goes), or the end moves later (or goes). Dates are
* calendar days, compared as stored.
*/
export function windowWidens(
before: { startsOn: Date | null; endsOn: Date | null },
after: { startsOn: Date | null; endsOn: Date | null },
): boolean {
const startsEarlier =
before.startsOn !== null &&
(after.startsOn === null ||
after.startsOn.getTime() < before.startsOn.getTime());
const endsLater =
before.endsOn !== null &&
(after.endsOn === null || after.endsOn.getTime() > before.endsOn.getTime());
return startsEarlier || endsLater;
}
/**
* Move an auto-taken medication's floor to now when it lies before now.
* Called wherever an edit reopens past slots (a wider window, a medication
* made active again): the option was on, but the person never had those
* days as due, so the pass must not fill them as taken. A no-op on a
* medication without the option.
*/
export interface AutoTakeFloorClient {
medication: {
updateMany(args: {
where: {
id: string;
autoMarkTaken: true;
autoMarkTakenSince: { lt: Date };
};
data: { autoMarkTakenSince: Date };
}): Promise<{ count: number }>;
};
}
export async function moveAutoTakeFloorToNow(
client: AutoTakeFloorClient,
medicationId: string,
now: Date = new Date(),
): Promise<void> {
await client.medication.updateMany({
where: {
id: medicationId,
autoMarkTaken: true,
autoMarkTakenSince: { lt: now },
},
data: { autoMarkTakenSince: now },
});
}
const ROLLING_SCHEDULE_SELECT = {
id: true,
windowStart: true,
+24 -5
View File
@@ -27,6 +27,10 @@ import {
type CourseStatus,
} from "@/lib/medications/course-window";
import { userDayKey } from "@/lib/tz/format";
import {
moveAutoTakeFloorToNow,
windowWidens,
} from "@/lib/medications/auto-take";
type Tx = Prisma.TransactionClient;
@@ -129,17 +133,32 @@ async function loadOwned(tx: Tx, userId: string, medicationId: string) {
type ProjectedWindow = { startsOn: Date | null; endsOn: Date | null };
/** Write the medication's window from its courses. The only writer. */
/**
* Write the medication's window from its courses. The only writer.
*
* 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.
*/
async function project(tx: Tx, medicationId: string): Promise<ProjectedWindow> {
const courses = await tx.medicationCourse.findMany({
where: { medicationId },
select: { startsOn: true, endsOn: true },
});
const [before, courses] = await Promise.all([
tx.medication.findUnique({
where: { id: medicationId },
select: { startsOn: true, endsOn: true },
}),
tx.medicationCourse.findMany({
where: { medicationId },
select: { startsOn: true, endsOn: true },
}),
]);
const window = projectCourseWindow(courses);
await tx.medication.update({
where: { id: medicationId },
data: { startsOn: window.startsOn, endsOn: window.endsOn },
});
if (before && windowWidens(before, window)) {
await moveAutoTakeFloorToNow(tx, medicationId);
}
return window;
}
+7 -1
View File
@@ -21,9 +21,13 @@
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
moveAutoTakeFloorToNow,
type AutoTakeFloorClient,
} from "@/lib/medications/auto-take";
interface ReconcilePrisma {
medication: {
medication: AutoTakeFloorClient["medication"] & {
findUnique: (args: {
where: { id: string };
select: { oneShot: true; active: true };
@@ -105,6 +109,8 @@ export async function reconcileOneShotState(
where: { id: medicationId, userId, oneShot: true },
data: { active: shouldBeActive },
});
// v1.42.2 — active again: the auto-take pass starts from now.
if (shouldBeActive) await moveAutoTakeFloorToNow(prismaClient, medicationId);
await auditLog("medication.oneShot.reconciled", {
userId,
@@ -380,6 +380,58 @@ describe("auto-mark-taken pass", () => {
});
});
describe("reopening past days never backfills them", () => {
it("a later end date or a re-activation moves the floor to now", async () => {
const prisma = getPrismaClient();
const userId = await makeUser();
const medicationId = await seedEvening(userId, "Course", {
autoMarkTakenSince: new Date("2026-06-01T00:00:00.000Z"),
});
const route = await import("@/app/api/medications/[id]/route");
const put = (body: unknown) =>
(route.PUT as Handler)(
new NextRequest(`http://localhost/api/medications/${medicationId}`, {
method: "PUT",
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
}),
{ params: Promise.resolve({ id: medicationId }) },
);
const since = async () =>
(
await prisma.medication.findUniqueOrThrow({
where: { id: medicationId },
select: { autoMarkTakenSince: true },
})
).autoMarkTakenSince?.toISOString();
// The course ended on 06-05; the pass filled up to then.
at("2026-06-03T10:00:00.000Z");
expect(
(await put({ startsOn: "2026-06-01", endsOn: "2026-06-05" })).status,
).toBe(200);
expect(await since()).toBe("2026-06-01T00:00:00.000Z");
at("2026-06-10T10:00:00.000Z");
await runIntakeAutoTakePass(prisma, { nowMs: Date.now() });
const filled = (await liveRows(medicationId)).length;
// Extended on 06-10 to the end of the month: 06-06..06-09 reopen, but
// the person never had them as due.
expect((await put({ endsOn: "2026-06-30" })).status).toBe(200);
expect(await since()).toBe("2026-06-10T10:00:00.000Z");
await runIntakeAutoTakePass(prisma, { nowMs: Date.now() });
expect((await liveRows(medicationId)).length).toBe(filled);
// Paused, then active again a week later: nothing in between is filled.
expect((await put({ active: false })).status).toBe(200);
at("2026-06-17T10:00:00.000Z");
expect((await put({ active: true })).status).toBe(200);
expect(await since()).toBe("2026-06-17T10:00:00.000Z");
await runIntakeAutoTakePass(prisma, { nowMs: Date.now() });
expect((await liveRows(medicationId)).length).toBe(filled);
});
});
describe("rolling cadence under auto-take", () => {
it("never stalls on a removed dose, and the card shows the slot the pass records", async () => {
const prisma = getPrismaClient();