diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bc45ec45..88ab221ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/lib/encounters/service.ts b/src/lib/encounters/service.ts index e9d30108e..a6767c94d 100644 --- a/src/lib/encounters/service.ts +++ b/src/lib/encounters/service.ts @@ -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, }; } diff --git a/src/lib/jobs/__tests__/measurement-reminder-byhour.test.ts b/src/lib/jobs/__tests__/measurement-reminder-byhour.test.ts index 511d06ff9..bb4dee432 100644 --- a/src/lib/jobs/__tests__/measurement-reminder-byhour.test.ts +++ b/src/lib/jobs/__tests__/measurement-reminder-byhour.test.ts @@ -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 = { + 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 }) => { + 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(); + }); }); diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index 05a8f14d9..cc3670df1 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -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); } diff --git a/src/lib/measurement-reminders/__tests__/satisfy.test.ts b/src/lib/measurement-reminders/__tests__/satisfy.test.ts index a557dab13..323f77572 100644 --- a/src/lib/measurement-reminders/__tests__/satisfy.test.ts +++ b/src/lib/measurement-reminders/__tests__/satisfy.test.ts @@ -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(); diff --git a/src/lib/measurement-reminders/satisfy.ts b/src/lib/measurement-reminders/satisfy.ts index b167bc025..ec19854d2 100644 --- a/src/lib/measurement-reminders/satisfy.ts +++ b/src/lib/measurement-reminders/satisfy.ts @@ -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, diff --git a/src/lib/telegram-webhook-handlers.ts b/src/lib/telegram-webhook-handlers.ts index 13cb85c13..6f7242d7a 100644 --- a/src/lib/telegram-webhook-handlers.ts +++ b/src/lib/telegram-webhook-handlers.ts @@ -877,6 +877,7 @@ export async function handleCallback(update: TelegramUpdate) { lastSatisfiedAt: true, lastSkippedAt: true, createdAt: true, + endsOn: true, }, }); if (!reminder) { diff --git a/src/lib/vaccinations/satisfy-matching.ts b/src/lib/vaccinations/satisfy-matching.ts index 0b4d89977..4febaa950 100644 --- a/src/lib/vaccinations/satisfy-matching.ts +++ b/src/lib/vaccinations/satisfy-matching.ts @@ -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