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[]; packCount: number; blistersPerPack: number; pillsPerBlister: 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; blisterSize: number; blistersNeeded: number; fullBlisters: number; loosePills: number; enough: boolean; }; type RefillEntry = { id: number; packsAdded: number; loosePillsAdded: number; refillDate: string; }; type FormBlister = { usage: string; every: string; startDate: string; startTime: string }; type FormState = { name: string; genericName: string; takenBy: string[]; // Changed from string to array packCount: string; blistersPerPack: string; pillsPerBlister: 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", blistersPerPack: "1", pillsPerBlister: "1", looseTablets: "0", pillWeightMg: "", expiryDate: "", notes: "", intakeRemindersEnabled: false, blisters: [defaultBlister()] }); // Field validation limits (must match backend) const FIELD_LIMITS = { name: { min: 1, max: 100 }, genericName: { max: 100 }, takenBy: { max: 100 }, notes: { max: 2000 } } as const; type FieldErrors = { name?: string; genericName?: string; takenBy?: string; notes?: string; }; 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 [formSaved, setFormSaved] = useState(false); const [originalForm, setOriginalForm] = useState(defaultForm()); const [editingId, setEditingId] = useState(null); const [showEditModal, setShowEditModal] = useState(false); const [form, setForm] = useState(defaultForm()); const [fieldErrors, setFieldErrors] = useState({}); const [range, setRange] = useState<{ start: string; end: string }>({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) }); // Validate form fields const validateField = (field: keyof FieldErrors, value: string | string[]): string | undefined => { const limits = FIELD_LIMITS[field]; // Skip validation for takenBy array (individual items validated on add) if (field === 'takenBy') return undefined; const strValue = typeof value === 'string' ? value : ''; if (field === 'name' && (!strValue || strValue.trim().length === 0)) { return t('common.validation.required'); } if ('max' in limits && strValue.length > limits.max) { return t('common.validation.maxLength', { max: limits.max, current: strValue.length }); } return undefined; }; // Check if form has any errors const hasValidationErrors = useMemo(() => { return Object.values(fieldErrors).some(error => error !== undefined); }, [fieldErrors]); // Check if form has been modified from original state const formChanged = useMemo(() => { return JSON.stringify(form) !== JSON.stringify(originalForm); }, [form, originalForm]); // Reset formSaved when form changes useEffect(() => { if (formChanged) { setFormSaved(false); } }, [formChanged]); // Validate all fields when form changes useEffect(() => { const errors: FieldErrors = {}; (['name', 'genericName', 'notes'] as const).forEach(field => { const error = validateField(field, form[field]); if (error) errors[field] = error; }); setFieldErrors(errors); }, [form.name, form.genericName, form.notes, t]); // 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)) }); } } else { setPlannerRows([]); 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, skipRemindersForTakenDoses: false, repeatRemindersEnabled: false, reminderRepeatIntervalMinutes: 30, maxNaggingReminders: 5, 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, // Stock calculation mode: "automatic" or "manual" stockCalculationMode: "automatic" as "automatic" | "manual", // 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 [scheduleLightboxImage, setScheduleLightboxImage] = useState(null); const [selectedUser, setSelectedUser] = useState(null); const [scheduleDays, setScheduleDays] = useState(30); const [showPastDays, setShowPastDays] = useState(false); const [takenDoses, setTakenDoses] = useState>(new Set()); const [dismissedDoses, setDismissedDoses] = useState>(new Set()); // Clear missed doses confirmation dialog const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false); const [clearingMissed, setClearingMissed] = useState(false); // Tag input state for "Taken By" field const [takenByInput, setTakenByInput] = useState(""); // 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); // Export/Import state const [exporting, setExporting] = useState(false); const [importing, setImporting] = useState(false); // User dropdown state (for mobile click-based behavior) const [userDropdownOpen, setUserDropdownOpen] = useState(false); const [showImportConfirm, setShowImportConfirm] = useState(false); const [pendingImportData, setPendingImportData] = useState(null); // Refill state const [showRefillModal, setShowRefillModal] = useState(false); const [refillPacks, setRefillPacks] = useState(1); const [refillLoose, setRefillLoose] = useState(0); const [refillSaving, setRefillSaving] = useState(false); const [refillHistory, setRefillHistory] = useState([]); const [refillHistoryExpanded, setRefillHistoryExpanded] = 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 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 { collapsed, expanded } = loadCollapsedDaysFromStorage( userStorageKey(user.id, "collapsedDays"), userStorageKey(user.id, "expandedDays") ); setManuallyCollapsedDays(collapsed); setManuallyExpandedDays(expanded); } }, [user?.id]); // Poll for taken doses from server (works with or without auth) useEffect(() => { async function loadTakenDoses() { try { const res = await fetch("/api/doses/taken", { credentials: "include" }); if (res.ok) { const data = await res.json(); const taken = new Set(); const dismissed = new Set(); for (const d of data.doses) { if (d.dismissed) { dismissed.add(d.doseId); } else { taken.add(d.doseId); } } setTakenDoses(taken); setDismissedDoses(dismissed); } // 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); }, []); // Get dose ID with optional person suffix function getDoseId(baseDoseId: string, person: string | null): string { return person ? `${baseDoseId}-${person}` : baseDoseId; } // Count taken doses for a day/item function countTakenDoses(doses: Array<{ id: string; takenBy: string[] }>): { total: number; taken: number } { let total = 0; let taken = 0; for (const d of doses) { const people = (d.takenBy || []).length > 0 ? d.takenBy : [null]; for (const person of people) { total++; if (takenDoses.has(getDoseId(d.id, person))) taken++; } } return { total, taken }; } 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; }); } } // Dismiss missed doses without deducting from stock async function dismissMissedDoses(doseIds: string[]) { if (doseIds.length === 0) return; setClearingMissed(true); try { const res = await fetch("/api/doses/dismiss", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ doseIds }), }); if (res.ok) { // Update local state - move these from neither set to dismissed set setDismissedDoses((prev) => { const next = new Set(prev); for (const id of doseIds) next.add(id); return next; }); setShowClearMissedConfirm(false); } } catch { // Error - dialog stays open } finally { setClearingMissed(false); } } // Close modal on Escape key useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape") { // Close modals in order of priority (topmost first) if (userDropdownOpen) { setUserDropdownOpen(false); } else if (scheduleLightboxImage) { closeScheduleLightbox(); } else if (showImageLightbox) { closeImageLightbox(); } else if (showRefillModal) { closeRefillModal(); } else if (showEditModal) { closeEditModal(); resetForm(); } else if (showShareDialog) { closeShareDialog(); } else if (showProfile) { closeProfile(); } else if (selectedUser) { closeUserFilter(); } else if (selectedMed) { closeMedDetail(); } } }; document.addEventListener("keydown", handleEscape); return () => document.removeEventListener("keydown", handleEscape); }, [selectedMed, showImageLightbox, scheduleLightboxImage, selectedUser, showProfile, showShareDialog, showEditModal, showRefillModal, userDropdownOpen]); // Handle browser back button to close modals (in priority order) useEffect(() => { const handlePopState = () => { // Close modals in order of priority (topmost first) // NOTE: This handler MUST NOT call history.back() or it will cause infinite loops // Only use direct state setters here if (showImageLightbox) { setShowImageLightbox(false); } else if (scheduleLightboxImage) { setScheduleLightboxImage(null); } else if (showRefillModal) { setShowRefillModal(false); } else if (showEditModal) { setShowEditModal(false); resetForm(); } else if (showShareDialog) { resetShareDialogState(); } else if (showProfile) { setShowProfile(false); } else if (selectedUser) { setSelectedUser(null); } else if (selectedMed) { setSelectedMed(null); } }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); }, [selectedMed, showImageLightbox, scheduleLightboxImage, selectedUser, showProfile, showShareDialog, showEditModal, showRefillModal]); // Close user dropdown when clicking outside useEffect(() => { if (!userDropdownOpen) return; const handleClickOutside = (e: MouseEvent) => { const target = e.target as HTMLElement; if (!target.closest('.user-menu')) { setUserDropdownOpen(false); } }; document.addEventListener("click", handleClickOutside); return () => document.removeEventListener("click", handleClickOutside); }, [userDropdownOpen]); // Close tooltips on scroll/touch (for mobile) useEffect(() => { const closeAllTooltips = () => { document.querySelectorAll('.info-tooltip.tooltip-active').forEach(el => { el.classList.remove('tooltip-active'); }); }; const handleTooltipClick = (e: Event) => { const target = e.target as HTMLElement; if (target.classList.contains('info-tooltip')) { // Close other tooltips first closeAllTooltips(); // Toggle this one target.classList.add('tooltip-active'); } else { closeAllTooltips(); } }; const handleTouchMove = () => { closeAllTooltips(); }; document.addEventListener('click', handleTooltipClick, { capture: true }); document.addEventListener('touchmove', handleTouchMove, { passive: true }); document.addEventListener('scroll', handleTouchMove, { passive: true }); return () => { document.removeEventListener('click', handleTooltipClick, { capture: true }); document.removeEventListener('touchmove', handleTouchMove); document.removeEventListener('scroll', handleTouchMove); }; }, []); // Prevent background scroll when modal is open useEffect(() => { const isModalOpen = selectedMed || selectedUser || showProfile || showShareDialog || showEditModal; if (isModalOpen) { const scrollY = window.scrollY; document.body.classList.add('modal-open'); document.body.style.top = `-${scrollY}px`; } else { const scrollY = document.body.style.top; document.body.classList.remove('modal-open'); document.body.style.top = ''; if (scrollY) { window.scrollTo(0, parseInt(scrollY || '0', 10) * -1); } } return () => { document.body.classList.remove('modal-open'); document.body.style.top = ''; }; }, [selectedMed, selectedUser, showProfile, showShareDialog, showEditModal]); // Update selectedMed when meds change (e.g., after refill) useEffect(() => { if (selectedMed) { const updated = meds.find(m => m.id === selectedMed.id); if (updated && ( updated.packCount !== selectedMed.packCount || updated.looseTablets !== selectedMed.looseTablets || updated.updatedAt !== selectedMed.updatedAt )) { setSelectedMed(updated); } } }, [meds, selectedMed]); // 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, settings.stockCalculationMode, takenDoses), [meds, schedule.events, i18n.language, settings.reminderDaysBefore, settings.stockCalculationMode, takenDoses]); 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 all unique people from medications for autocomplete suggestions const existingPeople = useMemo(() => { const allPeople = meds.flatMap(m => m.takenBy || []); return [...new Set(allPeople)].filter(Boolean).sort(); }, [meds]); // 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; takenBy: string[] }; 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, takenBy: event.takenBy || [] }); 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]); // Calculate missed past dose IDs for the "Clear missed" feature const missedPastDoseIds = useMemo(() => { const totalPastDoses = pastDays.flatMap(d => d.meds.flatMap(m => m.doses.flatMap(dose => (dose.takenBy || []).length > 0 ? dose.takenBy.map((p: string) => `${dose.id}-${p}`) : [dose.id] ) ) ); return totalPastDoses.filter(id => !takenDoses.has(id) && !dismissedDoses.has(id)); }, [pastDays, takenDoses, dismissedDoses]); // Load medications and settings when user changes (or on initial mount) useEffect(() => { loadMeds(); loadSettings(); // Reset planner when user changes setPlannerRows([]); setRange({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) }); }, [user?.id]); function loadMeds() { setLoading(true); fetch("/api/medications") .then((res) => res.json()) .then((data) => setMeds(Array.isArray(data) ? 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, skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses, repeatRemindersEnabled: settings.repeatRemindersEnabled, reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes, maxNaggingReminders: settings.maxNaggingReminders ?? 5, 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, // Stock calculation mode stockCalculationMode: settings.stockCalculationMode, // 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); } // Load refill history for a medication async function loadRefillHistory(medId: number) { try { const res = await fetch(`/api/medications/${medId}/refills`, { credentials: "include" }); if (res.ok) { const data = await res.json(); setRefillHistory(Array.isArray(data) ? data : (data.refills || [])); } else { setRefillHistory([]); } } catch { setRefillHistory([]); } } // Submit a refill async function submitRefill(medId: number) { if (refillPacks < 1 && refillLoose < 1) return; setRefillSaving(true); try { const res = await fetch(`/api/medications/${medId}/refill`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ packsAdded: refillPacks, loosePillsAdded: refillLoose }), }); if (res.ok) { const data = await res.json(); // Update form values if we're in edit mode if (editingId === medId && data.newStock) { setForm(f => ({ ...f, packCount: String(data.newStock.packCount), looseTablets: String(data.newStock.looseTablets), })); } // Reset refill form setRefillPacks(1); setRefillLoose(0); // Close refill modal via history back for proper back-button support if (showRefillModal) { window.history.back(); } // Reload medications to get updated stock loadMeds(); // Reload refill history await loadRefillHistory(medId); } } catch { // ignore } setRefillSaving(false); } // Helper to open medication detail modal with refill history function openMedDetail(med: Medication) { setSelectedMed(med); setRefillHistory([]); setRefillHistoryExpanded(false); loadRefillHistory(med.id); // Push history state so browser back closes modal instead of navigating window.history.pushState({ modal: 'medDetail', medId: med.id }, ''); } // Helper to close medication detail modal via history back function closeMedDetail() { if (selectedMed) { window.history.back(); } } // Modal helper functions for browser back button support function openImageLightbox() { setShowImageLightbox(true); window.history.pushState({ modal: 'imageLightbox' }, ''); } function closeImageLightbox() { if (showImageLightbox) { window.history.back(); } } function openScheduleLightbox(imageUrl: string) { setScheduleLightboxImage(imageUrl); window.history.pushState({ modal: 'scheduleLightbox' }, ''); } function closeScheduleLightbox() { if (scheduleLightboxImage) { window.history.back(); } } function openRefillModal() { setShowRefillModal(true); window.history.pushState({ modal: 'refill' }, ''); } function closeRefillModal() { if (showRefillModal) { window.history.back(); } } function openEditModal() { setShowEditModal(true); window.history.pushState({ modal: 'edit' }, ''); } function closeEditModal() { if (showEditModal) { window.history.back(); } } function openProfile() { setShowProfile(true); window.history.pushState({ modal: 'profile' }, ''); } function closeProfile() { if (showProfile) { window.history.back(); } } function openUserFilter(person: string) { setSelectedUser(person); window.history.pushState({ modal: 'userFilter', person }, ''); } function closeUserFilter() { if (selectedUser) { window.history.back(); } } 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); } // Export data to JSON file async function handleExport() { setExporting(true); try { const res = await fetch('/api/export?includeSensitive=true', { credentials: "include", }); if (!res.ok) throw new Error("Export failed"); const data = await res.json(); // Create download const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); const dateStr = new Date().toISOString().split("T")[0]; a.href = url; a.download = `${t('exportImport.downloadFilename')}-${dateStr}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (err) { console.error("Export error:", err); } setExporting(false); } // Handle file selection for import function handleImportFileSelect(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = (event) => { try { const data = JSON.parse(event.target?.result as string); if (!data.version || !data.exportedAt) { alert(t('exportImport.invalidFile')); return; } setPendingImportData(data); setShowImportConfirm(true); } catch { alert(t('exportImport.invalidFile')); } }; reader.readAsText(file); // Reset file input e.target.value = ""; } // Confirm and execute import async function handleImportConfirm() { if (!pendingImportData) return; setImporting(true); setShowImportConfirm(false); try { const res = await fetch("/api/import", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify(pendingImportData), }); if (!res.ok) { const err = await res.json(); alert(t('exportImport.importError') + ": " + (err.error || "Unknown error")); return; } const result = await res.json(); alert(t('exportImport.importSuccess') + "\n" + t('exportImport.importSuccessDetails', { medications: result.imported.medications, doses: result.imported.doseHistory, shares: result.imported.shareLinks, })); // Reload all data loadMeds(); loadSettings(); loadTakenDoses(); } catch (err) { console.error("Import error:", err); alert(t('exportImport.importError')); } setPendingImportData(null); setImporting(false); } // Helper function to load taken doses (extracted from useEffect) 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))); } } catch { // Silently fail } } 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); setTakenByInput(""); // Clear tag input when starting edit setFormSaved(false); const editForm: FormState = { name: med.name, genericName: med.genericName ?? "", takenBy: med.takenBy || [], // Already an array from API packCount: String(med.packCount), blistersPerPack: String(med.blistersPerPack), pillsPerBlister: String(med.pillsPerBlister), looseTablets: String(med.looseTablets), 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) })), }; setForm(editForm); setOriginalForm(editForm); // Show modal on mobile if (window.innerWidth <= 768) { openEditModal(); } } function resetForm() { setEditingId(null); setShowEditModal(false); setPendingImage(null); setPendingImagePreview(null); setTakenByInput(""); setFormSaved(false); const newForm = defaultForm(); setForm(newForm); setOriginalForm(newForm); } function handleValueChange(key: K, value: string) { setForm((prev) => ({ ...prev, [key]: value })); } // Tag input helpers for "Taken By" field function addTakenByPerson(name: string) { const trimmed = name.trim(); if (trimmed && trimmed.length <= FIELD_LIMITS.takenBy.max && !form.takenBy.includes(trimmed)) { setForm(prev => ({ ...prev, takenBy: [...prev.takenBy, trimmed] })); } setTakenByInput(""); } function removeTakenByPerson(name: string) { setForm(prev => ({ ...prev, takenBy: prev.takenBy.filter(p => p !== name) })); } function handleTakenByKeyDown(e: React.KeyboardEvent) { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addTakenByPerson(takenByInput); } else if (e.key === 'Backspace' && !takenByInput && form.takenBy.length > 0) { // Remove last tag on backspace when input is empty removeTakenByPerson(form.takenBy[form.takenBy.length - 1]); } } 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.filter(name => name.trim()), // Send array, filter empty strings packCount: Number(form.packCount) || 0, blistersPerPack: Math.max(1, Number(form.blistersPerPack) || 1), pillsPerBlister: Math.max(1, Number(form.pillsPerBlister) || 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"; const wasEditing = editingId; 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 (!wasEditing && pendingImage && res.ok) { const newMed = await res.json(); if (newMed?.id) { await uploadMedImage(newMed.id, pendingImage); } } // Mark as saved and update original form to current state if (res.ok) { setFormSaved(true); setOriginalForm(form); } } catch { // ignore } setSaving(false); // Only reset form if creating new medication, not when editing if (!wasEditing) { resetForm(); } else { // Close modal on mobile after edit (via history back for proper back-button support) if (showEditModal) { window.history.back(); } } 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); window.history.pushState({ modal: 'share' }, ''); setShareLink(null); setShareCopied(false); setShareSelectedPerson(""); setShareSelectedDays(30); // Get unique takenBy people from all medications (flatten arrays) const allPeople = meds.flatMap(m => m.takenBy || []); const uniquePeople = [...new Set(allPeople)].filter(Boolean).sort(); 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() { if (showShareDialog) { window.history.back(); } } // Internal function to reset share dialog state (called by popstate handler) function resetShareDialogState() { 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 && (
closeProfile()}>
e.stopPropagation()}> closeProfile()} />
)} } /> {(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?.pillsPerBlister ?? 1, med?.looseTablets ?? 0, med ? med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets : Math.round(row.medsLeft) ); return (
med && openMedDetail(med)}> {row.name} {med?.takenBy && med.takenBy.length > 0 && med.takenBy.map((person) => ( { e.stopPropagation(); openUserFilter(person); }}>{person} ))} {(med?.intakeRemindersEnabled || med?.notes) && ( {med?.intakeRemindersEnabled && ๐Ÿ””} {med?.notes && ๐Ÿ“} )} {formatFullBlisters(stock.fullBlisters, t)} {formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, med?.pillsPerBlister ?? 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?.pillsPerBlister ?? 1, med?.looseTablets ?? 0, med ? med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets : Math.round(row.medsLeft) ); return (
med && openMedDetail(med)}> {row.name} {med?.takenBy && med.takenBy.length > 0 && med.takenBy.map((person) => ( { e.stopPropagation(); openUserFilter(person); }}>{person} ))} {(med?.intakeRemindersEnabled || med?.notes) && ( {med?.intakeRemindersEnabled && ๐Ÿ””} {med?.notes && ๐Ÿ“} )} {formatFullBlisters(stock.fullBlisters, t)} {formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, med?.pillsPerBlister ?? 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 && m.takenBy.length > 0) && ( )}
{/* Past days toggle */} {pastDays.length > 0 && (() => { const missedCount = missedPastDoseIds.length; const totalPastDoses = pastDays.flatMap(d => d.meds.flatMap(m => m.doses.flatMap(dose => (dose.takenBy || []).length > 0 ? dose.takenBy.map(p => `${dose.id}-${p}`) : [dose.id]))); return (
0 ? 'has-missed' : ''}`} onClick={() => setShowPastDays(!showPastDays)} > {showPastDays ? 'โ–ผ' : 'โ–ถ'} {showPastDays ? t('dashboard.schedules.hidePastDays') : t('dashboard.schedules.showPastDays')} ({t('dashboard.schedules.pastDaysCount', { count: pastDays.length })}) {missedCount > 0 ? ( โš ๏ธ {missedCount} ) : totalPastDoses.length > 0 ? ( โœ“ ) : null}
{missedCount > 0 && ( )}
); })()} {/* Past days (when expanded) */} {showPastDays && pastDays.map((day) => { const allDoseIds = day.meds.flatMap((item) => item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id])); const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id) || dismissedDoses.has(id)); const takenCount = allDoseIds.filter((id) => takenDoses.has(id) || dismissedDoses.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 medCov = coverageByMed[item.medName]; const isEmpty = medCov ? medCov.medsLeft <= 0 : false; const itemDoseIds = item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]); const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); return (
med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`)} >
{item.medName}{med?.intakeRemindersEnabled && ๐Ÿ””}
{item.total} {t('common.pills')} {t('common.total')}
{item.doses.map((dose) => { // If no takenBy, show single checkbox; otherwise show one per person const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null]; return (
{dose.timeStr} {dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}
{people.map((person) => { const doseId = getDoseId(dose.id, person); const isTaken = takenDoses.has(doseId); return (
{person && openUserFilter(person)}>{person}} {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.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [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]; const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false; // 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 itemDoseIds = item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]); const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); return (
med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`)} >
{item.medName}{med?.intakeRemindersEnabled && ๐Ÿ””}
{item.total} {t('common.pills')} {t('common.total')} {status && {t(status.label)} }
{item.doses.map((dose) => { 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(); // If no takenBy, show single checkbox; otherwise show one per person const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null]; const allTaken = people.every((person) => takenDoses.has(getDoseId(dose.id, person))); return (
{dose.timeStr} {dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}
{people.map((person) => { const doseId = getDoseId(dose.id, person); const isTaken = takenDoses.has(doseId); return (
{person && openUserFilter(person)}>{person}} {isTaken ? ( ) : ( )}
); })}
); })}
); })}
); })}
{/* Clear Missed Doses Confirmation Modal */} {showClearMissedConfirm && (
setShowClearMissedConfirm(false)}>
e.stopPropagation()} style={{maxWidth: "450px"}}>

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

{t('dashboard.schedules.clearMissedConfirmMessage', { count: missedPastDoseIds.length })}

)} } />

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

{meds.map((med) => (
{med.name}
{t('medications.details.packs')}: {med.packCount} {t('medications.details.blisters')}: {med.blistersPerPack} {t('medications.details.pillsPerBlister')}: {med.pillsPerBlister} {t('medications.details.loose')}: {med.looseTablets}
{t('medications.details.total')}: {med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets} {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')}

{editingId && ( )}
{/* Refill section - only shown when editing */} {editingId && (

{t('refill.title')}

{(refillPacks > 0 || refillLoose > 0) && ( +{refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) + refillLoose} {t('common.pills')} )}
)}