From 5093f96e8ac790d23fb2548a325de8749cddb760 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Mon, 9 Feb 2026 20:58:08 +0100 Subject: [PATCH] fix: intake reminder catch-up for missed advance notification window (#148) When the scheduler missed the exact notification minute (due to system sleep, high load, or GC pauses), the advance reminder was permanently lost. A dead zone existed between the notify time and the intake time where neither advance nor missed-intake logic would trigger. Changes: - getUpcomingIntakes now catches up intakes where the notify window passed but the intake time is still in the future - Seeding logic sends a catch-up notification for recently missed intakes (within grace period) instead of silently seeding state - Added 4 tests covering catch-up scenarios --- .../src/services/intake-reminder-scheduler.ts | 36 ++++++++----- backend/src/test/services.test.ts | 50 +++++++++++++++++++ backend/src/utils/scheduler-utils.ts | 16 +++++- 3 files changed, 87 insertions(+), 15 deletions(-) diff --git a/backend/src/services/intake-reminder-scheduler.ts b/backend/src/services/intake-reminder-scheduler.ts index d61d848..d46a24b 100644 --- a/backend/src/services/intake-reminder-scheduler.ts +++ b/backend/src/services/intake-reminder-scheduler.ts @@ -416,19 +416,29 @@ async function checkAndSendIntakeRemindersForUser( if (!existingEntry) { // New dose - send first reminder if (isIntakePast) { - // Intake time already passed and we have no state entry — this means the scheduler - // was not aware of this intake before it happened (e.g., user just enabled reminders). - // Seed the state as already handled so repeat reminders can track from here, - // but do NOT send a notification for intakes that were missed before tracking started. - state.reminders[key] = { - firstSentAt: nowMs, - lastSentAt: nowMs, - sendCount: 0, - advanceSent: false, - }; - logger.debug( - `[IntakeReminder] User ${settings.userId}: Seeding state for past "${intake.medName}" at ${intake.intakeTimeStr} (no notification — first detection)` - ); + // Intake time already passed and we have no state entry. Check how recently it was missed. + const minutesSinceIntake = (nowMs - intakeTimeMs) / 60000; + const gracePeriodMinutes = (settings.reminderRepeatIntervalMinutes ?? 30) + REMINDER_MINUTES_BEFORE; + + if (minutesSinceIntake <= gracePeriodMinutes) { + // Recently missed — scheduler likely recovered from sleep/restart. + // Send a catch-up reminder (counts as first nagging reminder). + remindersToSend.push({ ...intake, currentSendCount: 1, maxReminders, isAdvanceReminder: false }); + logger.info( + `[IntakeReminder] User ${settings.userId}: Catch-up reminder for recently missed "${intake.medName}" at ${intake.intakeTimeStr} (${Math.round(minutesSinceIntake)} min ago)` + ); + } else { + // Long ago — seed state without notification (user likely already noticed) + state.reminders[key] = { + firstSentAt: nowMs, + lastSentAt: nowMs, + sendCount: 0, + advanceSent: false, + }; + logger.debug( + `[IntakeReminder] User ${settings.userId}: Seeding state for old past "${intake.medName}" at ${intake.intakeTimeStr} (no notification — ${Math.round(minutesSinceIntake)} min ago)` + ); + } } else { // Upcoming - this is advance reminder (no counter) remindersToSend.push({ ...intake, currentSendCount: 0, maxReminders, isAdvanceReminder: true }); diff --git a/backend/src/test/services.test.ts b/backend/src/test/services.test.ts index c6e0f56..cbe11c1 100644 --- a/backend/src/test/services.test.ts +++ b/backend/src/test/services.test.ts @@ -388,6 +388,56 @@ describe("Scheduler Utils - Upcoming Intakes", () => { // Both should be found as they're within the window expect(result.length).toBeGreaterThanOrEqual(1); }); + + it("should catch up missed advance reminder when notify window passed but intake still future", () => { + // Intake at 15:57, reminder 15 min before = 15:42 + // Scheduler was down at 15:42, now running at 15:50 (intake still in future) + const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T15:57:00" })]; + // "now" = 15:50 local time on the same day — past the 15:42 notify window, but before 15:57 intake + const now = new Date(2025, 0, 1, 15, 50, 0).getTime(); + + const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now); + + // Should still return the intake as a catch-up advance reminder + expect(result).toHaveLength(1); + expect(result[0].medName).toBe("TestMed"); + expect(result[0].usage).toBe(1); + }); + + it("should catch up missed advance reminder even 1 minute before intake", () => { + // Intake at 08:00, reminder at 07:45. Scheduler catches up at 07:59. + const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T08:00:00" })]; + const now = new Date(2025, 0, 1, 7, 59, 30).getTime(); + + const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now); + + expect(result).toHaveLength(1); + }); + + it("should not catch up for intakes already in the past", () => { + // Intake at 08:00, reminder at 07:45. Now = 08:05 (intake already past). + const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T08:00:00" })]; + const now = new Date(2025, 0, 1, 8, 5, 0).getTime(); + + const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now); + + // Should NOT return — intake is past, handled by getTodaysIntakes instead + expect(result).toHaveLength(0); + }); + + it("should catch up for recurring intake on later day", () => { + // Intake started Jan 1 at 10:00, every 1 day. Now = Jan 3 at 09:50 (past notify, before intake) + const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T10:00:00" })]; + const now = new Date(2025, 0, 3, 9, 50, 0).getTime(); + + const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now); + + // Should return today's occurrence via catch-up + expect(result).toHaveLength(1); + // The intake time should be Jan 3 at 10:00 + expect(result[0].intakeTime.getHours()).toBe(10); + expect(result[0].intakeTime.getDate()).toBe(3); + }); }); describe("getTodaysIntakes", () => { diff --git a/backend/src/utils/scheduler-utils.ts b/backend/src/utils/scheduler-utils.ts index 217e0e2..57cfa78 100644 --- a/backend/src/utils/scheduler-utils.ts +++ b/backend/src/utils/scheduler-utils.ts @@ -432,6 +432,11 @@ export function getUpcomingIntakes( const currentNotifyTime = currentOccurrence - minutesBefore * 60 * 1000; if (currentNotifyTime >= currentMinuteStart && currentOccurrence > now) { nextTime = currentOccurrence; + } else if (currentNotifyTime < currentMinuteStart && currentOccurrence > now) { + // CATCH-UP: The notify window was missed (e.g. due to system sleep/restart) + // but the intake time is still in the future — include it so the advance + // reminder can still be sent rather than falling into a dead zone. + nextTime = currentOccurrence; } else { nextTime = nextOccurrence; } @@ -440,8 +445,15 @@ export function getUpcomingIntakes( // Calculate when we should notify for this intake const notifyTime = nextTime - minutesBefore * 60 * 1000; - // Check if notifyTime falls within the current minute (precise matching) - if (notifyTime >= currentMinuteStart && notifyTime < currentMinuteEnd) { + // Match if: + // 1. notifyTime falls within the current minute (normal case), OR + // 2. notifyTime is in the past but intakeTime is still in the future (catch-up + // for missed advance reminder window — e.g. scheduler was down during the + // exact notification minute due to system sleep, restart, or heavy load) + const isInCurrentMinute = notifyTime >= currentMinuteStart && notifyTime < currentMinuteEnd; + const isMissedButStillUpcoming = notifyTime < currentMinuteStart && nextTime > now; + + if (isInCurrentMinute || isMissedButStillUpcoming) { const intakeDate = new Date(nextTime); upcoming.push({ medName,