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
This commit is contained in:
Daniel Volz
2026-02-09 20:58:08 +01:00
committed by GitHub
parent bd6eccdb22
commit 5093f96e8a
3 changed files with 87 additions and 15 deletions
@@ -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 });
+50
View File
@@ -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", () => {
+14 -2
View File
@@ -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,