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; every: number; start: string; }; type Medication = { id: number; name: string; genericName?: string | null; takenBy?: string | null; count: number; strips: number; stripSize: number; packCount?: number; stripsPerPack?: number; tabsPerStrip?: number; looseTablets?: number; pillWeightMg?: number | null; slices: Slice[]; imageUrl?: string | null; expiryDate?: string | null; notes?: string | null; intakeRemindersEnabled?: boolean; updatedAt: string | number | null; }; type PlannerRow = { medicationId: number; medicationName: string; totalPills: number; plannerUsage: number; stripSize: number; stripsNeeded: number; stripsAvailable: number; enough: boolean; }; type FormSlice = { usage: string; every: string; start: string }; type FormState = { name: string; genericName: string; takenBy: string; packCount: string; stripsPerPack: string; tabsPerStrip: string; looseTablets: string; pillWeightMg: string; expiryDate: string; notes: string; intakeRemindersEnabled: boolean; slices: FormSlice[]; }; const defaultSlice = (): FormSlice => ({ usage: "1", every: "1", start: toInputValue(new Date().toISOString()) }); const defaultForm = (): FormState => ({ name: "", genericName: "", takenBy: "", packCount: "1", stripsPerPack: "1", tabsPerStrip: "1", looseTablets: "0", pillWeightMg: "", expiryDate: "", notes: "", intakeRemindersEnabled: false, slices: [defaultSlice()] }); const todayIso = () => new Date().toISOString(); const plusDaysIso = (days: number) => { const d = new Date(); d.setDate(d.getDate() + days); return d.toISOString(); }; type Coverage = { name: string; medsLeft: number; daysLeft: number | null; depletionDate: string | null; depletionTime: number | null; nextDose: string | null; }; export default function App() { const { t, i18n } = useTranslation(); const [meds, setMeds] = useState([]); const [plannerRows, setPlannerRows] = useState(() => { if (typeof window !== "undefined") { const saved = localStorage.getItem("plannerRows"); if (saved) { try { return JSON.parse(saved); } catch { return []; } } } return []; }); const [plannerLoading, setPlannerLoading] = useState(false); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [editingId, setEditingId] = useState(null); const [form, setForm] = useState(defaultForm()); const [range, setRange] = useState<{ start: string; end: string }>(() => { if (typeof window !== "undefined") { const saved = localStorage.getItem("plannerRange"); if (saved) { try { return JSON.parse(saved); } catch { /* ignore */ } } } return { start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) }; }); const navigate = useNavigate(); const location = useLocation(); const currentPath = location.pathname; // Settings state const [settings, setSettings] = useState({ emailEnabled: false, notificationEmail: "", reminderDaysBefore: 7, repeatDailyReminders: false, lowStockDays: 30, normalStockDays: 90, highStockDays: 180, smtpHost: "", smtpPort: 587, smtpUser: "", smtpPass: "", smtpFrom: "", smtpSecure: false, 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: "", // Granular notification settings emailStockReminders: true, emailIntakeReminders: true, shoutrrrStockReminders: true, shoutrrrIntakeReminders: true, }); const [savedSettings, setSavedSettings] = useState(settings); const [settingsLoading, setSettingsLoading] = useState(false); const [settingsSaving, setSettingsSaving] = useState(false); const [settingsSaved, setSettingsSaved] = useState(false); const [testingEmail, setTestingEmail] = useState(false); const [testEmailResult, setTestEmailResult] = useState<{ success: boolean; message: string } | null>(null); const [testingShoutrrr, setTestingShoutrrr] = useState(false); const [testShoutrrrResult, setTestShoutrrrResult] = useState<{ success: boolean; message: string } | null>(null); const [sendingPlannerEmail, setSendingPlannerEmail] = useState(false); const [plannerEmailResult, setPlannerEmailResult] = useState<{ success: boolean; message: string } | null>(null); const [sendingReminderEmail, setSendingReminderEmail] = useState(false); const [reminderEmailResult, setReminderEmailResult] = useState<{ success: boolean; message: string } | null>(null); const [uploadingImage, setUploadingImage] = useState(false); const [selectedMed, setSelectedMed] = useState(null); const [showImageLightbox, setShowImageLightbox] = useState(false); const [selectedUser, setSelectedUser] = useState(null); const [scheduleDays, setScheduleDays] = useState(() => { const stored = localStorage.getItem("scheduleDays"); return stored ? Number(stored) : 30; }); // Track taken doses (stored in localStorage) const [takenDoses, setTakenDoses] = useState>(() => { try { const stored = localStorage.getItem("takenDoses"); if (stored) { const parsed = JSON.parse(stored); // Clean up old entries (older than 7 days) const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; const filtered = parsed.filter((item: { id: string; timestamp: number }) => item.timestamp > weekAgo); return new Set(filtered.map((item: { id: string }) => item.id)); } } catch {} return new Set(); }); function markDoseTaken(doseId: string) { setTakenDoses((prev) => { const next = new Set(prev); next.add(doseId); // Persist with timestamp for cleanup const items = Array.from(next).map((id) => ({ id, timestamp: Date.now() })); localStorage.setItem("takenDoses", JSON.stringify(items)); return next; }); } function undoDoseTaken(doseId: string) { setTakenDoses((prev) => { const next = new Set(prev); next.delete(doseId); const items = Array.from(next).map((id) => ({ id, timestamp: Date.now() })); localStorage.setItem("takenDoses", JSON.stringify(items)); return next; }); } // Close modal on Escape key useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape") { if (showImageLightbox) { setShowImageLightbox(false); } else if (selectedUser) { setSelectedUser(null); } else if (selectedMed) { setSelectedMed(null); } } }; document.addEventListener("keydown", handleEscape); return () => document.removeEventListener("keydown", handleEscape); }, [selectedMed, showImageLightbox, selectedUser]); // Check if settings have changed const settingsChanged = settings.emailEnabled !== savedSettings.emailEnabled || settings.notificationEmail !== savedSettings.notificationEmail || settings.reminderDaysBefore !== savedSettings.reminderDaysBefore || settings.repeatDailyReminders !== savedSettings.repeatDailyReminders || settings.lowStockDays !== savedSettings.lowStockDays || settings.normalStockDays !== savedSettings.normalStockDays || settings.highStockDays !== savedSettings.highStockDays || settings.shoutrrrEnabled !== savedSettings.shoutrrrEnabled || settings.shoutrrrUrl !== savedSettings.shoutrrrUrl; 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 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 }>(); schedule.events.slice(0, 2000).forEach((event) => { const day = days.get(event.dateStr) ?? { dateStr: event.dateStr, meds: new Map() }; const medEntry = day.meds.get(event.medName) ?? { medName: event.medName, total: 0, doses: [], lastWhen: event.when }; medEntry.total += event.usage; medEntry.doses.push({ id: event.id, timeStr: event.timeStr, when: event.when, usage: event.usage }); medEntry.lastWhen = Math.max(medEntry.lastWhen, event.when); day.meds.set(event.medName, medEntry); days.set(event.dateStr, day); }); return Array.from(days.values()).map((d) => ({ dateStr: d.dateStr, meds: Array.from(d.meds.values()) })).slice(0, scheduleDays); }, [schedule.events, scheduleDays]); useEffect(() => { loadMeds(); loadSettings(); }, []); function loadMeds() { setLoading(true); fetch("/api/medications") .then((res) => res.json()) .then((data: Medication[]) => setMeds(data)) .catch(() => setMeds([])) .finally(() => setLoading(false)); } function loadSettings() { setSettingsLoading(true); fetch("/api/settings") .then((res) => res.json()) .then((data) => { const newSettings = { ...settings, ...data, smtpPass: "" }; setSettings(newSettings); setSavedSettings(newSettings); setSettingsSaved(false); }) .catch(() => {}) .finally(() => setSettingsLoading(false)); } async function saveSettings(e: React.FormEvent) { e.preventDefault(); // Validate email if email notifications are enabled if (settings.emailEnabled && settings.notificationEmail) { const emailRegex = /^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$/i; if (!emailRegex.test(settings.notificationEmail)) { setTestEmailResult({ success: false, message: "Invalid email address" }); return; } } setSettingsSaving(true); setTestEmailResult(null); const payload = { emailEnabled: settings.emailEnabled, notificationEmail: settings.notificationEmail, reminderDaysBefore: settings.reminderDaysBefore, repeatDailyReminders: settings.repeatDailyReminders, lowStockDays: settings.lowStockDays, normalStockDays: settings.normalStockDays, 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, smtpPass: settings.smtpPass || undefined, smtpFrom: settings.smtpFrom, smtpSecure: settings.smtpSecure, }; await fetch("/api/settings", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }).catch(() => null); setSettingsSaving(false); setSavedSettings(settings); setSettingsSaved(true); } async function testEmail() { if (!settings.notificationEmail) return; setTestingEmail(true); setTestEmailResult(null); try { const res = await fetch("/api/settings/test-email", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: settings.notificationEmail }), }); const data = await res.json(); if (res.ok) { setTestEmailResult({ success: true, message: data.message || "Email sent!" }); } else { setTestEmailResult({ success: false, message: data.error || "Failed to send" }); } } catch { setTestEmailResult({ success: false, message: "Network error" }); } setTestingEmail(false); } async function testShoutrrr() { if (!settings.shoutrrrUrl) return; setTestingShoutrrr(true); setTestShoutrrrResult(null); try { const res = await fetch("/api/settings/test-shoutrrr", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: settings.shoutrrrUrl }), }); const data = await res.json(); if (res.ok) { setTestShoutrrrResult({ success: true, message: data.message || "Notification sent!" }); } else { setTestShoutrrrResult({ success: false, message: data.error || "Failed to send" }); } } catch { setTestShoutrrrResult({ success: false, message: "Network error" }); } setTestingShoutrrr(false); } async function sendPlannerEmail() { if (!settings.notificationEmail || plannerRows.length === 0) return; setSendingPlannerEmail(true); setPlannerEmailResult(null); try { const res = await fetch("/api/planner/send-email", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: settings.notificationEmail, from: range.start, until: range.end, rows: plannerRows, }), }); const data = await res.json(); if (res.ok) { setPlannerEmailResult({ success: true, message: data.message || "Email sent!" }); } else { setPlannerEmailResult({ success: false, message: data.error || "Failed to send" }); } } catch { setPlannerEmailResult({ success: false, message: "Network error" }); } setSendingPlannerEmail(false); } async function sendReminderEmail() { if (!settings.notificationEmail || coverage.low.length === 0) return; setSendingReminderEmail(true); setReminderEmailResult(null); try { const res = await fetch("/api/reminder/send-email", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: settings.notificationEmail, lowStock: coverage.low, }), }); const data = await res.json(); if (res.ok) { setReminderEmailResult({ success: true, message: data.message || "Email sent!" }); // Reload settings to get updated lastAutoEmailSent loadSettings(); } else { setReminderEmailResult({ success: false, message: data.error || "Failed to send" }); } } catch { setReminderEmailResult({ success: false, message: "Network error" }); } setSendingReminderEmail(false); } async function deleteMed(id: number) { await fetch(`/api/medications/${id}`, { method: "DELETE" }).catch(() => null); if (editingId === id) resetForm(); loadMeds(); } async function uploadMedImage(medId: number, file: File) { setUploadingImage(true); const formData = new FormData(); formData.append("file", file); try { const res = await fetch(`/api/medications/${medId}/image`, { method: "POST", body: formData, }); if (res.ok) { loadMeds(); } } catch { // ignore } setUploadingImage(false); } async function deleteMedImage(medId: number) { await fetch(`/api/medications/${medId}/image`, { method: "DELETE" }).catch(() => null); loadMeds(); } function setSliceValue(idx: number, field: keyof FormSlice, value: string) { setForm((prev) => { const next = [...prev.slices]; next[idx] = { ...next[idx], [field]: value }; return { ...prev, slices: next }; }); } function addSlice() { setForm((prev) => ({ ...prev, slices: [...prev.slices, defaultSlice()] })); } function removeSlice(idx: number) { setForm((prev) => ({ ...prev, slices: prev.slices.filter((_, i) => i !== idx) })); } function startEdit(med: Medication) { setEditingId(med.id); setForm({ name: med.name, genericName: med.genericName ?? "", takenBy: med.takenBy ?? "", packCount: String(med.packCount ?? 1), stripsPerPack: String(med.stripsPerPack ?? med.strips ?? 1), tabsPerStrip: String(med.tabsPerStrip ?? med.stripSize ?? 1), looseTablets: String(med.looseTablets ?? 0), pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "", expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "", notes: med.notes ?? "", intakeRemindersEnabled: med.intakeRemindersEnabled ?? false, slices: med.slices.map((s) => ({ usage: String(s.usage), every: String(s.every), start: toInputValue(s.start) })), }); } function resetForm() { setEditingId(null); setForm(defaultForm()); } function handleValueChange(key: K, value: string) { setForm((prev) => ({ ...prev, [key]: value })); } async function saveMedication(e: React.FormEvent) { e.preventDefault(); if (!form.name.trim()) return; setSaving(true); const payload = { name: form.name.trim(), genericName: form.genericName.trim() || null, takenBy: form.takenBy.trim() || null, packCount: Number(form.packCount) || 0, stripsPerPack: Math.max(1, Number(form.stripsPerPack) || 1), tabsPerStrip: Math.max(1, Number(form.tabsPerStrip) || 1), looseTablets: Math.max(0, Number(form.looseTablets) || 0), pillWeightMg: form.pillWeightMg ? Number(form.pillWeightMg) : null, expiryDate: form.expiryDate || null, notes: form.notes.trim() || null, intakeRemindersEnabled: form.intakeRemindersEnabled, slices: form.slices.map((s) => ({ usage: Number(s.usage) || 0, every: Math.max(1, Number(s.every) || 1), start: toIsoString(s.start) })), }; const method = editingId ? "PUT" : "POST"; const url = editingId ? `/api/medications/${editingId}` : "/api/medications"; await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }).catch(() => null); setSaving(false); resetForm(); loadMeds(); } async function runPlanner(e: React.FormEvent) { e.preventDefault(); setPlannerLoading(true); const body = { startDate: toIsoString(range.start), endDate: toIsoString(range.end) }; const rows = await fetch("/api/medications/usage", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }) .then((res) => res.json()) .catch(() => []) as PlannerRow[]; setPlannerRows(rows); setPlannerLoading(false); // Save to localStorage localStorage.setItem("plannerRange", JSON.stringify(range)); localStorage.setItem("plannerRows", JSON.stringify(rows)); } function resetRange() { setRange({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) }); setPlannerRows([]); localStorage.removeItem("plannerRange"); localStorage.removeItem("plannerRows"); } const [theme, setTheme] = useState<"light" | "dark">(() => { if (typeof window !== "undefined") { return (localStorage.getItem("theme") as "light" | "dark") || "dark"; } return "dark"; }); useEffect(() => { document.documentElement.setAttribute("data-theme", theme); localStorage.setItem("theme", theme); }, [theme]); function toggleTheme() { setTheme((prev) => (prev === "dark" ? "light" : "dark")); } // Page titles based on current route const pageInfo = { "/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 (
MedAssist-ng

