From 3d5526875cabc3f9ab4ea1f6f3f9aee1d0b847a3 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Thu, 25 Dec 2025 09:10:41 +0100 Subject: [PATCH] feat: enhance reminder system with notification type and channel tracking --- backend/src/routes/settings.ts | 2 ++ .../src/services/intake-reminder-scheduler.ts | 11 +++++-- backend/src/services/reminder-scheduler.ts | 13 ++++++-- frontend/src/App.tsx | 33 ++++++++++++++++--- frontend/src/i18n/de.json | 7 +++- frontend/src/i18n/en.json | 7 +++- 6 files changed, 62 insertions(+), 11 deletions(-) diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 327aae4..0312163 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -140,6 +140,8 @@ export async function settingsRoutes(app: FastifyInstance) { // Reminder state lastAutoEmailSent: reminderState.lastAutoEmailSent, nextScheduledCheck: reminderState.nextScheduledCheck, + lastNotificationType: reminderState.lastNotificationType, + lastNotificationChannel: reminderState.lastNotificationChannel, }); }); diff --git a/backend/src/services/intake-reminder-scheduler.ts b/backend/src/services/intake-reminder-scheduler.ts index 790c7b5..8e8d9f1 100644 --- a/backend/src/services/intake-reminder-scheduler.ts +++ b/backend/src/services/intake-reminder-scheduler.ts @@ -5,6 +5,7 @@ import { readFileSync, writeFileSync, existsSync } from "fs"; import { resolve } from "path"; import { loadNotificationSettings, sendShoutrrrNotification } from "../routes/settings.js"; import { getTranslations, t, getDateLocale, type Language } from "../i18n/translations.js"; +import { getReminderState, updateReminderSentTime } from "./reminder-scheduler.js"; type Slice = { usage: number; every: number; start: string }; @@ -65,10 +66,10 @@ type UpcomingIntake = { function getUpcomingIntakes(medName: string, slices: Slice[], minutesBefore: number): UpcomingIntake[] { const now = Date.now(); - // Window looks 2 minutes into past and (minutesBefore + 1) minutes into future - // This ensures we don't miss reminders due to timing jitter + // Window to detect if "now" is the right time to send reminder + // We check if the notify time (intake - 15min) falls within current minute ±1 const windowStart = now - 2 * 60 * 1000; // 2 minutes ago (catch slightly late checks) - const windowEnd = now + (minutesBefore + 1) * 60 * 1000; // minutesBefore + 1 minute from now + const windowEnd = now + 1 * 60 * 1000; // 1 minute from now const upcoming: UpcomingIntake[] = []; @@ -312,6 +313,10 @@ async function checkAndSendIntakeReminders(logger: { info: (msg: string) => void saveIntakeReminderState({ sentReminders: [...cleanedReminders, ...newKeys], }); + + // Update global reminder state for UI display + const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push"; + updateReminderSentTime("intake", channel); } } diff --git a/backend/src/services/reminder-scheduler.ts b/backend/src/services/reminder-scheduler.ts index d49ba11..dc6f87f 100644 --- a/backend/src/services/reminder-scheduler.ts +++ b/backend/src/services/reminder-scheduler.ts @@ -32,6 +32,8 @@ type ReminderState = { 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 + lastNotificationType: "stock" | "intake" | null; // Type of last notification + lastNotificationChannel: "email" | "push" | "both" | null; // Channel used for last notification }; const REMINDER_HOUR = 6; // 6:00 AM local time @@ -158,12 +160,14 @@ function loadReminderState(): ReminderState { lastAutoEmailDate: saved.lastAutoEmailDate ?? null, notifiedMedications: saved.notifiedMedications ?? [], nextScheduledCheck: saved.nextScheduledCheck ?? null, + lastNotificationType: saved.lastNotificationType ?? null, + lastNotificationChannel: saved.lastNotificationChannel ?? null, }; } } catch { // ignore } - return { lastAutoEmailSent: null, lastAutoEmailDate: null, notifiedMedications: [], nextScheduledCheck: null }; + return { lastAutoEmailSent: null, lastAutoEmailDate: null, notifiedMedications: [], nextScheduledCheck: null, lastNotificationType: null, lastNotificationChannel: null }; } function saveReminderState(state: ReminderState): void { @@ -174,13 +178,15 @@ export function getReminderState(): ReminderState { return loadReminderState(); } -export function updateReminderSentTime(): void { +export function updateReminderSentTime(type: "stock" | "intake" = "stock", channel: "email" | "push" | "both" = "email"): void { const state = loadReminderState(); const today = getTodayInTimezone(); saveReminderState({ ...state, lastAutoEmailSent: new Date().toISOString(), lastAutoEmailDate: today, + lastNotificationType: type, + lastNotificationChannel: channel, }); } @@ -451,11 +457,14 @@ async function checkAndSendReminder(logger: { info: (msg: string) => void; error // Update state if any notification was sent successfully if (emailSuccess || shoutrrrSuccess) { const currentState = loadReminderState(); + const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push"; saveReminderState({ lastAutoEmailSent: new Date().toISOString(), lastAutoEmailDate: today, notifiedMedications: [...new Set([...stillLowStock, ...medsToNotify.map((m) => m.name)])], nextScheduledCheck: currentState.nextScheduledCheck, + lastNotificationType: "stock", + lastNotificationChannel: channel, }); } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e747d98..a7877fa 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -126,6 +126,8 @@ export default function App() { hasSmtpPassword: false, lastAutoEmailSent: null as string | null, nextScheduledCheck: null as string | null, + lastNotificationType: null as "stock" | "intake" | null, + lastNotificationChannel: null as "email" | "push" | "both" | null, // Shoutrrr/ntfy settings shoutrrrEnabled: false, shoutrrrUrl: "", @@ -602,7 +604,7 @@ export default function App() {
{settings.emailEnabled && settings.shoutrrrEnabled ? "🔔" : settings.emailEnabled ? "📧" : "🔔"} - {t('dashboard.reminders.active')} — {getReminderStatusText(settings.reminderDaysBefore, coverage.low, settings.lastAutoEmailSent, t, i18n.language)} + {t('dashboard.reminders.active')} — {getReminderStatusText(settings.reminderDaysBefore, coverage.low, settings.lastAutoEmailSent, settings.lastNotificationType, settings.lastNotificationChannel, t, i18n.language)} {settings.emailEnabled && settings.notificationEmail && → {settings.notificationEmail}}
@@ -1768,7 +1770,15 @@ function calculateCoverage(meds: Medication[], events: Array<{ medName: string; return { low, all: coverage }; } -function getReminderStatusText(reminderDaysBefore: number, lowStock: Coverage[], lastSent: string | null, t: (key: string, options?: Record) => string, locale: string): React.ReactNode { +function getReminderStatusText( + reminderDaysBefore: number, + lowStock: Coverage[], + lastSent: string | null, + lastType: "stock" | "intake" | null, + lastChannel: "email" | "push" | "both" | null, + t: (key: string, options?: Record) => string, + locale: string +): React.ReactNode { // Find the earliest medication that needs a reminder (based on reminderDaysBefore) const medsNeedingReminder = lowStock .filter((c) => c.depletionTime !== null && c.daysLeft !== null && c.daysLeft <= reminderDaysBefore) @@ -1779,13 +1789,28 @@ function getReminderStatusText(reminderDaysBefore: number, lowStock: Coverage[], return date.toLocaleDateString(locale, { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" }); }; + const getTypeLabel = () => lastType === "intake" ? t('dashboard.reminders.typeIntake') : t('dashboard.reminders.typeStock'); + const getChannelLabel = () => { + if (lastChannel === "both") return t('dashboard.reminders.channelBoth'); + if (lastChannel === "push") return t('dashboard.reminders.channelPush'); + return t('dashboard.reminders.channelEmail'); + }; + + const formatLastInfo = (iso: string) => { + const dateStr = formatLastSent(iso); + if (lastType && lastChannel) { + return `${dateStr} (${getTypeLabel()}, ${getChannelLabel()})`; + } + return dateStr; + }; + if (medsNeedingReminder.length > 0) { // There are medications that need reminders if (lastSent) { return ( <> ⚠ {t('dashboard.reminders.needReorder', { count: medsNeedingReminder.length })} - {" · "}{t('dashboard.reminders.lastReminder')}: {formatLastSent(lastSent)} + {" · "}{t('dashboard.reminders.lastReminder')}: {formatLastInfo(lastSent)} ); } @@ -1815,7 +1840,7 @@ function getReminderStatusText(reminderDaysBefore: number, lowStock: Coverage[], return ( <> ✓ {t('dashboard.reminders.allStockOk')} - {" · "}{t('dashboard.reminders.lastReminder')}: {formatLastSent(lastSent)} + {" · "}{t('dashboard.reminders.lastReminder')}: {formatLastInfo(lastSent)} ); } diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index d0d3a88..30f52eb 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -43,7 +43,12 @@ "noRemindersNeeded": "keine Erinnerungen nötig", "needReorder": "{{count}} Medikament nachbestellen", "needReorder_other": "{{count}} Medikamente nachbestellen", - "waitingFirstCheck": "warte auf erste Prüfung" + "waitingFirstCheck": "warte auf erste Prüfung", + "typeStock": "Bestand", + "typeIntake": "Einnahme", + "channelEmail": "E-Mail", + "channelPush": "Push", + "channelBoth": "E-Mail + Push" } }, "table": { diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index f5d4859..c7edb82 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -43,7 +43,12 @@ "noRemindersNeeded": "no reminders needed", "needReorder": "{{count}} med needs reorder", "needReorder_other": "{{count}} meds need reorder", - "waitingFirstCheck": "waiting for first check" + "waitingFirstCheck": "waiting for first check", + "typeStock": "Stock", + "typeIntake": "Intake", + "channelEmail": "Email", + "channelPush": "Push", + "channelBoth": "Email + Push" } }, "table": {