feat(i18n): add internationalization support with English and German translations

- Integrated i18next for language detection and translation management.
- Added translation files for English and German languages.
- Implemented translation keys for notifications, reminders, and common UI elements.
- Updated main application entry point to include i18n initialization.
- Styled language selection dropdown in settings.
- Enhanced package dependencies to include i18next and react-i18next.
This commit is contained in:
Daniel Volz
2025-12-22 10:55:53 +01:00
parent f1ee8e6fdf
commit fc7852bafe
13 changed files with 1242 additions and 257 deletions
+169
View File
@@ -0,0 +1,169 @@
// Backend translations for notifications
export type Language = "en" | "de";
type TranslationKeys = {
// Stock reminder email
stockReminder: {
subject: string;
title: string;
description: string;
alertSingle: string;
alertMultiple: string;
tableHeaders: {
medication: string;
pills: string;
days: string;
runsOut: string;
};
footer: string;
};
// Intake reminder email
intakeReminder: {
subject: string;
title: string;
description: string;
alertSingle: string;
alertMultiple: string;
tableHeaders: {
medication: string;
dosage: string;
time: string;
};
pills: string;
footer: string;
};
// Push notifications
push: {
stockTitle: string;
stockTitleMultiple: string;
intakeTitle: string;
pillsLeft: string;
daysLeft: string;
pillsAt: string;
};
// Common
common: {
pill: string;
pills: string;
day: string;
days: string;
soon: string;
};
};
const translations: Record<Language, TranslationKeys> = {
en: {
stockReminder: {
subject: "MedAssist Auto-Reminder: {count} Medication{s} Running Low",
title: "⚠️ MedAssist - Automatic Reorder Reminder",
description: "The following medications are running low and need to be reordered:",
alertSingle: "⚠️ 1 medication running low!",
alertMultiple: "⚠️ {count} medications running low!",
tableHeaders: {
medication: "Medication",
pills: "Pills",
days: "Days",
runsOut: "Runs Out",
},
footer: "🤖 Automatic reminder from MedAssist",
},
intakeReminder: {
subject: "MedAssist: Medication Reminder - {medications}",
title: "💊 MedAssist - Intake Reminder",
description: "Time to take your medication in {minutes} minutes:",
alertSingle: "💊 1 medication scheduled",
alertMultiple: "💊 {count} medications scheduled",
tableHeaders: {
medication: "Medication",
dosage: "Dosage",
time: "Time",
},
pills: "pills",
footer: "MedAssist Medication Planner",
},
push: {
stockTitle: "MedAssist: 1 Medication Running Low",
stockTitleMultiple: "MedAssist: {count} Medications Running Low",
intakeTitle: "Medication Reminder in {minutes} min",
pillsLeft: "{count} pills",
daysLeft: "{count} days left",
pillsAt: "{count} pills at {time}",
},
common: {
pill: "pill",
pills: "pills",
day: "day",
days: "days",
soon: "soon",
},
},
de: {
stockReminder: {
subject: "MedAssist Auto-Erinnerung: {count} Medikament{e} wird knapp",
title: "⚠️ MedAssist - Automatische Nachbestell-Erinnerung",
description: "Die folgenden Medikamente gehen zur Neige und sollten nachbestellt werden:",
alertSingle: "⚠️ 1 Medikament wird knapp!",
alertMultiple: "⚠️ {count} Medikamente werden knapp!",
tableHeaders: {
medication: "Medikament",
pills: "Tabletten",
days: "Tage",
runsOut: "Aufgebraucht",
},
footer: "🤖 Automatische Erinnerung von MedAssist",
},
intakeReminder: {
subject: "MedAssist: Einnahme-Erinnerung - {medications}",
title: "💊 MedAssist - Einnahme-Erinnerung",
description: "Zeit für Ihre Medikamente in {minutes} Minuten:",
alertSingle: "💊 1 Medikament geplant",
alertMultiple: "💊 {count} Medikamente geplant",
tableHeaders: {
medication: "Medikament",
dosage: "Dosis",
time: "Uhrzeit",
},
pills: "Tabletten",
footer: "MedAssist Medikamentenplaner",
},
push: {
stockTitle: "MedAssist: 1 Medikament wird knapp",
stockTitleMultiple: "MedAssist: {count} Medikamente werden knapp",
intakeTitle: "Einnahme-Erinnerung in {minutes} Min.",
pillsLeft: "{count} Tabletten",
daysLeft: "{count} Tage übrig",
pillsAt: "{count} Tabletten um {time}",
},
common: {
pill: "Tablette",
pills: "Tabletten",
day: "Tag",
days: "Tage",
soon: "bald",
},
},
};
export function getTranslations(language: Language): TranslationKeys {
return translations[language] || translations.en;
}
// Helper function to replace placeholders in strings
export function t(template: string, params: Record<string, string | number> = {}): string {
let result = template;
for (const [key, value] of Object.entries(params)) {
result = result.replace(new RegExp(`\\{${key}\\}`, "g"), String(value));
}
return result;
}
// Get date locale for toLocaleDateString
export function getDateLocale(language: Language): string {
switch (language) {
case "de":
return "de-DE";
case "en":
default:
return "en-US";
}
}
+14 -1
View File
@@ -3,6 +3,7 @@ import nodemailer from "nodemailer";
import { readFileSync, writeFileSync, existsSync } from "fs";
import { resolve } from "path";
import { getReminderState } from "../services/reminder-scheduler.js";
import type { Language } from "../i18n/translations.js";
type SettingsBody = {
emailEnabled: boolean;
@@ -19,6 +20,8 @@ type SettingsBody = {
emailIntakeReminders: boolean;
shoutrrrStockReminders: boolean;
shoutrrrIntakeReminders: boolean;
// Language setting
language: Language;
};
type TestEmailBody = {
@@ -48,6 +51,8 @@ type NotificationSettings = {
emailIntakeReminders: boolean;
shoutrrrStockReminders: boolean;
shoutrrrIntakeReminders: boolean;
// Language setting
language: Language;
};
function loadNotificationSettings(): NotificationSettings {
@@ -69,6 +74,8 @@ function loadNotificationSettings(): NotificationSettings {
emailIntakeReminders: saved.emailIntakeReminders ?? true,
shoutrrrStockReminders: saved.shoutrrrStockReminders ?? true,
shoutrrrIntakeReminders: saved.shoutrrrIntakeReminders ?? true,
// Language setting (default to English)
language: saved.language ?? "en",
};
}
} catch {
@@ -88,6 +95,7 @@ function loadNotificationSettings(): NotificationSettings {
emailIntakeReminders: true,
shoutrrrStockReminders: true,
shoutrrrIntakeReminders: true,
language: "en",
};
}
@@ -120,6 +128,8 @@ export async function settingsRoutes(app: FastifyInstance) {
emailIntakeReminders: notification.emailIntakeReminders,
shoutrrrStockReminders: notification.shoutrrrStockReminders,
shoutrrrIntakeReminders: notification.shoutrrrIntakeReminders,
// Language setting
language: notification.language,
// SMTP settings (admin-configured, from .env)
smtpHost: process.env.SMTP_HOST ?? "",
smtpPort: parseInt(process.env.SMTP_PORT ?? "587"),
@@ -153,6 +163,8 @@ export async function settingsRoutes(app: FastifyInstance) {
emailIntakeReminders: body.emailIntakeReminders ?? true,
shoutrrrStockReminders: body.shoutrrrStockReminders ?? true,
shoutrrrIntakeReminders: body.shoutrrrIntakeReminders ?? true,
// Language setting
language: body.language ?? "en",
});
return reply.send({ success: true });
@@ -240,7 +252,8 @@ export async function sendShoutrrrNotification(urlStr: string, title: string, me
let body: string | undefined;
// Remove emojis from title for header compatibility (ntfy doesn't support unicode in headers)
const cleanTitle = title.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|/gu, "").trim();
// Match common emojis, pictographs, symbols, and variation selectors
const cleanTitle = title.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE00}-\u{FE0F}]|[\u{2000}-\u{206F}]||/gu, "").trim();
// Handle different URL formats
if (urlStr.startsWith("ntfy://")) {
@@ -4,6 +4,7 @@ import { medications } from "../db/schema.js";
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";
type Slice = { usage: number; every: number; start: string };
@@ -107,7 +108,7 @@ function getUpcomingIntakes(medName: string, slices: Slice[], minutesBefore: num
return upcoming;
}
async function sendIntakeReminderEmail(email: string, intakes: UpcomingIntake[]): Promise<{ success: boolean; error?: string }> {
async function sendIntakeReminderEmail(email: string, intakes: UpcomingIntake[], language: Language): Promise<{ success: boolean; error?: string }> {
const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_PASS;
@@ -119,36 +120,41 @@ async function sendIntakeReminderEmail(email: string, intakes: UpcomingIntake[])
return { success: false, error: "SMTP not configured" };
}
const tr = getTranslations(language);
const tableRows = intakes
.map(
(intake) => `
<tr>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb;">${intake.medName}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center;"><strong>${intake.usage}</strong> pills</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center;"><strong>${intake.usage}</strong> ${tr.intakeReminder.pills}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center;">${intake.intakeTimeStr}</td>
</tr>
`
)
.join("");
const alertText = intakes.length === 1
? tr.intakeReminder.alertSingle
: t(tr.intakeReminder.alertMultiple, { count: intakes.length });
const html = `
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">💊 MedAssist - Intake Reminder</h2>
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">Time to take your medication in ${REMINDER_MINUTES_BEFORE} minutes:</p>
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">${tr.intakeReminder.title}</h2>
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">${t(tr.intakeReminder.description, { minutes: REMINDER_MINUTES_BEFORE })}</p>
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #eff6ff; border: 1px solid #bfdbfe;">
<p style="margin: 0; color: #1e40af; font-weight: 500; font-size: 13px;">
💊 ${intakes.length} medication${intakes.length > 1 ? "s" : ""} scheduled
${alertText}
</p>
</div>
<table style="width: 100%; border-collapse: collapse; background: white;">
<thead>
<tr style="background: #f3f4f6;">
<th style="padding: 10px 12px; text-align: left; font-size: 11px; text-transform: uppercase; color: #6b7280;">Medication</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280;">Dosage</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280;">Time</th>
<th style="padding: 10px 12px; text-align: left; font-size: 11px; text-transform: uppercase; color: #6b7280;">${tr.intakeReminder.tableHeaders.medication}</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280;">${tr.intakeReminder.tableHeaders.dosage}</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280;">${tr.intakeReminder.tableHeaders.time}</th>
</tr>
</thead>
<tbody>
@@ -158,20 +164,22 @@ async function sendIntakeReminderEmail(email: string, intakes: UpcomingIntake[])
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
<p style="color: #9ca3af; font-size: 11px; margin: 0;">
MedAssist Medication Planner
${tr.intakeReminder.footer}
</p>
</div>
</div>
`;
const plainText = `MedAssist - Intake Reminder
const plainText = `${tr.intakeReminder.title}
Time to take your medication in ${REMINDER_MINUTES_BEFORE} minutes:
${t(tr.intakeReminder.description, { minutes: REMINDER_MINUTES_BEFORE })}
${intakes.map((i) => `${i.medName}: ${i.usage} pills at ${i.intakeTimeStr}`).join("\n")}
${intakes.map((i) => `${i.medName}: ${i.usage} ${tr.intakeReminder.pills} ${tr.intakeReminder.tableHeaders.time.toLowerCase()}: ${i.intakeTimeStr}`).join("\n")}
---
MedAssist Medication Planner`;
${tr.intakeReminder.footer}`;
const subject = t(tr.intakeReminder.subject, { medications: intakes.map(i => i.medName).join(", ") });
try {
const transporter = nodemailer.createTransport({
@@ -187,7 +195,7 @@ MedAssist Medication Planner`;
await transporter.sendMail({
from: smtpFrom,
to: email,
subject: `💊 MedAssist: Medication Reminder - ${intakes.map(i => i.medName).join(", ")}`,
subject: `💊 ${subject}`,
text: plainText,
html,
});
@@ -201,6 +209,8 @@ MedAssist Medication Planner`;
async function checkAndSendIntakeReminders(logger: { info: (msg: string) => void; error: (msg: string) => void }): Promise<void> {
const settings = loadNotificationSettings();
const language = settings.language;
const tr = getTranslations(language);
// Check if any intake reminder notifications are enabled (granular check)
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders;
@@ -249,7 +259,7 @@ async function checkAndSendIntakeReminders(logger: { info: (msg: string) => void
// Send email if enabled for intake reminders
if (emailEnabled) {
const result = await sendIntakeReminderEmail(settings.notificationEmail, newReminders);
const result = await sendIntakeReminderEmail(settings.notificationEmail, newReminders, language);
emailSuccess = result.success;
if (result.success) {
logger.info(`[IntakeReminder] Email sent successfully`);
@@ -260,9 +270,9 @@ async function checkAndSendIntakeReminders(logger: { info: (msg: string) => void
// Send Shoutrrr notification if enabled for intake reminders
if (shoutrrrEnabled) {
const title = `Medication Reminder in ${REMINDER_MINUTES_BEFORE} min`;
const title = t(tr.push.intakeTitle, { minutes: REMINDER_MINUTES_BEFORE });
const message = newReminders
.map((i) => `- ${i.medName}: ${i.usage} pills at ${i.intakeTimeStr}`)
.map((i) => `- ${i.medName}: ${t(tr.push.pillsAt, { count: i.usage, time: i.intakeTimeStr })}`)
.join("\n");
const result = await sendShoutrrrNotification(settings.shoutrrrUrl, title, message);
+37 -22
View File
@@ -4,6 +4,7 @@ import { medications } from "../db/schema.js";
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";
type Slice = { usage: number; every: number; start: string };
@@ -22,6 +23,8 @@ type NotificationSettings = {
emailIntakeReminders: boolean;
shoutrrrStockReminders: boolean;
shoutrrrIntakeReminders: boolean;
// Language setting
language: Language;
};
type ReminderState = {
@@ -201,13 +204,13 @@ function calculateDailyUsage(slices: Slice[]): number {
return slices.reduce((sum, s) => sum + s.usage / s.every, 0);
}
function calculateDepletionInfo(med: { count: number; slices: Slice[] }): { daysLeft: number | null; depletionDate: string | null } {
function calculateDepletionInfo(med: { count: number; slices: Slice[] }, language: Language): { daysLeft: number | null; depletionDate: string | null } {
const dailyUsage = calculateDailyUsage(med.slices);
if (dailyUsage <= 0) return { daysLeft: null, depletionDate: null };
const daysLeft = Math.floor(med.count / dailyUsage);
const depletionMs = Date.now() + daysLeft * 86_400_000;
const depletionDate = new Date(depletionMs).toLocaleDateString("en-US", {
const depletionDate = new Date(depletionMs).toLocaleDateString(getDateLocale(language), {
weekday: "short",
day: "2-digit",
month: "short",
@@ -223,14 +226,14 @@ type LowStockItem = {
depletionDate: string | null;
};
async function getMedicationsNeedingReminder(reminderDaysBefore: number): Promise<LowStockItem[]> {
async function getMedicationsNeedingReminder(reminderDaysBefore: number, language: Language): Promise<LowStockItem[]> {
const rows = await db.select().from(medications).orderBy(medications.id);
const lowStock: LowStockItem[] = [];
for (const row of rows) {
const slices = parseSlices(row);
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: row.count, slices });
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: row.count, slices }, language);
// Check if medication runs out within reminderDaysBefore days
if (daysLeft !== null && daysLeft <= reminderDaysBefore) {
@@ -246,7 +249,7 @@ async function getMedicationsNeedingReminder(reminderDaysBefore: number): Promis
return lowStock;
}
async function sendReminderEmail(email: string, lowStock: LowStockItem[]): Promise<{ success: boolean; error?: string }> {
async function sendReminderEmail(email: string, lowStock: LowStockItem[], language: Language): Promise<{ success: boolean; error?: string }> {
const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_PASS;
@@ -258,6 +261,7 @@ async function sendReminderEmail(email: string, lowStock: LowStockItem[]): Promi
return { success: false, error: "SMTP not configured" };
}
const tr = getTranslations(language);
const tableRows = lowStock
.map(
(row) => `
@@ -271,15 +275,19 @@ async function sendReminderEmail(email: string, lowStock: LowStockItem[]): Promi
)
.join("");
const alertText = lowStock.length === 1
? tr.stockReminder.alertSingle
: t(tr.stockReminder.alertMultiple, { count: lowStock.length });
const html = `
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">⚠️ MedAssist - Automatic Reorder Reminder</h2>
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">The following medications are running low and need to be reordered:</p>
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">${tr.stockReminder.title}</h2>
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">${tr.stockReminder.description}</p>
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #fef2f2; border: 1px solid #fecaca;">
<p style="margin: 0; color: #991b1b; font-weight: 500; font-size: 13px;">
⚠️ ${lowStock.length} medication${lowStock.length > 1 ? "s" : ""} running low!
${alertText}
</p>
</div>
@@ -287,10 +295,10 @@ async function sendReminderEmail(email: string, lowStock: LowStockItem[]): Promi
<table style="width: 100%; border-collapse: collapse; background: white; min-width: 400px;">
<thead>
<tr style="background: #f3f4f6;">
<th style="padding: 10px 12px; text-align: left; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Medication</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Pills</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Days</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Runs Out</th>
<th style="padding: 10px 12px; text-align: left; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">${tr.stockReminder.tableHeaders.medication}</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">${tr.stockReminder.tableHeaders.pills}</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">${tr.stockReminder.tableHeaders.days}</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">${tr.stockReminder.tableHeaders.runsOut}</th>
</tr>
</thead>
<tbody>
@@ -301,20 +309,23 @@ async function sendReminderEmail(email: string, lowStock: LowStockItem[]): Promi
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
<p style="color: #9ca3af; font-size: 11px; margin: 0;">
🤖 Automatic reminder from MedAssist
${tr.stockReminder.footer}
</p>
</div>
</div>
`;
const plainText = `MedAssist - Automatic Reorder Reminder
const plainText = `${tr.stockReminder.title}
The following medications are running low:
${tr.stockReminder.description}
${lowStock.map((r) => `${r.name}: ${r.medsLeft} pills left, ${r.daysLeft ?? 0} days remaining, runs out ${r.depletionDate ?? "soon"}`).join("\n")}
${lowStock.map((r) => `${r.name}: ${r.medsLeft} ${tr.common.pills}, ${r.daysLeft ?? 0} ${tr.common.days}, ${tr.stockReminder.tableHeaders.runsOut}: ${r.depletionDate ?? tr.common.soon}`).join("\n")}
---
Automatic reminder from MedAssist`;
${tr.stockReminder.footer}`;
const subjectPlural = lowStock.length === 1 ? "" : (language === "de" ? "e" : "s");
const subject = t(tr.stockReminder.subject, { count: lowStock.length, s: subjectPlural, e: subjectPlural });
try {
const transporter = nodemailer.createTransport({
@@ -330,7 +341,7 @@ Automatic reminder from MedAssist`;
await transporter.sendMail({
from: smtpFrom,
to: email,
subject: `⚠️ MedAssist Auto-Reminder: ${lowStock.length} Medication${lowStock.length > 1 ? "s" : ""} Running Low`,
subject: `⚠️ ${subject}`,
text: plainText,
html,
});
@@ -344,6 +355,8 @@ Automatic reminder from MedAssist`;
async function checkAndSendReminder(logger: { info: (msg: string) => void; error: (msg: string) => void }): Promise<void> {
const settings = loadNotificationSettings();
const language = settings.language;
const tr = getTranslations(language);
// Check if any stock reminder notifications are enabled (granular check)
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailStockReminders;
@@ -358,7 +371,7 @@ async function checkAndSendReminder(logger: { info: (msg: string) => void; error
const today = getTodayInTimezone(); // YYYY-MM-DD in configured timezone
// Get all medications that need a reminder
const allLowStock = await getMedicationsNeedingReminder(settings.reminderDaysBefore);
const allLowStock = await getMedicationsNeedingReminder(settings.reminderDaysBefore, language);
if (allLowStock.length === 0) {
// No low stock - clear the notified list (medications have been restocked)
@@ -408,7 +421,7 @@ async function checkAndSendReminder(logger: { info: (msg: string) => void; error
// Send email if enabled
if (emailEnabled) {
const result = await sendReminderEmail(settings.notificationEmail, medsToNotify);
const result = await sendReminderEmail(settings.notificationEmail, medsToNotify, language);
emailSuccess = result.success;
if (result.success) {
logger.info(`[Reminder] Email sent successfully to ${settings.notificationEmail}`);
@@ -419,9 +432,11 @@ async function checkAndSendReminder(logger: { info: (msg: string) => void; error
// Send Shoutrrr notification if enabled
if (shoutrrrEnabled) {
const title = `⚠️ MedAssist: ${medsToNotify.length} Medication${medsToNotify.length > 1 ? "s" : ""} Running Low`;
const title = medsToNotify.length === 1
? tr.push.stockTitle
: t(tr.push.stockTitleMultiple, { count: medsToNotify.length });
const message = medsToNotify
.map((m) => `${m.name}: ${m.medsLeft} pills, ${m.daysLeft ?? 0} days left`)
.map((m) => `${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`)
.join("\n");
const result = await sendShoutrrrNotification(settings.shoutrrrUrl, title, message);