fix(reminders): an edit that does not change the cycle keeps the open slot

Renaming an overdue booster check-up on the web still moved it to next
year. The edit sheet sends the first due date as the browser's local
midnight on every save, a booster row stores the dose instant plus N
days, and the route read the different instant as a new first due date.

The route now compares the first due date by calendar day in the
profile timezone and drops a resent same-day value from the write. The
edit sheet sends only the fields that changed. Re-enabling a reminder
that stays due after it is sent keeps the slot it was on instead of
scheduling from now; a short-cycle reminder is still scheduled from
now. Moving the notify hour later on the day a reminder was already
sent carries the last-sent cursor along, so the slot does not go out a
second time at the new hour.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 10:38:47 +02:00
1 parent 31e4c95487
commit 7aefde67e2
5 files changed
+233 -26

No files matched your search

@@ -66,6 +66,8 @@ const BASE_ROW = {
location: null,
nextDueAt: new Date("2026-01-31T09:00:00Z"),
lastSatisfiedAt: null as Date | null,
lastNotifiedAt: null as Date | null,
snoozedUntil: null as Date | null,
enabled: true,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
@@ -270,7 +272,24 @@ describe("PATCH /api/measurement-reminders/[id] — nextDueAt honours explicit-n
expect(persisted.nextDueAt).toEqual(new Date("2026-01-31T17:00:00Z"));
});
it("re-enabling a disabled reminder schedules it from now", async () => {
it("re-enabling a disabled short-cycle reminder schedules it from now", async () => {
const weekly = { ...BASE_ROW, intervalDays: 7, enabled: false };
findFirstMock.mockResolvedValue(weekly);
const res = await PATCH(makeRequest({ enabled: true }), params);
expect(res.status).toBe(200);
const persisted = updateMock.mock.calls[0]?.[0]?.data as Record<
string,
unknown
>;
const expected = computeReminderNextDueAt(weekly, TZ, NOW);
expect(persisted.nextDueAt).toEqual(expected);
});
it("re-enabling an overdue reminder that stays due keeps its open slot", async () => {
// Thirty-day cycle, due in January: switching it off and on again is not
// doing it.
findFirstMock.mockResolvedValue({ ...BASE_ROW, enabled: false });
const res = await PATCH(makeRequest({ enabled: true }), params);
@@ -280,7 +299,53 @@ describe("PATCH /api/measurement-reminders/[id] — nextDueAt honours explicit-n
string,
unknown
>;
const expected = computeReminderNextDueAt(BASE_ROW, TZ, NOW);
expect(persisted.nextDueAt).toEqual(expected);
expect(persisted.enabled).toBe(true);
expect("nextDueAt" in persisted).toBe(false);
});
it("reads a resent first due date on the same calendar day as unchanged", async () => {
// A booster row stores the dose instant plus N days; the web form sends
// the same day back as local midnight. Same day in the profile zone, so
// neither the anchor nor the due date moves.
findFirstMock.mockResolvedValue({
...BASE_ROW,
anchorDate: new Date("2026-01-10T13:37:00Z"),
});
const res = await PATCH(
makeRequest({
label: "New label",
anchorDate: "2026-01-09T23:00:00.000Z", // 00:00 on the 10th, Berlin
}),
params,
);
expect(res.status).toBe(200);
const persisted = updateMock.mock.calls[0]?.[0]?.data as Record<
string,
unknown
>;
expect("anchorDate" in persisted).toBe(false);
expect("nextDueAt" in persisted).toBe(false);
});
it("moves the last-sent cursor with a later notify hour on the day it was sent", async () => {
// Sent at 09:00 today, moved to 18:00: still the slot that was sent, so
// the tick must not send it again at 18:00.
findFirstMock.mockResolvedValue({
...BASE_ROW,
nextDueAt: new Date("2026-06-15T07:00:00Z"),
lastNotifiedAt: new Date("2026-06-15T07:00:05Z"),
});
const res = await PATCH(makeRequest({ notifyHour: 18 }), params);
expect(res.status).toBe(200);
const persisted = updateMock.mock.calls[0]?.[0]?.data as Record<
string,
unknown
>;
expect(persisted.nextDueAt).toEqual(new Date("2026-06-15T16:00:00Z"));
expect(persisted.lastNotifiedAt).toEqual(new Date("2026-06-15T16:00:00Z"));
});
});
@@ -33,6 +33,8 @@ import {
} from "@/lib/measurement-reminders/scheduling";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
import { userDayKey } from "@/lib/tz/format";
import { holdsOpenAfterReminder } from "@/lib/measurement-reminders/holds-open";
type RouteParams = { params: Promise<{ id: string }> };
@@ -191,15 +193,40 @@ export const PATCH = apiHandler(
// label, location or type edit used to recompute too, and since the
// recompute searches strictly after now, correcting the label of an
// open, overdue check-up quietly moved it to its next slot. Compared by
// value, because the edit forms resend every field on each save.
// value, because a client may resend every field on each save.
//
// The first due date is compared by CALENDAR DAY in the profile zone. The
// web form sends a date as the browser's local midnight, and a booster
// row stores the dose instant plus N days, so the same day arrives as a
// different instant. A resent same-day anchor is dropped from the write
// altogether, so the stored instant stays what it was.
const sameInstant = (a: Date | null, b: Date | null) =>
(a?.getTime() ?? null) === (b?.getTime() ?? null);
const sameDay = (a: Date | null, b: Date | null) =>
a === null || b === null
? a === b
: userDayKey(a, timezone) === userDayKey(b, timezone);
if (
Object.hasOwn(updateData, "anchorDate") &&
sameDay(merged.anchorDate, existing.anchorDate)
) {
delete updateData.anchorDate;
merged.anchorDate = existing.anchorDate;
}
const cadenceChanged =
merged.intervalDays !== existing.intervalDays ||
merged.rrule !== existing.rrule ||
!sameInstant(merged.anchorDate, existing.anchorDate) ||
(updateData.enabled === true && !existing.enabled);
if (cadenceChanged) {
!sameInstant(merged.anchorDate, existing.anchorDate);
const reEnabled = updateData.enabled === true && !existing.enabled;
// Re-enabling a reminder that stays due after it is sent keeps the slot it
// was on, overdue or not: switching it off and on again is not doing it.
// A short-cycle reminder is scheduled from now, as before.
const keepsOpenSlot =
reEnabled &&
!cadenceChanged &&
existing.nextDueAt !== null &&
holdsOpenAfterReminder(existing, timezone, existing.nextDueAt);
if (cadenceChanged || (reEnabled && !keepsOpenSlot)) {
const after =
existing.lastSatisfiedAt && existing.lastSatisfiedAt > now
? existing.lastSatisfiedAt
@@ -230,6 +257,16 @@ export const PATCH = apiHandler(
if (sameInstant(existing.snoozedUntil, existing.nextDueAt)) {
updateData.snoozedUntil = moved;
}
// Already sent for this slot and moved to a later hour: carry the
// last-sent cursor along, or the slot would read as unsent and go out
// a second time at the new hour.
if (
existing.lastNotifiedAt !== null &&
existing.lastNotifiedAt.getTime() >= existing.nextDueAt.getTime() &&
moved.getTime() > existing.lastNotifiedAt.getTime()
) {
updateData.lastNotifiedAt = moved;
}
}
const updated = await prisma.measurementReminder.update({