From 4c351aae2dde214c4e17462d6d801e617b202280 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Sat, 20 Dec 2025 19:55:03 +0100 Subject: [PATCH] feat: add next scheduled check to reminder state and update UI to display next check time --- backend/src/routes/settings.ts | 1 + backend/src/services/reminder-scheduler.ts | 77 ++++++++++++++++++---- frontend/src/App.tsx | 14 ++-- 3 files changed, 74 insertions(+), 18 deletions(-) diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index e4f1299..bd1f286 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -80,6 +80,7 @@ export async function settingsRoutes(app: FastifyInstance) { hasSmtpPassword: !!process.env.SMTP_PASS, // Reminder state lastAutoEmailSent: reminderState.lastAutoEmailSent, + nextScheduledCheck: reminderState.nextScheduledCheck, }); }); diff --git a/backend/src/services/reminder-scheduler.ts b/backend/src/services/reminder-scheduler.ts index a6c5da5..73a009b 100644 --- a/backend/src/services/reminder-scheduler.ts +++ b/backend/src/services/reminder-scheduler.ts @@ -20,8 +20,29 @@ type ReminderState = { lastAutoEmailSent: string | null; // ISO date string lastAutoEmailDate: string | null; // YYYY-MM-DD - to track if we already sent today notifiedMedications: string[]; // List of medication names that have been notified (cleared when restocked) + nextScheduledCheck: string | null; // ISO date string for when the next check is scheduled }; +const REMINDER_HOUR = 6; // 6:00 AM + +function getNextScheduledTime(): Date { + const now = new Date(); + const next = new Date(now); + next.setHours(REMINDER_HOUR, 0, 0, 0); + + // If we're past today's scheduled time, schedule for tomorrow + if (now >= next) { + next.setDate(next.getDate() + 1); + } + + return next; +} + +function getMsUntilNextCheck(): number { + const next = getNextScheduledTime(); + return next.getTime() - Date.now(); +} + const notificationSettingsFile = resolve(process.cwd(), "data", "notification-settings.json"); const reminderStateFile = resolve(process.cwd(), "data", "reminder-state.json"); @@ -53,12 +74,13 @@ function loadReminderState(): ReminderState { lastAutoEmailSent: saved.lastAutoEmailSent ?? null, lastAutoEmailDate: saved.lastAutoEmailDate ?? null, notifiedMedications: saved.notifiedMedications ?? [], + nextScheduledCheck: saved.nextScheduledCheck ?? null, }; } } catch { // ignore } - return { lastAutoEmailSent: null, lastAutoEmailDate: null, notifiedMedications: [] }; + return { lastAutoEmailSent: null, lastAutoEmailDate: null, notifiedMedications: [], nextScheduledCheck: null }; } function saveReminderState(state: ReminderState): void { @@ -301,11 +323,13 @@ async function checkAndSendReminder(logger: { info: (msg: string) => void; error const result = await sendReminderEmail(settings.notificationEmail, medsToNotify); if (result.success) { - // Update state + // Update state (preserve nextScheduledCheck) + const currentState = loadReminderState(); saveReminderState({ lastAutoEmailSent: new Date().toISOString(), lastAutoEmailDate: today, notifiedMedications: [...new Set([...stillLowStock, ...medsToNotify.map((m) => m.name)])], + nextScheduledCheck: currentState.nextScheduledCheck, }); logger.info(`[Reminder] Email sent successfully to ${settings.notificationEmail}`); } else { @@ -313,24 +337,53 @@ async function checkAndSendReminder(logger: { info: (msg: string) => void; error } } -let schedulerInterval: NodeJS.Timeout | null = null; +let schedulerTimeout: NodeJS.Timeout | null = null; + +function scheduleNextCheck(logger: { info: (msg: string) => void; error: (msg: string) => void }): void { + const msUntilNext = getMsUntilNextCheck(); + const nextTime = getNextScheduledTime(); + + // Save next scheduled time to state + const state = loadReminderState(); + saveReminderState({ + ...state, + nextScheduledCheck: nextTime.toISOString(), + }); + + logger.info(`[Reminder] Next check scheduled for ${nextTime.toLocaleString()} (in ${Math.round(msUntilNext / 1000 / 60)} minutes)`); + + schedulerTimeout = setTimeout(() => { + checkAndSendReminder(logger).catch((err) => logger.error(`[Reminder] Error: ${err}`)); + // Schedule the next check after this one completes + scheduleNextCheck(logger); + }, msUntilNext); +} export function startReminderScheduler(logger: { info: (msg: string) => void; error: (msg: string) => void }): void { - // Run check immediately on startup logger.info("[Reminder] Starting reminder scheduler..."); - checkAndSendReminder(logger).catch((err) => logger.error(`[Reminder] Error: ${err}`)); - // Then run every hour to check (will only send once per day) - schedulerInterval = setInterval(() => { + // Check if we need to run immediately (missed today's check) + const state = loadReminderState(); + const today = new Date().toISOString().split("T")[0]; + const now = new Date(); + const todayAt6AM = new Date(now); + todayAt6AM.setHours(REMINDER_HOUR, 0, 0, 0); + + // If it's past 6 AM today and we haven't checked today, run immediately + if (now >= todayAt6AM && state.lastAutoEmailDate !== today) { + logger.info("[Reminder] Missed today's check, running now..."); checkAndSendReminder(logger).catch((err) => logger.error(`[Reminder] Error: ${err}`)); - }, 60 * 60 * 1000); // Every hour + } - logger.info("[Reminder] Scheduler started - checking hourly, sending max once per day"); + // Schedule next check at 6 AM + scheduleNextCheck(logger); + + logger.info(`[Reminder] Scheduler started - daily check at ${REMINDER_HOUR}:00`); } export function stopReminderScheduler(): void { - if (schedulerInterval) { - clearInterval(schedulerInterval); - schedulerInterval = null; + if (schedulerTimeout) { + clearTimeout(schedulerTimeout); + schedulerTimeout = null; } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7f0a171..908abc2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -93,6 +93,7 @@ export default function App() { smtpSecure: false, hasSmtpPassword: false, lastAutoEmailSent: null as string | null, + nextScheduledCheck: null as string | null, }); const [savedSettings, setSavedSettings] = useState(settings); const [settingsLoading, setSettingsLoading] = useState(false); @@ -721,7 +722,7 @@ export default function App() { {settings.emailEnabled && ( <>
-

🤖 How it works: The server checks hourly. When a medication drops below the threshold, you get an email.

+

🤖 How it works: The server checks daily at 6:00 AM. When a medication drops below the threshold, you get an email.

- {settings.lastAutoEmailSent && ( -
-

✓ Last automatic email: {new Date(settings.lastAutoEmailSent).toLocaleString()}

-
- )} +
+

Next automatic check: {settings.nextScheduledCheck ? new Date(settings.nextScheduledCheck).toLocaleString() : "—"}

+ {settings.lastAutoEmailSent && ( +

✓ Last automatic email: {new Date(settings.lastAutoEmailSent).toLocaleString()}

+ )} +
)}