import { useEffect, useMemo, useState } from "react"; import { Routes, Route, useNavigate, useLocation, Navigate, useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { AuthProvider, useAuth, AuthPage, UserProfile } from "./components/Auth"; 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; fullBlisters: number; loosePills: 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; }; // ============================================================================= // Main App Wrapper with Auth // ============================================================================= export default function App() { return ( {/* Public share route - accessible without auth */} } /> {/* All other routes go through AppRouter */} } /> ); } function AppRouter() { const { user, authState, loading } = useAuth(); const location = useLocation(); const navigate = useNavigate(); // Show loading while checking auth state if (loading) { return (

๐Ÿ’Š MedAssist

Loading...

); } // If auth is enabled if (authState?.authEnabled) { // Need to register first user if (authState.needsSetup) { return ; } // Not logged in if (!user) { return ; } } // Auth disabled or user is logged in - show main app return ; } // ============================================================================= // Main App Content // ============================================================================= // Helper for user-specific localStorage keys function userStorageKey(userId: number | undefined, key: string): string { return userId ? `user_${userId}_${key}` : key; } function AppContent() { const { t, i18n } = useTranslation(); const { user, authState } = useAuth(); const [showProfile, setShowProfile] = useState(false); const [meds, setMeds] = useState([]); const [plannerRows, setPlannerRows] = useState([]); 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 }>({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) }); // Load user-specific planner data when user changes useEffect(() => { if (typeof window !== "undefined" && user?.id) { const savedRows = localStorage.getItem(userStorageKey(user.id, "plannerRows")); const savedRange = localStorage.getItem(userStorageKey(user.id, "plannerRange")); if (savedRows) { try { setPlannerRows(JSON.parse(savedRows)); } catch { setPlannerRows([]); } } else { setPlannerRows([]); } if (savedRange) { try { setRange(JSON.parse(savedRange)); } catch { /* keep default */ } } else { setRange({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) }); } } }, [user?.id]); 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, // Admin settings (from .env, read-only) expiryWarningDays: 30, }); 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(30); const [takenDoses, setTakenDoses] = useState>(new Set()); // Share dialog state const [showShareDialog, setShowShareDialog] = useState(false); const [sharePeople, setSharePeople] = useState([]); const [shareSelectedPerson, setShareSelectedPerson] = useState(""); const [shareSelectedDays, setShareSelectedDays] = useState(30); const [shareGenerating, setShareGenerating] = useState(false); const [shareLink, setShareLink] = useState(null); const [shareCopied, setShareCopied] = useState(false); // Collapsed days state (manually collapsed days are persisted) const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState>(new Set()); const [manuallyExpandedDays, setManuallyExpandedDays] = useState>(new Set()); // Load user-specific scheduleDays and takenDoses when user changes useEffect(() => { if (typeof window !== "undefined" && user?.id) { const storedDays = localStorage.getItem(userStorageKey(user.id, "scheduleDays")); setScheduleDays(storedDays ? Number(storedDays) : 30); // Load manually collapsed/expanded days from localStorage const storedCollapsed = localStorage.getItem(userStorageKey(user.id, "collapsedDays")); const storedExpanded = localStorage.getItem(userStorageKey(user.id, "expandedDays")); try { setManuallyCollapsedDays(storedCollapsed ? new Set(JSON.parse(storedCollapsed)) : new Set()); setManuallyExpandedDays(storedExpanded ? new Set(JSON.parse(storedExpanded)) : new Set()); } catch { setManuallyCollapsedDays(new Set()); setManuallyExpandedDays(new Set()); } // Load taken doses from server async function loadTakenDoses() { try { const res = await fetch("/api/doses/taken", { credentials: "include" }); if (res.ok) { const data = await res.json(); setTakenDoses(new Set(data.doses.map((d: { doseId: string }) => d.doseId))); } // Don't reset on error - keep current state } catch { // Don't reset on error - keep current state } } loadTakenDoses(); // Poll for updates every 5 seconds (real-time sync with share links) const interval = setInterval(loadTakenDoses, 5000); return () => clearInterval(interval); } }, [user?.id]); async function markDoseTaken(doseId: string) { // Optimistic update setTakenDoses((prev) => { const next = new Set(prev); next.add(doseId); return next; }); // Send to server try { await fetch("/api/doses/taken", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ doseId }), }); } catch { // Revert on error setTakenDoses((prev) => { const next = new Set(prev); next.delete(doseId); return next; }); } } async function undoDoseTaken(doseId: string) { // Optimistic update setTakenDoses((prev) => { const next = new Set(prev); next.delete(doseId); return next; }); // Send to server try { await fetch(`/api/doses/taken/${encodeURIComponent(doseId)}`, { method: "DELETE", credentials: "include", }); } catch { // Revert on error setTakenDoses((prev) => { const next = new Set(prev); next.add(doseId); 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, 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(() => { 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, date: new Date(event.when), 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, date: d.date, 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 user-specific localStorage if (user?.id) { localStorage.setItem(userStorageKey(user.id, "plannerRange"), JSON.stringify(range)); localStorage.setItem(userStorageKey(user.id, "plannerRows"), JSON.stringify(rows)); } } function resetRange() { setRange({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) }); setPlannerRows([]); if (user?.id) { localStorage.removeItem(userStorageKey(user.id, "plannerRange")); localStorage.removeItem(userStorageKey(user.id, "plannerRows")); } } // Share dialog functions async function openShareDialog() { setShowShareDialog(true); setShareLink(null); setShareCopied(false); setShareSelectedPerson(""); setShareSelectedDays(30); // Get unique takenBy people from medications const uniquePeople = [...new Set(meds.map(m => m.takenBy).filter(Boolean))] as string[]; setSharePeople(uniquePeople); if (uniquePeople.length > 0) { setShareSelectedPerson(uniquePeople[0]); } } async function generateShareLink() { if (!shareSelectedPerson) return; setShareGenerating(true); setShareCopied(false); try { const res = await fetch("/api/share", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ takenBy: shareSelectedPerson, scheduleDays: shareSelectedDays, }), }); if (res.ok) { const data = await res.json(); const fullUrl = `${window.location.origin}/share/${data.token}`; setShareLink(fullUrl); } else { const err = await res.json(); alert(err.error || "Failed to generate share link"); } } catch { alert("Failed to generate share link"); } finally { setShareGenerating(false); } } function copyShareLink() { if (shareLink) { navigator.clipboard.writeText(shareLink); setShareCopied(true); setTimeout(() => setShareCopied(false), 2000); } } function closeShareDialog() { setShowShareDialog(false); setShareLink(null); setShareCopied(false); } // Toggle day collapse/expand function toggleDayCollapse(dateStr: string, isAutoCollapsed: boolean) { if (isAutoCollapsed) { // Day is auto-collapsed (all taken) - toggle the expanded override setManuallyExpandedDays((prev) => { const next = new Set(prev); if (next.has(dateStr)) { next.delete(dateStr); } else { next.add(dateStr); } if (user?.id) localStorage.setItem(userStorageKey(user.id, "expandedDays"), JSON.stringify([...next])); return next; }); } else { // Day is not auto-collapsed - toggle manual collapse setManuallyCollapsedDays((prev) => { const next = new Set(prev); if (next.has(dateStr)) { next.delete(dateStr); } else { next.add(dateStr); } if (user?.id) localStorage.setItem(userStorageKey(user.id, "collapsedDays"), JSON.stringify([...next])); return next; }); } } 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}

