diff --git a/.env.example b/.env.example index d85bd88..3ec2a9c 100644 --- a/.env.example +++ b/.env.example @@ -31,5 +31,10 @@ SMTP_SECURE=false # Rate limits EMAILS_PER_DAY=3 -# Default value only - frontend settings (stored in settings.json) take precedence -REMINDER_DAYS_BEFORE=7 \ No newline at end of file +# Admin settings default value only - frontend settings (stored in settings.json) take precedence +REMINDER_DAYS_BEFORE=7 + +# Admin settings (not editable in UI) +REMINDER_HOUR=6 # 24h format (0-23), e.g. 6 = 6:00 AM, 18 = 6:00 PM +REMINDER_MINUTES_BEFORE=15 # Minutes before intake to send reminder +EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning \ No newline at end of file diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index ec0420e..5458a43 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -142,6 +142,8 @@ export async function settingsRoutes(app: FastifyInstance) { nextScheduledCheck: reminderState.nextScheduledCheck, lastNotificationType: reminderState.lastNotificationType, lastNotificationChannel: reminderState.lastNotificationChannel, + // Admin settings (from .env, read-only) + expiryWarningDays: parseInt(process.env.EXPIRY_WARNING_DAYS ?? "30", 10), }); }); diff --git a/backend/src/services/intake-reminder-scheduler.ts b/backend/src/services/intake-reminder-scheduler.ts index b10db69..9889f40 100644 --- a/backend/src/services/intake-reminder-scheduler.ts +++ b/backend/src/services/intake-reminder-scheduler.ts @@ -13,7 +13,7 @@ type IntakeReminderState = { sentReminders: string[]; // Array of "medName:timestamp" to track sent reminders }; -const REMINDER_MINUTES_BEFORE = 15; +const REMINDER_MINUTES_BEFORE = parseInt(process.env.REMINDER_MINUTES_BEFORE ?? "15", 10); const CHECK_INTERVAL_MS = 60 * 1000; // Check every 1 minute // Get current timezone from TZ env variable or default to UTC diff --git a/backend/src/services/reminder-scheduler.ts b/backend/src/services/reminder-scheduler.ts index 2299ca2..d00945a 100644 --- a/backend/src/services/reminder-scheduler.ts +++ b/backend/src/services/reminder-scheduler.ts @@ -36,7 +36,7 @@ type ReminderState = { lastNotificationChannel: "email" | "push" | "both" | null; // Channel used for last notification }; -const REMINDER_HOUR = 6; // 6:00 AM local time +const REMINDER_HOUR = parseInt(process.env.REMINDER_HOUR ?? "6", 10); // Default 6:00 AM local time // Get current timezone from TZ env variable or default to UTC function getTimezone(): string { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 67393bd..6b68aa0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -136,6 +136,8 @@ export default function App() { emailIntakeReminders: true, shoutrrrStockReminders: true, shoutrrrIntakeReminders: true, + // Admin settings (from .env, read-only) + expiryWarningDays: 30, }); const [savedSettings, setSavedSettings] = useState(settings); const [settingsLoading, setSettingsLoading] = useState(false); @@ -224,7 +226,7 @@ export default function App() { const schedule = useMemo(() => buildSchedulePreview(meds, i18n.language), [meds, i18n.language]); const totalTablets = useMemo(() => deriveTotal(form), [form]); - const coverage = useMemo(() => calculateCoverage(meds, schedule.events, i18n.language), [meds, schedule.events, i18n.language]); + const coverage = useMemo(() => calculateCoverage(meds, schedule.events, i18n.language, settings.reminderDaysBefore), [meds, schedule.events, i18n.language, settings.reminderDaysBefore]); const depletionByMed = useMemo(() => Object.fromEntries(coverage.all.map((c) => [c.name, c.depletionTime])), [coverage.all]); const coverageByMed = useMemo(() => Object.fromEntries(coverage.all.map((c) => [c.name, c])), [coverage.all]); const groupedSchedule = useMemo(() => { @@ -615,13 +617,32 @@ export default function App() {

{t('dashboard.reorder.title')}

