mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
23e0cc35af
commit
0ff1340ff4
6 files changed
+202
-7
No files matched your search
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user