From 7aefde67e27d50820af776a5eede41212d0c501b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 10:38:47 +0200 Subject: [PATCH] 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. --- .../[id]/__tests__/route.test.ts | 71 ++++++++++++++++++- .../api/measurement-reminders/[id]/route.ts | 45 ++++++++++-- .../vorsorge-section.tsx | 55 +++++++++----- .../__tests__/edit-body.test.ts | 45 ++++++++++++ src/lib/measurement-reminders/edit-body.ts | 43 +++++++++++ 5 files changed, 233 insertions(+), 26 deletions(-) create mode 100644 src/lib/measurement-reminders/__tests__/edit-body.test.ts create mode 100644 src/lib/measurement-reminders/edit-body.ts 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 8ca59d072..28611e5fc 100644 --- a/src/app/api/measurement-reminders/[id]/__tests__/route.test.ts +++ b/src/app/api/measurement-reminders/[id]/__tests__/route.test.ts @@ -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")); }); }); diff --git a/src/app/api/measurement-reminders/[id]/route.ts b/src/app/api/measurement-reminders/[id]/route.ts index 082d4cf50..78b69439c 100644 --- a/src/app/api/measurement-reminders/[id]/route.ts +++ b/src/app/api/measurement-reminders/[id]/route.ts @@ -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({ diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index e211df9b2..1674b2457 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -51,6 +51,10 @@ import { isOverdueDue, relativeDueKey, } from "@/lib/measurement-reminders/due-day"; +import { + changedReminderFields, + type ReminderEditBody, +} from "@/lib/measurement-reminders/edit-body"; import { cn } from "@/lib/utils"; import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs"; import { EncounterSheet } from "@/components/encounters/encounter-sheet"; @@ -259,6 +263,8 @@ export function VorsorgeSection({ setVisitSession((n) => n + 1); }; const [form, setForm] = useState(EMPTY_FORM); + // The form as it opened, so an edit sends only what changed. + const [initialForm, setInitialForm] = useState(EMPTY_FORM); // The value-capture sheet: holds the reminder whose measurement we are // entering. Non-null ⇒ the MeasurementForm sheet is open for that row. const [capturing, setCapturing] = useState(null); @@ -292,7 +298,7 @@ export function VorsorgeSection({ : interval != null ? CADENCE_CUSTOM : "7"; - setForm({ + const opened: FormState = { label: resolveReminderLabel(reminder, t), kind: reminder.measurementType ? "type" : "freeText", measurementType: reminder.measurementType ?? "BLOOD_PRESSURE_SYS", @@ -303,7 +309,9 @@ export function VorsorgeSection({ anchorDate: toDateInputValue(reminder.anchorDate), notifyHour: reminder.notifyHour, location: reminder.location ?? "", - }); + }; + setForm(opened); + setInitialForm(opened); setEditing(reminder); } @@ -314,36 +322,45 @@ export function VorsorgeSection({ // Resolve the picker state to the mutually-exclusive cadence pair the // schema expects (exactly one of intervalDays / rrule). - function resolveCadence(): { + function resolveCadence(state: FormState): { intervalDays: number | null; rrule: string | null; } { - if (form.cadenceChoice === CADENCE_RRULE) { - const trimmed = form.rrule.trim(); + if (state.cadenceChoice === CADENCE_RRULE) { + const trimmed = state.rrule.trim(); return { intervalDays: null, rrule: trimmed || null }; } - if (form.cadenceChoice === CADENCE_CUSTOM) { - return { intervalDays: form.customIntervalDays, rrule: null }; + if (state.cadenceChoice === CADENCE_CUSTOM) { + return { intervalDays: state.customIntervalDays, rrule: null }; } - return { intervalDays: Number(form.cadenceChoice), rrule: null }; + return { intervalDays: Number(state.cadenceChoice), rrule: null }; + } + + function toEditBody(state: FormState): ReminderEditBody { + const { intervalDays, rrule } = resolveCadence(state); + return { + label: state.label.trim(), + measurementType: state.kind === "type" ? state.measurementType : null, + intervalDays, + rrule, + anchorDate: fromDateInputValue(state.anchorDate), + notifyHour: state.notifyHour, + location: state.location.trim() || null, + }; } function submit() { const trimmed = form.label.trim(); if (!trimmed) return; - const { intervalDays, rrule } = resolveCadence(); - const body = { - label: trimmed, - measurementType: form.kind === "type" ? form.measurementType : null, - intervalDays, - rrule, - anchorDate: fromDateInputValue(form.anchorDate), - notifyHour: form.notifyHour, - location: form.location.trim() || null, - }; + const body = toEditBody(form); if (isEdit) { + // Only what changed: an untouched first due date resent as local + // midnight used to read as a new one and move an overdue check-up on. update.mutate( - { id: (editing as MeasurementReminder).id, body }, + { + id: (editing as MeasurementReminder).id, + body: changedReminderFields(toEditBody(initialForm), body), + }, { onSuccess: closeSheet }, ); } else { diff --git a/src/lib/measurement-reminders/__tests__/edit-body.test.ts b/src/lib/measurement-reminders/__tests__/edit-body.test.ts new file mode 100644 index 000000000..68e9a8ea2 --- /dev/null +++ b/src/lib/measurement-reminders/__tests__/edit-body.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { changedReminderFields, type ReminderEditBody } from "../edit-body"; + +const BASE: ReminderEditBody = { + label: "Skin check", + measurementType: null, + intervalDays: 365, + rrule: null, + anchorDate: "2026-01-09T23:00:00.000Z", + notifyHour: 9, + location: null, +}; + +describe("changedReminderFields", () => { + it("sends only what the edit changed", () => { + expect( + changedReminderFields(BASE, { ...BASE, label: "Dermatology" }), + ).toEqual({ label: "Dermatology" }); + }); + + it("sends nothing for an untouched form, so a save moves no date", () => { + expect(changedReminderFields(BASE, { ...BASE })).toEqual({}); + }); + + it("sends the cadence as a pair, since the two are mutually exclusive", () => { + expect( + changedReminderFields(BASE, { + ...BASE, + intervalDays: null, + rrule: "FREQ=YEARLY", + }), + ).toEqual({ intervalDays: null, rrule: "FREQ=YEARLY" }); + }); + + it("sends a changed first due date and notify hour", () => { + expect( + changedReminderFields(BASE, { + ...BASE, + anchorDate: "2026-02-01T23:00:00.000Z", + notifyHour: 18, + }), + ).toEqual({ anchorDate: "2026-02-01T23:00:00.000Z", notifyHour: 18 }); + }); +}); diff --git a/src/lib/measurement-reminders/edit-body.ts b/src/lib/measurement-reminders/edit-body.ts new file mode 100644 index 000000000..240804a56 --- /dev/null +++ b/src/lib/measurement-reminders/edit-body.ts @@ -0,0 +1,43 @@ +/** + * v1.39.2 — the body an edit of a Vorsorge reminder sends: only the fields + * the person changed. + * + * The edit sheet used to send every field on each save. The first due date + * went out as the browser's local midnight even when nobody touched it, which + * the server read as a new first due date and rescheduled from, so renaming + * an overdue check-up moved it to its next slot. The server now compares by + * calendar day as well; this keeps the client from claiming changes it did + * not make in the first place. + */ +export interface ReminderEditBody { + label: string; + measurementType: string | null; + intervalDays: number | null; + rrule: string | null; + anchorDate: string | null; + notifyHour: number; + location: string | null; +} + +export function changedReminderFields( + initial: ReminderEditBody, + next: ReminderEditBody, +): Partial { + const body: Partial = {}; + if (next.label !== initial.label) body.label = next.label; + if (next.measurementType !== initial.measurementType) { + body.measurementType = next.measurementType; + } + // Mutually exclusive on the server: a change to either sends both. + if ( + next.intervalDays !== initial.intervalDays || + next.rrule !== initial.rrule + ) { + body.intervalDays = next.intervalDays; + body.rrule = next.rrule; + } + if (next.anchorDate !== initial.anchorDate) body.anchorDate = next.anchorDate; + if (next.notifyHour !== initial.notifyHour) body.notifyHour = next.notifyHour; + if (next.location !== initial.location) body.location = next.location; + return body; +}