{pageInfo.eyebrow}

{pageInfo.title}

} /> {(settings.emailEnabled || settings.shoutrrrEnabled) && (
{settings.emailEnabled && settings.shoutrrrEnabled ? "๐Ÿ””" : settings.emailEnabled ? "๐Ÿ“ง" : "๐Ÿ””"} {t('dashboard.reminders.active')} โ€” {getReminderStatusText(settings.reminderDaysBefore, coverage.low, settings.lastAutoEmailSent, settings.lastNotificationType, settings.lastNotificationChannel, t, i18n.language)} {settings.emailEnabled && settings.notificationEmail && โ†’ {settings.notificationEmail}}
)}

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

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

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

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

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

) : ( <>
{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)} {t(status.label)} {row.depletionDate ?? "-"} {getNextReminderForMed(row, settings.reminderDaysBefore, i18n.language)}
); })}
{(settings.emailEnabled || settings.shoutrrrEnabled) && (
{reminderEmailResult && ( {reminderEmailResult.message} )}
)} )}

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

{t('dashboard.overview.badge')}
{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(i18n.language, { day: "2-digit", month: "short", year: "2-digit" }) : "-"} {t(status.label)}
); })}

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

{groupedSchedule.map((day) => (
{day.dateStr}
{day.meds.map((item) => { const medCoverage = coverageByMed[item.medName]; const med = meds.find(m => m.name === item.medName); const depletionTime = depletionByMed[item.medName]; // Check if this dose is scheduled after medication runs out const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime; const status = willBeOutOfStock ? { className: "danger", label: "status.outOfStock" } : medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null; const allTaken = item.doses.every((d) => takenDoses.has(d.id)); const takenCount = item.doses.filter((d) => takenDoses.has(d.id)).length; return (
{item.medName}{med?.intakeRemindersEnabled && ๐Ÿ””}
{item.total} {t('common.pills')} {t('common.total')} {status && {t(status.label)} }
{item.doses.map((dose) => { const isTaken = takenDoses.has(dose.id); const isOverdue = dose.when < Date.now(); return (
{dose.timeStr} {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 ? ( ) : ( )}
); })}
); })}
))}
} />

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

{loading ? t('common.loading') : t('medications.list.entries', { count: meds.length })}
{meds.map((med) => (
{med.name}
{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}
{t('medications.details.total')}: {med.count} {t('common.pills')}
{med.slices.map((s, idx) => (
{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)}
))}
))}

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

{t('form.badge')}