From fc7852bafebee1a0a37254d7395dcafb162fa069 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Mon, 22 Dec 2025 10:55:53 +0100 Subject: [PATCH] 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. --- backend/src/i18n/translations.ts | 169 +++++++ backend/src/routes/settings.ts | 15 +- .../src/services/intake-reminder-scheduler.ts | 44 +- backend/src/services/reminder-scheduler.ts | 59 ++- frontend/package-lock.json | 100 +++- frontend/package.json | 3 + frontend/src/App.tsx | 471 ++++++++++-------- frontend/src/i18n/de.json | 236 +++++++++ frontend/src/i18n/en.json | 236 +++++++++ frontend/src/i18n/index.ts | 30 ++ frontend/src/main.tsx | 1 + frontend/src/styles.css | 36 ++ package-lock.json | 99 +++- 13 files changed, 1242 insertions(+), 257 deletions(-) create mode 100644 backend/src/i18n/translations.ts create mode 100644 frontend/src/i18n/de.json create mode 100644 frontend/src/i18n/en.json create mode 100644 frontend/src/i18n/index.ts diff --git a/backend/src/i18n/translations.ts b/backend/src/i18n/translations.ts new file mode 100644 index 0000000..91650e4 --- /dev/null +++ b/backend/src/i18n/translations.ts @@ -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 = { + 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 { + 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"; + } +} diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 5d915dd..7a25026 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -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://")) { diff --git a/backend/src/services/intake-reminder-scheduler.ts b/backend/src/services/intake-reminder-scheduler.ts index e6cd720..45b68a9 100644 --- a/backend/src/services/intake-reminder-scheduler.ts +++ b/backend/src/services/intake-reminder-scheduler.ts @@ -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) => ` ${intake.medName} - ${intake.usage} pills + ${intake.usage} ${tr.intakeReminder.pills} ${intake.intakeTimeStr} ` ) .join(""); + const alertText = intakes.length === 1 + ? tr.intakeReminder.alertSingle + : t(tr.intakeReminder.alertMultiple, { count: intakes.length }); + const html = `
-

💊 MedAssist - Intake Reminder

-

Time to take your medication in ${REMINDER_MINUTES_BEFORE} minutes:

+

${tr.intakeReminder.title}

+

${t(tr.intakeReminder.description, { minutes: REMINDER_MINUTES_BEFORE })}

- 💊 ${intakes.length} medication${intakes.length > 1 ? "s" : ""} scheduled + ${alertText}

- - - + + + @@ -158,20 +164,22 @@ async function sendIntakeReminderEmail(email: string, intakes: UpcomingIntake[])

- MedAssist Medication Planner + ${tr.intakeReminder.footer}

`; - 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 { 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); diff --git a/backend/src/services/reminder-scheduler.ts b/backend/src/services/reminder-scheduler.ts index dcf33f7..d49ba11 100644 --- a/backend/src/services/reminder-scheduler.ts +++ b/backend/src/services/reminder-scheduler.ts @@ -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 { +async function getMedicationsNeedingReminder(reminderDaysBefore: number, language: Language): Promise { 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 = `
-

⚠️ MedAssist - Automatic Reorder Reminder

-

The following medications are running low and need to be reordered:

+

${tr.stockReminder.title}

+

${tr.stockReminder.description}

- ⚠️ ${lowStock.length} medication${lowStock.length > 1 ? "s" : ""} running low! + ${alertText}

@@ -287,10 +295,10 @@ async function sendReminderEmail(email: string, lowStock: LowStockItem[]): Promi
MedicationDosageTime${tr.intakeReminder.tableHeaders.medication}${tr.intakeReminder.tableHeaders.dosage}${tr.intakeReminder.tableHeaders.time}
- - - - + + + + @@ -301,20 +309,23 @@ async function sendReminderEmail(email: string, lowStock: LowStockItem[]): Promi

- 🤖 Automatic reminder from MedAssist + ${tr.stockReminder.footer}

`; - 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 { 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); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 46ea42f..ea29993 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,8 +8,11 @@ "name": "medassist-frontend", "version": "0.1.0", "dependencies": { + "i18next": "^24.2.2", + "i18next-browser-languagedetector": "^8.0.4", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-i18next": "^15.4.1", "react-router-dom": "^7.11.0", "zod": "^3.23.8" }, @@ -257,6 +260,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -1457,6 +1469,56 @@ "node": ">=6.9.0" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "24.2.3", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.3.tgz", + "integrity": "sha512-lfbf80OzkocvX7nmZtu7nSTNbrTYR52sLWxPtlXX1zAhVw8WEnFk4puUkCR4B1dNQwbSpEHHHemcZu//7EcB7A==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.26.10" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", + "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1621,6 +1683,32 @@ "react": "^18.3.1" } }, + "node_modules/react-i18next": { + "version": "15.7.4", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz", + "integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.4.0", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -1767,8 +1855,9 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1884,6 +1973,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index f7f9a97..a526afb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,8 +10,11 @@ "lint": "echo 'add lint config'" }, "dependencies": { + "i18next": "^24.2.2", + "i18next-browser-languagedetector": "^8.0.4", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-i18next": "^15.4.1", "react-router-dom": "^7.11.0", "zod": "^3.23.8" }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5fa921b..4151d75 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { Routes, Route, useNavigate, useLocation, Navigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; type Slice = { usage: number; @@ -77,6 +78,7 @@ type Coverage = { }; export default function App() { + const { t, i18n } = useTranslation(); const [meds, setMeds] = useState([]); const [plannerRows, setPlannerRows] = useState(() => { if (typeof window !== "undefined") { @@ -218,10 +220,11 @@ export default function App() { settings.shoutrrrEnabled !== savedSettings.shoutrrrEnabled || settings.shoutrrrUrl !== savedSettings.shoutrrrUrl; - const schedule = useMemo(() => buildSchedulePreview(meds), [meds]); + const schedule = useMemo(() => buildSchedulePreview(meds, i18n.language), [meds, i18n.language]); const totalTablets = useMemo(() => deriveTotal(form), [form]); - const coverage = useMemo(() => calculateCoverage(meds, schedule.events), [meds, schedule.events]); + const coverage = useMemo(() => calculateCoverage(meds, schedule.events, i18n.language), [meds, schedule.events, i18n.language]); 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(() => { type DoseInfo = { id: string; timeStr: string; when: number; usage: number }; const days = new Map }>(); @@ -290,6 +293,14 @@ export default function App() { highStockDays: settings.highStockDays, shoutrrrEnabled: settings.shoutrrrEnabled, shoutrrrUrl: settings.shoutrrrUrl, + // Granular notification settings + emailStockReminders: settings.emailStockReminders, + emailIntakeReminders: settings.emailIntakeReminders, + shoutrrrStockReminders: settings.shoutrrrStockReminders, + shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders, + // Language setting (for backend notifications) + language: i18n.language, + // SMTP (legacy - not saved, read from .env) smtpHost: settings.smtpHost, smtpPort: settings.smtpPort, smtpUser: settings.smtpUser, @@ -553,12 +564,12 @@ export default function App() { // Page titles based on current route const pageInfo = { - "/dashboard": { eyebrow: "MedAssist · Overview", title: "Dashboard" }, - "/medications": { eyebrow: "MedAssist · Inventory", title: "Manage Medications" }, - "/planner": { eyebrow: "MedAssist · Planner", title: "Demand Calculator" }, - "/settings": { eyebrow: "MedAssist · Configuration", title: "Settings" }, - "/schedule": { eyebrow: "MedAssist · Schedule", title: "Upcoming Schedules" }, - }[currentPath] || { eyebrow: "MedAssist · Overview", title: "Dashboard" }; + "/dashboard": { eyebrow: t('header.eyebrow.overview'), title: t('nav.dashboard') }, + "/medications": { eyebrow: t('header.eyebrow.inventory'), title: t('nav.medications') }, + "/planner": { eyebrow: t('header.eyebrow.planner'), title: t('nav.planner') }, + "/settings": { eyebrow: t('header.eyebrow.settings'), title: t('nav.settings') }, + "/schedule": { eyebrow: t('header.eyebrow.schedule'), title: t('dashboard.schedules.title') }, + }[currentPath] || { eyebrow: t('header.eyebrow.overview'), title: t('nav.dashboard') }; return (
@@ -572,12 +583,12 @@ export default function App() {
- - - + + +
- - +
@@ -591,7 +602,7 @@ export default function App() {
{settings.emailEnabled && settings.shoutrrrEnabled ? "🔔" : settings.emailEnabled ? "📧" : "🔔"} - Automatic reminders active — {getReminderStatusText(settings.reminderDaysBefore, coverage.low, settings.lastAutoEmailSent)} + {t('dashboard.reminders.active')} — {getReminderStatusText(settings.reminderDaysBefore, coverage.low, settings.lastAutoEmailSent, t, i18n.language)} {settings.emailEnabled && settings.notificationEmail && → {settings.notificationEmail}}
@@ -599,35 +610,36 @@ export default function App() {
-

Reorder Reminder

- Stock watch +

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

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

No medications configured yet.

+

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

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

All good, enough stock.

+

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

) : ( <>
- Name - Current pills - Days left - Status - Runs out - Auto-remind + {t('table.name')} + {t('table.currentPills')} + {t('table.daysLeft')} + {t('table.status')} + {t('table.runsOut')} + {t('table.autoRemind')}
{coverage.low.map((row) => { const status = getStockStatus(row.daysLeft, row.medsLeft, settings); const med = meds.find(m => m.name === row.name); + const textClass = status.className === "danger" ? "danger-text" : status.className === "warning" ? "warning-text" : ""; return (
med && setSelectedMed(med)}> - {row.name}{med?.takenBy && { e.stopPropagation(); setSelectedUser(med.takenBy!); }}>{med.takenBy}}{med?.intakeRemindersEnabled && 🔔}{med?.notes && 📝} - {formatNumber(row.medsLeft)} - {formatNumber(row.daysLeft)} - {status.label} - {row.depletionDate ?? "-"} - {getNextReminderForMed(row, settings.reminderDaysBefore)} + {row.name}{med?.takenBy && { e.stopPropagation(); setSelectedUser(med.takenBy!); }}>{med.takenBy}}{med?.intakeRemindersEnabled && 🔔}{med?.notes && 📝} + {formatNumber(row.medsLeft)} + {formatNumber(row.daysLeft)} + {t(status.label)} + {row.depletionDate ?? "-"} + {getNextReminderForMed(row, settings.reminderDaysBefore, i18n.language)}
); })} @@ -635,7 +647,7 @@ export default function App() { {(settings.emailEnabled || settings.shoutrrrEnabled) && (
{reminderEmailResult && ( @@ -652,30 +664,31 @@ export default function App() {
-

Medication Overview

- Stock +

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

+ {t('dashboard.overview.badge')}
- Name - Current pills - Days left - Runs out - Expiry - Status + {t('table.name')} + {t('table.currentPills')} + {t('table.daysLeft')} + {t('table.runsOut')} + {t('table.expiry')} + {t('table.status')}
{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 textClass = status.className === "danger" ? "danger-text" : status.className === "warning" ? "warning-text" : ""; return (
med && setSelectedMed(med)}> - {row.name}{med?.takenBy && { e.stopPropagation(); setSelectedUser(med.takenBy!); }}>{med.takenBy}}{med?.intakeRemindersEnabled && 🔔}{med?.notes && 📝} - {formatNumber(row.medsLeft)} - {formatNumber(row.daysLeft)} - {row.depletionDate ?? "-"} - {med?.expiryDate ? new Date(med.expiryDate).toLocaleDateString([], { day: "2-digit", month: "short", year: "2-digit" }) : "-"} - {status.label} + {row.name}{med?.takenBy && { e.stopPropagation(); setSelectedUser(med.takenBy!); }}>{med.takenBy}}{med?.intakeRemindersEnabled && 🔔}{med?.notes && 📝} + {formatNumber(row.medsLeft)} + {formatNumber(row.daysLeft)} + {row.depletionDate ?? "-"} + {med?.expiryDate ? new Date(med.expiryDate).toLocaleDateString(i18n.language, { day: "2-digit", month: "short", year: "2-digit" }) : "-"} + {t(status.label)}
); })} @@ -686,7 +699,7 @@ export default function App() {
-

navigate("/schedule")}>Upcoming Schedules

+

navigate("/schedule")}>{t('dashboard.schedules.title')}

@@ -708,17 +721,21 @@ export default function App() { {day.meds.map((item) => { const depletionTime = depletionByMed[item.medName]; const outOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime; + const medCoverage = coverageByMed[item.medName]; + const isLowStock = medCoverage && medCoverage.daysLeft !== null && medCoverage.daysLeft <= settings.lowStockDays && !outOfStock; const med = meds.find(m => m.name === item.medName); const allTaken = item.doses.every((d) => takenDoses.has(d.id)); const takenCount = item.doses.filter((d) => takenDoses.has(d.id)).length; + const stockClass = outOfStock ? "danger" : isLowStock ? "warning" : "success"; + const stockLabel = outOfStock ? t('status.noPillsLeft') : isLowStock ? t('status.lowStock') : t('status.stockOk'); return (
-
{item.medName}{med?.intakeRemindersEnabled && 🔔}
+
{item.medName}{med?.intakeRemindersEnabled && 🔔}
- {item.total} pills total - - {outOfStock ? "⚠ No pills left" : "✓ Stock OK"} + {item.total} {t('common.pills')} {t('common.total')} + + {stockLabel}
@@ -728,11 +745,11 @@ export default function App() { return (
{dose.timeStr} - {dose.usage} pill{dose.usage !== 1 ? "s" : ""}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}{med?.takenBy && taken by setSelectedUser(med.takenBy!)}>{med.takenBy}} + {dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}{med?.takenBy && {t('dose.takenBy')} setSelectedUser(med.takenBy!)}>{med.takenBy}} {isTaken ? ( - + ) : ( - + )}
); @@ -753,8 +770,8 @@ export default function App() {
-

Medication list

- {loading ? "Loading..." : `${meds.length} entries`} +

{t('medications.list.title')}

+ {loading ? t('common.loading') : t('medications.list.entries', { count: meds.length })}
{meds.map((med) => ( @@ -766,22 +783,22 @@ export default function App() {
{med.name}
- Packs: {med.packCount ?? 1} - Blisters per pack: {med.stripsPerPack ?? med.strips ?? 1} - Pills per blister: {med.tabsPerStrip ?? med.stripSize} - Loose: {med.looseTablets ?? 0} + {t('medications.details.packs')}: {med.packCount ?? 1} + {t('medications.details.blisters')}: {med.stripsPerPack ?? med.strips ?? 1} + {t('medications.details.pillsPerBlister')}: {med.tabsPerStrip ?? med.stripSize} + {t('medications.details.loose')}: {med.looseTablets ?? 0}
-
Total: {med.count} pills
+
{t('medications.details.total')}: {med.count} {t('common.pills')}
- - + +
{med.slices.map((s, idx) => (
- {s.usage} {s.usage === 1 ? "pill" : "pills"} · every {s.every} {s.every === 1 ? "day" : "days"} · from {formatDateTime(s.start)} + {s.usage} {s.usage === 1 ? t('common.pill') : t('common.pills')} · {t('form.slices.every')} {s.every} {s.every === 1 ? t('common.day') : t('common.days')} · {t('form.slices.from')} {formatDateTime(s.start, i18n.language)}
))}
@@ -792,57 +809,57 @@ export default function App() {
-

{editingId ? "Edit entry" : "New entry"}

- Packs + loose pills +

{editingId ? t('form.editEntry') : t('form.newEntry')}

+ {t('form.badge')}
MedicationPillsDaysRuns Out${tr.stockReminder.tableHeaders.medication}${tr.stockReminder.tableHeaders.pills}${tr.stockReminder.tableHeaders.days}${tr.stockReminder.tableHeaders.runsOut}