From cd79cfaae9778cc934737d6b46534ee732842f28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 10:05:54 +0200 Subject: [PATCH] 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. --- docs/api/openapi.yaml | 9 ++- .../[id]/__tests__/route.test.ts | 49 +++++++++++++++ .../api/measurement-reminders/[id]/route.ts | 59 +++++++++++++++---- .../openapi/routes/measurement-reminders.ts | 2 +- src/lib/validations/measurement-reminders.ts | 2 +- 5 files changed, 106 insertions(+), 15 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 45b2bcd59..8235440f2 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/src/app/api/measurement-reminders/[id]/__tests__/route.test.ts b/src/app/api/measurement-reminders/[id]/__tests__/route.test.ts index 97c9fb415..8ca59d072 100644 --- a/src/app/api/measurement-reminders/[id]/__tests__/route.test.ts +++ b/src/app/api/measurement-reminders/[id]/__tests__/route.test.ts @@ -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); }); diff --git a/src/app/api/measurement-reminders/[id]/route.ts b/src/app/api/measurement-reminders/[id]/route.ts index f51d742ca..082d4cf50 100644 --- a/src/app/api/measurement-reminders/[id]/route.ts +++ b/src/app/api/measurement-reminders/[id]/route.ts @@ -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 }, diff --git a/src/lib/openapi/routes/measurement-reminders.ts b/src/lib/openapi/routes/measurement-reminders.ts index 8fdbf8742..3312ad0b7 100644 --- a/src/lib/openapi/routes/measurement-reminders.ts +++ b/src/lib/openapi/routes/measurement-reminders.ts @@ -115,7 +115,7 @@ export const measurementReminderPaths: NonNullable = 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, diff --git a/src/lib/validations/measurement-reminders.ts b/src/lib/validations/measurement-reminders.ts index 31e9d2f6a..724e2b2d4 100644 --- a/src/lib/validations/measurement-reminders.ts +++ b/src/lib/validations/measurement-reminders.ts @@ -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<