diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 500bc360f..da7612a17 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -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 diff --git a/src/lib/medications/__tests__/auto-take-window.test.ts b/src/lib/medications/__tests__/auto-take-window.test.ts new file mode 100644 index 000000000..17b660fbf --- /dev/null +++ b/src/lib/medications/__tests__/auto-take-window.test.ts @@ -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 }, + }); + }); +}); diff --git a/src/lib/medications/auto-take.ts b/src/lib/medications/auto-take.ts index d34a0e3ad..a9113cd48 100644 --- a/src/lib/medications/auto-take.ts +++ b/src/lib/medications/auto-take.ts @@ -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 { + await client.medication.updateMany({ + where: { + id: medicationId, + autoMarkTaken: true, + autoMarkTakenSince: { lt: now }, + }, + data: { autoMarkTakenSince: now }, + }); +} + const ROLLING_SCHEDULE_SELECT = { id: true, windowStart: true, diff --git a/src/lib/medications/courses.ts b/src/lib/medications/courses.ts index 4abd7b7d0..760b187f9 100644 --- a/src/lib/medications/courses.ts +++ b/src/lib/medications/courses.ts @@ -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 { - 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; } diff --git a/src/lib/medications/lifecycle.ts b/src/lib/medications/lifecycle.ts index 80b84dc57..a021daf9c 100644 --- a/src/lib/medications/lifecycle.ts +++ b/src/lib/medications/lifecycle.ts @@ -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, diff --git a/tests/integration/medication-auto-mark-taken.test.ts b/tests/integration/medication-auto-mark-taken.test.ts index fb36186c1..a0e80eb67 100644 --- a/tests/integration/medication-auto-mark-taken.test.ts +++ b/tests/integration/medication-auto-mark-taken.test.ts @@ -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();