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 Blister = { 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; blisters: Blister[]; 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 FormBlister = { usage: string; every: string; startDate: string; startTime: 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; blisters: FormBlister[]; }; const defaultBlister = (): FormBlister => { const now = new Date(); return { usage: "1", every: "1", startDate: toDateValue(now), startTime: toTimeValue(now) }; }; const defaultForm = (): FormState => ({ name: "", genericName: "", takenBy: "", packCount: "1", stripsPerPack: "1", tabsPerStrip: "1", looseTablets: "0", pillWeightMg: "", expiryDate: "", notes: "", intakeRemindersEnabled: false, blisters: [defaultBlister()] }); 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, authError } = useAuth(); const location = useLocation(); const navigate = useNavigate(); // Show loading while checking auth state if (loading) { return (

๐Ÿ’Š MedAssist

Loading...

); } // Show error if we couldn't connect to the server if (authError) { return (

๐Ÿ’Š MedAssist

Connection Error
{authError}

Please check if the server is running and try again.

); } // If auth state is null (shouldn't happen after loading, but be safe) if (!authState) { return (

๐Ÿ’Š MedAssist

Initializing...

); } // 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, logout } = 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 [showEditModal, setShowEditModal] = useState(false); 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 [pendingImage, setPendingImage] = useState(null); const [pendingImagePreview, setPendingImagePreview] = useState(null); const [selectedMed, setSelectedMed] = useState(null); const [showImageLightbox, setShowImageLightbox] = useState(false); const [selectedUser, setSelectedUser] = useState(null); const [scheduleDays, setScheduleDays] = useState(30); const [showPastDays, setShowPastDays] = useState(false); 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") { // Close modals in order of priority (topmost first) if (showImageLightbox) { setShowImageLightbox(false); } else if (showEditModal) { setShowEditModal(false); resetForm(); } else if (showShareDialog) { setShowShareDialog(false); } else if (showProfile) { setShowProfile(false); } else if (selectedUser) { setSelectedUser(null); } else if (selectedMed) { setSelectedMed(null); } } }; document.addEventListener("keydown", handleEscape); return () => document.removeEventListener("keydown", handleEscape); }, [selectedMed, showImageLightbox, selectedUser, showProfile, showShareDialog, showEditModal]); // 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, true), [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]); // Get worst stock status for a day's medications (for coloring day blocks) const getDayStockStatus = (dayMeds: { medName: string; lastWhen: number }[]) => { const statuses = dayMeds.map((item) => { const cov = coverageByMed[item.medName]; const depletionTime = depletionByMed[item.medName]; // Will be out of stock by this day? if (typeof depletionTime === "number" && item.lastWhen > depletionTime) { return "danger"; } if (!cov) return "success"; const { daysLeft, medsLeft } = cov; // Currently out of stock if (medsLeft <= 0 || daysLeft === 0) return "danger"; // No schedule (can't calculate) if (daysLeft === null) return "success"; // Low stock: < lowStockDays (warning) if (daysLeft < settings.lowStockDays) return "warning"; // Normal/High stock return "success"; }); return statuses.includes("danger") ? "danger" : statuses.includes("warning") ? "warning" : "success"; }; 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), isPast: event.isPast, 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, isPast: d.isPast, meds: Array.from(d.meds.values()) })); }, [schedule.events]); const pastDays = useMemo(() => groupedSchedule.filter(d => d.isPast), [groupedSchedule]); const futureDays = useMemo(() => groupedSchedule.filter(d => !d.isPast).slice(0, scheduleDays), [groupedSchedule, 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(); // Auto-disable email if no recipient is set const effectiveEmailEnabled = settings.emailEnabled && !!settings.notificationEmail?.trim(); // Auto-disable push if no URL is set const effectiveShoutrrrEnabled = settings.shoutrrrEnabled && !!settings.shoutrrrUrl?.trim(); // Validate email if email notifications are enabled if (effectiveEmailEnabled && 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: effectiveEmailEnabled, notificationEmail: settings.notificationEmail, reminderDaysBefore: settings.reminderDaysBefore, repeatDailyReminders: settings.repeatDailyReminders, lowStockDays: settings.lowStockDays, normalStockDays: settings.normalStockDays, highStockDays: settings.highStockDays, shoutrrrEnabled: effectiveShoutrrrEnabled, 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); // Update local state with effective values const updatedSettings = { ...settings, emailEnabled: effectiveEmailEnabled, shoutrrrEnabled: effectiveShoutrrrEnabled }; setSettings(updatedSettings); setSettingsSaving(false); setSavedSettings(updatedSettings); 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 setBlisterValue(idx: number, field: keyof FormBlister, value: string) { setForm((prev) => { const next = [...prev.blisters]; next[idx] = { ...next[idx], [field]: value }; return { ...prev, blisters: next }; }); } function addBlister() { setForm((prev) => ({ ...prev, blisters: [...prev.blisters, defaultBlister()] })); } function removeBlister(idx: number) { setForm((prev) => ({ ...prev, blisters: prev.blisters.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, blisters: med.blisters.map((s) => ({ usage: String(s.usage), every: String(s.every), startDate: toDateValue(s.start), startTime: toTimeValue(s.start) })), }); // Show modal on mobile if (window.innerWidth <= 768) { setShowEditModal(true); } } function resetForm() { setEditingId(null); setShowEditModal(false); setPendingImage(null); setPendingImagePreview(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, blisters: form.blisters.map((s) => ({ usage: Number(s.usage) || 0, every: Math.max(1, Number(s.every) || 1), start: toIsoString(combineDateAndTime(s.startDate, s.startTime)) })), }; const method = editingId ? "PUT" : "POST"; const url = editingId ? `/api/medications/${editingId}` : "/api/medications"; try { const res = await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); // If creating new medication and we have a pending image, upload it if (!editingId && pendingImage && res.ok) { const newMed = await res.json(); if (newMed?.id) { await uploadMedImage(newMed.id, pendingImage); } } } catch { // ignore } 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}