{authState?.authEnabled && user && ( )}
{/* Profile Modal */} {showProfile && (
setShowProfile(false)}>
e.stopPropagation()}> setShowProfile(false)} />
)} } /> {(settings.emailEnabled || settings.shoutrrrEnabled) && (
{settings.emailEnabled && settings.shoutrrrEnabled ? "๐Ÿ””" : settings.emailEnabled ? "๐Ÿ“ง" : "๐Ÿ””"} {t('dashboard.reminders.active')} โ€” {getReminderStatusText(settings.reminderDaysBefore, settings.lowStockDays, coverage.low, coverage.all, settings.lastAutoEmailSent, settings.lastNotificationType, settings.lastNotificationChannel, t, i18n.language)} {settings.emailEnabled && settings.notificationEmail && โ†’ {settings.notificationEmail}}
)}

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

{t('dashboard.reorder.badge')}
{(() => { 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.fullBlisters')} {t('table.openBlister')} {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" : ""; const stock = getBlisterStock( Math.round(row.medsLeft), med?.tabsPerStrip ?? 1, med?.looseTablets ?? 0, med?.count ?? Math.round(row.medsLeft) ); return (
med && setSelectedMed(med)}> {row.name}{med?.takenBy && { e.stopPropagation(); setSelectedUser(med.takenBy!); }}>{med.takenBy}}{med?.intakeRemindersEnabled && ๐Ÿ””}{med?.notes && ๐Ÿ“} {formatFullBlisters(stock.fullBlisters, t)} {formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, med?.tabsPerStrip ?? 1, t)} {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.fullBlisters')} {t('table.openBlister')} {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, settings.expiryWarningDays); const textClass = status.className === "danger" ? "danger-text" : status.className === "warning" ? "warning-text" : ""; const stock = getBlisterStock( Math.round(row.medsLeft), med?.tabsPerStrip ?? 1, med?.looseTablets ?? 0, med?.count ?? Math.round(row.medsLeft) ); return (
med && setSelectedMed(med)}> {row.name}{med?.takenBy && { e.stopPropagation(); setSelectedUser(med.takenBy!); }}>{med.takenBy}}{med?.intakeRemindersEnabled && ๐Ÿ””}{med?.notes && ๐Ÿ“} {formatFullBlisters(stock.fullBlisters, t)} {formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, med?.tabsPerStrip ?? 1, t)} {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)}
); })}

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

{meds.some(m => m.takenBy) && ( )}
{groupedSchedule.map((day) => { // Check if all doses in this day are taken (auto-collapse) const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id)); const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id)); const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length; // Check if this is today, past, or future const today = new Date(); today.setHours(0, 0, 0, 0); const dayDate = new Date(day.date); dayDate.setHours(0, 0, 0, 0); const isToday = dayDate.getTime() === today.getTime(); // Determine if day should be collapsed: only today is expanded by default const isAutoCollapsed = allDayTaken || !isToday; const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr); const isManuallyCollapsed = manuallyCollapsedDays.has(day.dateStr); const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed; return (
toggleDayCollapse(day.dateStr, isAutoCollapsed)} title={isCollapsed ? t('common.expand') : t('common.collapse')} > {isCollapsed ? "โ–ถ" : "โ–ผ"} {day.dateStr} {allDayTaken ? ( โœ“ {t('dashboard.schedules.allTaken')} ) : ( {takenCount}/{allDoseIds.length} )}
{!isCollapsed && 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)); 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(); // Only disable doses on future DAYS, not later today const doseDate = new Date(dose.when); doseDate.setHours(0, 0, 0, 0); const todayMidnight = new Date(); todayMidnight.setHours(0, 0, 0, 0); const isFutureDose = doseDate.getTime() > todayMidnight.getTime(); 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')}