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<