feat: add admin settings for reminder hour and minutes, and update expiry warning handling in UI and translations
This commit is contained in:
+7
-2
@@ -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
|
||||
# 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
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+37
-15
@@ -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() {
|
||||
<h2>{t('dashboard.reorder.title')}</h2>
|
||||
<span className="pill neutral">{t('dashboard.reorder.badge')}</span>
|
||||
</div>
|
||||
{meds.length === 0 ? (
|
||||
<p className="muted">{t('dashboard.reorder.noMeds')}</p>
|
||||
) : coverage.low.length === 0 ? (
|
||||
<p className="success-text">{t('dashboard.reorder.allGood')}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="table table-6">
|
||||
{(() => {
|
||||
if (meds.length === 0) {
|
||||
return <p className="muted">{t('dashboard.reorder.noMeds')}</p>;
|
||||
}
|
||||
|
||||
// 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 <p className="success-text">{t('dashboard.reorder.allGood')}</p>;
|
||||
} else {
|
||||
// Some meds are Low but not critical
|
||||
return <p className="warning-text">{t('dashboard.reorder.lowWarning', { count: lowStockCount })}</p>;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="table table-6">
|
||||
<div className="table-head">
|
||||
<span>{t('table.name')}</span>
|
||||
<span>{t('table.currentPills')}</span>
|
||||
@@ -659,7 +680,8 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
@@ -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 (
|
||||
<div key={row.name} className="table-row clickable" onClick={() => 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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user