fix(reminders): keep a course window's end through every reschedule

The first due slot of a Coach course honoured endsOn, but the tick's
roll-on, satisfy and skip all built their schedule input without it, so
a seven-day twice-daily course kept rolling on indefinitely and never
reached the null nextDueAt the expired-course cleanup waits for.
Carry endsOn on RollableReminder and SatisfiableReminder and select it
wherever a caller narrows the row.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 00:41:31 +01:00
1 parent f521cdbbaf
commit 6c8458d911
8 files changed
+152

No files matched your search

+8
View File
@@ -110,6 +110,14 @@
come back under the new link, and an option changed at that moment
could be lost to the new link. Turning it off now wins whichever comes
first, and the change lands on the link that stays live.
- **A seven-day blood pressure course now ends after seven days.** A
course suggested by the Coach knew its last day only until its first
reminder went out. From then on every sent reminder, every reading and
every skip planned the next one without that end, so the morning and
evening reminders went on for good. The end now holds through every
reschedule, and once the last slot is past the course is cleared from
the list as intended. A course that already ran past its end sends the
one reminder it has planned and then stops.
### Security
+2
View File
@@ -325,6 +325,7 @@ export async function resolveClosableReminder(
lastSatisfiedAt: true,
lastSkippedAt: true,
createdAt: true,
endsOn: true,
},
});
if (!row || row.userId !== userId || row.deletedAt !== null) return undefined;
@@ -340,6 +341,7 @@ export async function resolveClosableReminder(
lastSatisfiedAt: row.lastSatisfiedAt,
lastSkippedAt: row.lastSkippedAt,
createdAt: row.createdAt,
endsOn: row.endsOn,
};
}
@@ -116,4 +116,83 @@ describe.each(ZONES)("BYHOUR=7,19 in %s", (tz) => {
expect(sent).toEqual(["14 07:00", "14 19:00", "15 07:00", "15 19:00"]);
});
it("stops after the last slot of a course window and leaves the row for the cleanup", async () => {
const endsOn = at(tz, 15, 12);
const base = {
intervalDays: null,
rrule: "FREQ=DAILY;BYHOUR=7,19",
anchorDate: at(tz, 14, 0),
notifyHour: 7,
lastSatisfiedAt: null,
createdAt: at(tz, 13, 12),
endsOn,
};
const row: Record<string, unknown> = {
id: "r-bp-course",
userId: "u1",
origin: "COACH",
measurementType: null,
label: "BP",
location: null,
lastSkippedAt: null,
lastNotifiedAt: null,
enabled: true,
deletedAt: null,
...base,
nextDueAt: computeReminderNextDueAt(base, tz, at(tz, 14, 0)),
user: { id: "u1", timezone: tz, locale: "en" },
};
const prisma = {
measurementReminder: {
findMany: vi.fn(async () =>
(row.nextDueAt as Date | null) === null ? [] : [row],
),
update: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
Object.assign(row, data);
return row;
}),
updateMany: vi.fn(async () => ({ count: 0 })),
},
measurement: { findFirst: vi.fn(async () => null) },
labResult: { findFirst: vi.fn(async () => null) },
};
const sent: string[] = [];
const dispatch = vi.fn(async () => ({
dispatched: true,
channelsAttempted: 1,
channelsSucceeded: 1,
}));
let last = at(tz, 14, 0);
for (
let t = at(tz, 14, 0).getTime();
t < at(tz, 18, 0).getTime();
t += 15 * 60_000
) {
const now = new Date(t);
last = now;
const before = dispatch.mock.calls.length;
await runMeasurementReminderTick(prisma as never, now, {
dispatch: dispatch as never,
isModuleEnabled: async () => true,
});
if (dispatch.mock.calls.length > before) {
const p = wallClockInTz(now, tz);
sent.push(
`${p.day} ${String(p.hour).padStart(2, "0")}:${String(p.minute).padStart(2, "0")}`,
);
}
}
expect(sent).toEqual(["14 07:00", "14 19:00", "15 07:00", "15 19:00"]);
// The course rolled off its end: no next slot, which is exactly the
// shape the expired-course cleanup tombstones on the following tick.
expect(row.nextDueAt).toBeNull();
expect((row.endsOn as Date).getTime()).toBeLessThan(last.getTime());
const cleanup = prisma.measurementReminder.updateMany.mock.calls.find(
(call: unknown[]) => JSON.stringify(call[0]).includes('"origin":"COACH"'),
);
expect(cleanup).toBeDefined();
});
});
+2
View File
@@ -704,6 +704,7 @@ type RollableReminder = {
notifyHour: number;
lastSatisfiedAt: Date | null;
createdAt: Date;
endsOn: Date | null;
};
/**
@@ -733,6 +734,7 @@ function nextSlotAfterReminder(
notifyHour: reminder.notifyHour,
lastSatisfiedAt: rolling ? now : reminder.lastSatisfiedAt,
createdAt: reminder.createdAt,
endsOn: reminder.endsOn,
};
return computeReminderNextDueAt(scheduleInput, timezone, now);
}
@@ -66,6 +66,7 @@ function reminder(
lastSatisfiedAt: null,
lastSkippedAt: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
endsOn: null,
...overrides,
};
}
@@ -255,6 +256,59 @@ describe("satisfyReminder", () => {
});
});
describe("course window", () => {
// A twice-daily course whose last day is 2026-06-15 (Berlin). Satisfying
// or skipping the final evening slot must end the course, not roll it on
// to an open-ended 2026-06-16 morning.
const course = {
intervalDays: null,
rrule: "FREQ=DAILY;BYHOUR=7,19",
notifyHour: 7,
createdAt: new Date("2026-06-13T10:00:00Z"),
endsOn: new Date("2026-06-15T10:00:00Z"),
};
const lastEvening = new Date("2026-06-15T17:30:00Z");
it("satisfy past the last slot leaves no next slot", async () => {
const { prisma, updates } = makePrisma();
const result = await satisfyReminder(
prisma as never,
reminder(course),
TZ,
lastEvening,
"manual",
);
expect(result.satisfied).toBe(true);
expect(result.nextDueAt).toBeNull();
expect(updates[0].data.nextDueAt).toBeNull();
});
it("skip past the last slot leaves no next slot", async () => {
const { prisma, updates } = makePrisma();
const result = await skipReminder(
prisma as never,
reminder(course),
TZ,
lastEvening,
);
expect(result.skipped).toBe(true);
expect(result.nextDueAt).toBeNull();
expect(updates[0].data.nextDueAt).toBeNull();
});
it("still rolls within the window", async () => {
const { prisma } = makePrisma();
const result = await satisfyReminder(
prisma as never,
reminder(course),
TZ,
new Date("2026-06-15T05:30:00Z"),
"manual",
);
expect(result.nextDueAt).toEqual(new Date("2026-06-15T17:00:00Z"));
});
});
describe("skipReminder", () => {
it("stamps lastSkippedAt, increments skipCount, clears snooze — and never touches lastSatisfiedAt", async () => {
const { prisma, updates } = makePrisma();
+4
View File
@@ -77,6 +77,8 @@ export interface SatisfiableReminder {
lastSatisfiedAt: Date | null;
lastSkippedAt: Date | null;
createdAt: Date;
/** Course-window end; without it a finite course reschedules forever. */
endsOn: Date | null;
}
export interface SatisfyResult {
@@ -151,6 +153,7 @@ export async function satisfyReminder(
notifyHour: reminder.notifyHour,
lastSatisfiedAt: anchorAt,
createdAt: reminder.createdAt,
endsOn: reminder.endsOn,
};
const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, anchorAt);
@@ -239,6 +242,7 @@ export async function skipReminder(
notifyHour: reminder.notifyHour,
lastSatisfiedAt: skippedAt,
createdAt: reminder.createdAt,
endsOn: reminder.endsOn,
};
const nextDueAt = computeReminderNextDueAt(
scheduleInput,
+1
View File
@@ -877,6 +877,7 @@ export async function handleCallback(update: TelegramUpdate) {
lastSatisfiedAt: true,
lastSkippedAt: true,
createdAt: true,
endsOn: true,
},
});
if (!reminder) {
+2
View File
@@ -121,6 +121,7 @@ export async function satisfyBoostersForDose(
lastSatisfiedAt: true,
lastSkippedAt: true,
createdAt: true,
endsOn: true,
},
});
if (reminders.length === 0) return EMPTY;
@@ -148,6 +149,7 @@ export async function satisfyBoostersForDose(
lastSatisfiedAt: reminder.lastSatisfiedAt,
lastSkippedAt: reminder.lastSkippedAt,
createdAt: reminder.createdAt,
endsOn: reminder.endsOn,
};
// `satisfyReminder` types its client as the full `PrismaClient` because
// every other caller hands it one; the only member it touches is