{/* Settings button only shown when auth is disabled (no user dropdown available) */} {!authState?.authEnabled && ( )} {authState?.authEnabled && user && (
{user.avatarUrl ? ( {user.username} ) : (
{user.username.charAt(0).toUpperCase()}
)} {user.username}
)}
{/* 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')}

{(() => { 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" : "success-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) && ( {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('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" : "success-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) && ( {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) && ( )}
{/* Past days toggle */} {pastDays.length > 0 && (() => { const totalPastDoses = pastDays.flatMap(d => d.meds.flatMap(m => m.doses.map(dose => dose.id))); const missedPastDoses = totalPastDoses.filter(id => !takenDoses.has(id)).length; return (
0 ? 'has-missed' : ''}`} onClick={() => setShowPastDays(!showPastDays)} > {showPastDays ? 'โ–ผ' : 'โ–ถ'} {showPastDays ? t('dashboard.schedules.hidePastDays') : t('dashboard.schedules.showPastDays')} ({t('dashboard.schedules.pastDaysCount', { count: pastDays.length })}) {missedPastDoses > 0 ? ( โš ๏ธ {missedPastDoses} ) : totalPastDoses.length > 0 ? ( โœ“ ) : null}
); })()} {/* Past days (when expanded) */} {showPastDays && pastDays.map((day) => { 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; const isAutoCollapsed = true; // Past days are always auto-collapsed const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr); const isCollapsed = !isManuallyExpanded; const worstStatus = getDayStockStatus(day.meds); 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 med = meds.find(m => m.name === item.medName); const allTaken = item.doses.every((d) => takenDoses.has(d.id)); return (
{item.medName}{med?.intakeRemindersEnabled && ๐Ÿ””}
{item.total} {t('common.pills')} {t('common.total')}
{item.doses.map((dose) => { const isTaken = takenDoses.has(dose.id); 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 ? ( ) : ( )}
); })}
); })}
); })} {/* Current and future days */} {futureDays.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; // Calculate worst stock status for this day const dayStockStatuses = day.meds.map((item) => { const medCoverage = coverageByMed[item.medName]; const depletionTime = depletionByMed[item.medName]; const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime; if (willBeOutOfStock) return "danger"; if (!medCoverage) return "success"; const status = getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings); return status.className; }); const worstStatus = dayStockStatuses.includes("danger") ? "danger" : dayStockStatuses.includes("warning") ? "warning" : "success"; // 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')}

{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.blisters.map((s, idx) => (
{s.usage} {s.usage === 1 ? t('common.pill') : t('common.pills')} ยท {t('form.blisters.every')} {s.every} {s.every === 1 ? t('common.day') : t('common.days')} ยท {t('form.blisters.from')} {formatDateTime(s.start, i18n.language)}
))}
))}

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