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

+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