mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
93881c1f93
commit
cd79cfaae9
5 files changed
+106
-15
No files matched your search
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<
|
||||
|
||||
Reference in new issue
Block a user