{t('dashboard.reorder.badge')} - {meds.length === 0 ? ( -

{t('dashboard.reorder.noMeds')}

- ) : coverage.low.length === 0 ? ( -

{t('dashboard.reorder.allGood')}

- ) : ( - <> -
+ {(() => { + if (meds.length === 0) { + return

{t('dashboard.reorder.noMeds')}

; + } + + // Count medications with "Low" stock status (based on lowStockDays setting) + const lowStockCount = coverage.all.filter(c => { + if (c.medsLeft <= 0) return true; // out of stock + if (c.daysLeft === null) return false; // no schedule + return c.daysLeft < settings.lowStockDays; + }).length; + + if (coverage.low.length === 0) { + // No critical meds (≤3 days) + if (lowStockCount === 0) { + // All good - everything is Normal or High + return

{t('dashboard.reorder.allGood')}

; + } else { + // Some meds are Low but not critical + return

{t('dashboard.reorder.lowWarning', { count: lowStockCount })}

; + } + } + + return ( + <> +
{t('table.name')} {t('table.currentPills')} @@ -659,7 +680,8 @@ export default function App() {
)} - )} + ); + })()} @@ -681,7 +703,7 @@ export default function App() { {coverage.all.map((row) => { const status = getStockStatus(row.daysLeft, row.medsLeft, settings); const med = meds.find(m => m.name === row.name); - const expiryClass = getExpiryClass(med?.expiryDate); + const expiryClass = getExpiryClass(med?.expiryDate, settings.expiryWarningDays); const textClass = status.className === "danger" ? "danger-text" : status.className === "warning" ? "warning-text" : ""; return (
med && setSelectedMed(med)}> @@ -1722,7 +1744,7 @@ function formatNumber(value: number | null) { return value.toFixed(1); } -function getExpiryClass(expiryDate: string | null | undefined): string { +function getExpiryClass(expiryDate: string | null | undefined, expiryWarningDays: number = 30): string { if (!expiryDate) return ""; const now = new Date(); const expiry = new Date(expiryDate); @@ -1730,11 +1752,11 @@ function getExpiryClass(expiryDate: string | null | undefined): string { const diffDays = diffMs / (1000 * 60 * 60 * 24); if (diffDays <= 7) return "danger-text"; // 1 week or less (or expired) - if (diffDays <= 30) return "warning-text"; // 1 month or less - return "success-text"; // more than 1 month + if (diffDays <= expiryWarningDays) return "warning-text"; // within warning period + return "success-text"; // outside warning period } -function calculateCoverage(meds: Medication[], events: Array<{ medName: string; when: number }>, locale: string) { +function calculateCoverage(meds: Medication[], events: Array<{ medName: string; when: number }>, locale: string, reminderDaysBefore: number) { const MS_PER_DAY = 86_400_000; const now = Date.now(); @@ -1767,7 +1789,7 @@ function calculateCoverage(meds: Medication[], events: Array<{ medName: string; }; }); - const low = coverage.filter((c) => c.medsLeft <= 0 || (c.daysLeft !== null && c.daysLeft <= 3)); + const low = coverage.filter((c) => c.medsLeft <= 0 || (c.daysLeft !== null && c.daysLeft <= reminderDaysBefore)); return { low, all: coverage }; } diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index 30f52eb..a5730bb 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -20,8 +20,8 @@ "title": "Nachbestell-Erinnerung", "badge": "Bestandsüberwachung", "noMeds": "Noch keine Medikamente konfiguriert.", - "allGood": "Alles in Ordnung, genug Vorrat.", - "sendReminder": "🔔 Erinnerung jetzt senden" + "allGood": "Alles in Ordnung, genug Vorrat.", "lowWarning": "Genug Vorrat, aber {{count}} Medikament wird knapp.", + "lowWarning_other": "Genug Vorrat, aber {{count}} Medikamente werden knapp.", "sendReminder": "🔔 Erinnerung jetzt senden" }, "overview": { "title": "Medikamentenübersicht", diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index c7edb82..a269609 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -21,6 +21,8 @@ "badge": "Stock watch", "noMeds": "No medications configured yet.", "allGood": "All good, enough stock.", + "lowWarning": "Enough stock for now, but {{count}} medication is running low.", + "lowWarning_other": "Enough stock for now, but {{count}} medications are running low.", "sendReminder": "🔔 Send Reminder Now" }, "overview": {