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": {