fix(reminders): keep a check-up's due date when only its details are edited

Every edit of a reminder recomputed its due date from now, so fixing
the label or the location of an open, overdue check-up moved it to its
next slot, the same "gone until next year" the reminder tick caused.
The due date is now recomputed only when the edit changes when the
reminder recurs: the interval, the rule, the first due date (compared
by value, since the edit forms resend every field), or switching a
disabled reminder back on. A notify-hour edit keeps the due day and
moves the hour on it, together with a snooze pinned to that slot.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 10:05:54 +02:00
1 parent 93881c1f93
commit cd79cfaae9
5 files changed
+106 -15

No files matched your search

+6 -3
View File
@@ -13350,8 +13350,11 @@ paths:
tags:
- MeasurementReminders
summary: Edit a Vorsorge reminder (v1.17.1)
description: Partial edit; omitted fields are left untouched. nextDueAt is recomputed server-side after the cadence
merge. Owner-scoped.
description: Partial edit; omitted fields are left untouched. nextDueAt is recomputed server-side only when the edit
changes when the reminder recurs (intervalDays, rrule, anchorDate compared by value, or enabled switched back
on); that recompute also clears a snooze. A notifyHour-only edit keeps the due day and moves the hour on it. Any
other edit (label, location, measurementType) leaves nextDueAt as it is, so an open, overdue check-up stays due.
Owner-scoped.
parameters:
- in: path
name: id
@@ -26953,7 +26956,7 @@ components:
enabled:
type: boolean
description: Partial edit of a Vorsorge reminder. Omitted fields are left untouched; nextDueAt is recomputed server-side
after the patch applies.
when the cadence changes and otherwise kept (a notifyHour edit moves only the hour on the same due day).
MeasurementReminderSnooze:
type: object
properties:
@@ -231,6 +231,55 @@ describe("PATCH /api/measurement-reminders/[id] — nextDueAt honours explicit-n
expect("rrule" in persisted).toBe(false);
expect("anchorDate" in persisted).toBe(false);
// v1.39.2 — and nextDueAt is not written either. It used to be
// recomputed on every PATCH, so correcting the label of an open,
// overdue check-up (BASE_ROW is due 2026-01-31, NOW is June) quietly
// moved it to its next slot, the same "reminded and gone" defect the
// reminder tick had. Only a change to when it recurs reschedules it.
expect("nextDueAt" in persisted).toBe(false);
expect("snoozedUntil" in persisted).toBe(false);
});
it("an edit that resends the unchanged cadence keeps an overdue slot", async () => {
findFirstMock.mockResolvedValue(BASE_ROW);
const res = await PATCH(
makeRequest({ label: "New label", intervalDays: 30, rrule: null }),
params,
);
expect(res.status).toBe(200);
const persisted = updateMock.mock.calls[0]?.[0]?.data as Record<
string,
unknown
>;
expect("nextDueAt" in persisted).toBe(false);
});
it("a notify-hour edit keeps the due day and moves only the hour", async () => {
findFirstMock.mockResolvedValue(BASE_ROW);
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
>;
// 2026-01-31 18:00 in Berlin (UTC+1 in winter).
expect(persisted.nextDueAt).toEqual(new Date("2026-01-31T17:00:00Z"));
});
it("re-enabling a disabled reminder schedules it from now", async () => {
findFirstMock.mockResolvedValue({ ...BASE_ROW, enabled: false });
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(BASE_ROW, TZ, NOW);
expect(persisted.nextDueAt).toEqual(expected);
});
+49 -10
View File
@@ -1,8 +1,10 @@
/**
* v1.17.1 — Vorsorge (measurement) reminder by id: get + patch + delete.
*
* PATCH re-derives the server-authoritative `nextDueAt` after applying
* the (mutually-exclusive cadence) edit. DELETE removes the row; the
* PATCH re-derives the server-authoritative `nextDueAt` when the edit
* changes when the reminder recurs (interval, rule, anchor, or re-enabling
* it); a notify-hour edit moves the hour on the same due day, and any other
* edit leaves the due date alone. DELETE removes the row; the
* confirmation dialog says the reminder is permanently deleted, and nothing
* here needs a tombstone to keep that promise honest.
*/
@@ -30,6 +32,7 @@ import {
type ReminderScheduleInput,
} from "@/lib/measurement-reminders/scheduling";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
type RouteParams = { params: Promise<{ id: string }> };
@@ -184,14 +187,50 @@ export const PATCH = apiHandler(
// (Coach-suggested) cadence keeps self-expiring.
endsOn: existing.endsOn,
};
const after =
existing.lastSatisfiedAt && existing.lastSatisfiedAt > now
? existing.lastSatisfiedAt
: now;
updateData.nextDueAt = computeReminderNextDueAt(merged, timezone, after);
// v1.37.20 (#223) — a cadence edit recomputes `nextDueAt`, so it clears
// the snooze cursor: the cycle the snooze pushed back no longer exists.
updateData.snoozedUntil = null;
// v1.39.2 — only an edit to WHEN the reminder recurs reschedules it. A
// 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.
const sameInstant = (a: Date | null, b: Date | null) =>
(a?.getTime() ?? null) === (b?.getTime() ?? null);
const cadenceChanged =
merged.intervalDays !== existing.intervalDays ||
merged.rrule !== existing.rrule ||
!sameInstant(merged.anchorDate, existing.anchorDate) ||
(updateData.enabled === true && !existing.enabled);
if (cadenceChanged) {
const after =
existing.lastSatisfiedAt && existing.lastSatisfiedAt > now
? existing.lastSatisfiedAt
: now;
updateData.nextDueAt = computeReminderNextDueAt(merged, timezone, after);
// v1.37.20 (#223) — a cadence edit recomputes `nextDueAt`, so it clears
// the snooze cursor: the cycle the snooze pushed back no longer exists.
updateData.snoozedUntil = null;
} else if (
merged.notifyHour !== existing.notifyHour &&
existing.nextDueAt !== null
) {
// A new notify hour keeps the due DAY and moves the hour on it, so an
// open slot stays open. A snooze pinned to that same slot moves with it.
const day = wallClockInTz(existing.nextDueAt, timezone);
const moved = zonedWallClockToUtc(
{
year: day.year,
month: day.month,
day: day.day,
hour: merged.notifyHour,
minute: 0,
second: 0,
},
timezone,
);
updateData.nextDueAt = moved;
if (sameInstant(existing.snoozedUntil, existing.nextDueAt)) {
updateData.snoozedUntil = moved;
}
}
const updated = await prisma.measurementReminder.update({
where: { id },
@@ -115,7 +115,7 @@ export const measurementReminderPaths: NonNullable<ZodOpenApiObject["paths"]> =
tags: ["MeasurementReminders"],
summary: "Edit a Vorsorge reminder (v1.17.1)",
description:
"Partial edit; omitted fields are left untouched. nextDueAt is recomputed server-side after the cadence merge. Owner-scoped.",
"Partial edit; omitted fields are left untouched. nextDueAt is recomputed server-side only when the edit changes when the reminder recurs (intervalDays, rrule, anchorDate compared by value, or enabled switched back on); that recompute also clears a snooze. A notifyHour-only edit keeps the due day and moves the hour on it. Any other edit (label, location, measurementType) leaves nextDueAt as it is, so an open, overdue check-up stays due. Owner-scoped.",
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
+1 -1
View File
@@ -185,7 +185,7 @@ export const updateMeasurementReminderSchema = z
.meta({
id: "MeasurementReminderUpdate",
description:
"Partial edit of a Vorsorge reminder. Omitted fields are left untouched; nextDueAt is recomputed server-side after the patch applies.",
"Partial edit of a Vorsorge reminder. Omitted fields are left untouched; nextDueAt is recomputed server-side when the cadence changes and otherwise kept (a notifyHour edit moves only the hour on the same due day).",
});
export type UpdateMeasurementReminderInput = z.infer<