mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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({
|
||||
|
||||
@@ -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<FormState>(EMPTY_FORM);
|
||||
// The form as it opened, so an edit sends only what changed.
|
||||
const [initialForm, setInitialForm] = useState<FormState>(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<MeasurementReminder | null>(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 {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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<ReminderEditBody> {
|
||||
const body: Partial<ReminderEditBody> = {};
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user