diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ef391b0..45dbaae 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -222,6 +222,8 @@ The `main` branch is protected - releases must go through the automated release > ⚠️ **MANDATORY**: GitHub Releases MUST contain a written message! > Not just auto-generated commit lists, but a brief descriptive text. +**Release title:** Use just `vX.Y.Z` (e.g., `v1.4.1`), NOT "Release vX.Y.Z". + **Keep it informative but concise.** Users want to know what changed and where to find it. **Required structure of release notes:** @@ -243,6 +245,12 @@ The `main` branch is protected - releases must go through the automated release - ❌ Number of tests added - ❌ Internal API changes (unless breaking) - ❌ Excessive emoji on every bullet point +- ❌ .gitignore changes or other developer-only file changes +- ❌ AI/Copilot instruction updates +- ❌ CI/CD workflow changes (unless affecting users) +- ❌ Code refactoring without user-visible changes + +**Only include user-relevant changes** - things that affect what users see or experience in the app. **Example of good release notes:** diff --git a/.gitignore b/.gitignore index 50dc15b..6e51a9d 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,4 @@ Thumbs.db *.local .cache/ .turbo/ +docs/TECH_STACK.md \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 4355a3c..495bd31 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "medassist-ng-backend", - "version": "1.1.0", + "version": "1.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "medassist-ng-backend", - "version": "1.1.0", + "version": "1.4.1", "dependencies": { "@fastify/cookie": "^10.0.1", "@fastify/cors": "^10.0.1", @@ -2079,7 +2079,6 @@ "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.10.0.tgz", "integrity": "sha512-2ERn08T4XOVx34yBtUPq0RDjAdd9TJ5qNH/izugr208ml2F94mk92qC64kXyDVQINodWJvp3kAdq6P4zTtCZ7g==", "license": "MIT", - "peer": true, "dependencies": { "@libsql/core": "^0.10.0", "@libsql/hrana-client": "^0.6.2", @@ -4579,7 +4578,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -5776,7 +5774,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6538,7 +6535,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -6602,7 +6598,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -6678,7 +6673,6 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 59bf3ff..851f2c7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "medassist-ng-frontend", - "version": "1.1.0", + "version": "1.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "medassist-ng-frontend", - "version": "1.1.0", + "version": "1.4.1", "dependencies": { "i18next": "^24.2.2", "i18next-browser-languagedetector": "^8.0.4", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9b8cd82..e3a18de 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,142 +1,16 @@ -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"; +import { useEffect, useState } from "react"; +import { Routes, Route, Navigate } from "react-router-dom"; +import { AuthProvider, useAuth, AuthPage } from "./components/Auth"; +import { AppHeader } from "./components/AppHeader"; +import { SharedSchedule, Lightbox, MedDetailModal, UserFilterModal, ShareDialog, ProfileModal, AboutModal } from "./components"; +import { AppProvider, useAppContext } from "./context"; +import { PlannerPage, SchedulePage, SettingsPage, DashboardPage, MedicationsPage } from "./pages"; // Vite injects this at build time from package.json declare const __APP_VERSION__: string; -const FRONTEND_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'unknown'; +export const FRONTEND_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'unknown'; const GITHUB_REPO = 'DanielVolz/medassist-ng'; -const GITHUB_URL = `https://github.com/${GITHUB_REPO}`; - -// Simple semver comparison: returns -1 if a < b, 0 if equal, 1 if a > b -function compareSemver(a: string, b: string): number { - const parseVersion = (v: string) => v.replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0); - const pa = parseVersion(a); - const pb = parseVersion(b); - for (let i = 0; i < 3; i++) { - const va = pa[i] || 0; - const vb = pb[i] || 0; - if (va < vb) return -1; - if (va > vb) return 1; - } - return 0; -} - -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; - stockAdjustment?: number; - lastStockCorrectionAt?: string | null; // When stock was last corrected - consumed doses before this don't count - pillWeightMg?: number | null; - blisters: Blister[]; - imageUrl?: string | null; - expiryDate?: string | null; - notes?: string | null; - intakeRemindersEnabled?: boolean; - updatedAt: string | number | null; -}; - -// Helper to calculate total pills including stockAdjustment -function getMedTotal(med: Medication): number { - return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0); -} - -// Helper to get the base package size (without stockAdjustment) -function getPackageSize(med: Medication): number { - return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets; -} - -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; -}; +export const GITHUB_URL = `https://github.com/${GITHUB_REPO}`; // ============================================================================= // Main App Wrapper with Auth @@ -156,8 +30,6 @@ export default function App() { function AppRouter() { const { user, authState, loading, authError } = useAuth(); - const location = useLocation(); - const navigate = useNavigate(); // Show loading while checking auth state if (loading) { @@ -221,367 +93,58 @@ function AppRouter() { } // Auth disabled or user is logged in - show main app - return ; + 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(); + // Get shared state from AppContext + const ctx = useAppContext(); + const { + // Medications + meds, loadMeds, + // Settings + settings, + // Refill + showRefillModal, setShowRefillModal, refillPacks, setRefillPacks, refillLoose, setRefillLoose, + refillSaving, refillHistory, refillHistoryExpanded, setRefillHistoryExpanded, + showEditStockModal, setShowEditStockModal, editStockFullBlisters, setEditStockFullBlisters, + editStockPartialBlisterPills, setEditStockPartialBlisterPills, editStockSaving, + openRefillModal, closeRefillModal, openEditStockModal, closeEditStockModal, + // Share + showShareDialog, sharePeople, shareSelectedPerson, setShareSelectedPerson, + shareSelectedDays, setShareSelectedDays, shareGenerating, shareLink, setShareLink, + shareCopied, setShareCopied, generateShareLink, copyShareLink, closeShareDialog, resetShareDialogState, + // Computed + coverage, + // Modal state + selectedMed, setSelectedMed, showImageLightbox, setShowImageLightbox, + scheduleLightboxImage, setScheduleLightboxImage, selectedUser, setSelectedUser, + // Modal helpers + openMedDetail, closeMedDetail, openImageLightbox, closeImageLightbox, + openScheduleLightbox, closeScheduleLightbox, closeUserFilter, + } = ctx; + + // Wrapper to pass meds to openShareDialog + const openShareDialog = () => ctx.openShareDialog(); + + // Local-only state (not shared across components) const [showProfile, setShowProfile] = useState(false); const [showAbout, setShowAbout] = useState(false); - const [backendVersion, setBackendVersion] = useState(null); - const [updateCheckResult, setUpdateCheckResult] = useState<{ - status: 'idle' | 'checking' | 'up-to-date' | 'update-available' | 'error'; - latestVersion?: string; - lastChecked?: string; - }>({ status: 'idle' }); - 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); - const [exportIncludeImages, setExportIncludeImages] = useState(true); - const [showExportModal, setShowExportModal] = useState(false); - const [importResult, setImportResult] = useState<{medications: number, doses: number, shares: number} | null>(null); - // 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); - // Edit stock (correction) state - const [showEditStockModal, setShowEditStockModal] = useState(false); - const [editStockFullBlisters, setEditStockFullBlisters] = useState(0); - const [editStockPartialBlisterPills, setEditStockPartialBlisterPills] = useState(0); - const [editStockSaving, setEditStockSaving] = 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) { + if (scheduleLightboxImage) { closeScheduleLightbox(); } else if (showImageLightbox) { closeImageLightbox(); @@ -589,9 +152,6 @@ function AppContent() { closeEditStockModal(); } else if (showRefillModal) { closeRefillModal(); - } else if (showEditModal) { - closeEditModal(); - resetForm(); } else if (showShareDialog) { closeShareDialog(); } else if (showAbout) { @@ -607,7 +167,7 @@ function AppContent() { }; document.addEventListener("keydown", handleEscape); return () => document.removeEventListener("keydown", handleEscape); - }, [selectedMed, showImageLightbox, scheduleLightboxImage, selectedUser, showProfile, showAbout, showShareDialog, showEditModal, showRefillModal, showEditStockModal, userDropdownOpen]); + }, [selectedMed, showImageLightbox, scheduleLightboxImage, selectedUser, showProfile, showAbout, showShareDialog, showRefillModal, showEditStockModal]); // Handle browser back button to close modals (in priority order) useEffect(() => { @@ -623,9 +183,6 @@ function AppContent() { setShowEditStockModal(false); } else if (showRefillModal) { setShowRefillModal(false); - } else if (showEditModal) { - setShowEditModal(false); - resetForm(); } else if (showShareDialog) { resetShareDialogState(); } else if (showAbout) { @@ -640,20 +197,7 @@ function AppContent() { }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); - }, [selectedMed, showImageLightbox, scheduleLightboxImage, selectedUser, showProfile, showAbout, showShareDialog, showEditModal, showRefillModal, showEditStockModal]); - - // 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]); + }, [selectedMed, showImageLightbox, scheduleLightboxImage, selectedUser, showProfile, showAbout, showShareDialog, showRefillModal, showEditStockModal]); // Close tooltips on scroll/touch (for mobile) useEffect(() => { @@ -691,7 +235,7 @@ function AppContent() { // Prevent background scroll when modal is open useEffect(() => { - const isModalOpen = selectedMed || selectedUser || showProfile || showAbout || showShareDialog || showEditModal; + const isModalOpen = selectedMed || selectedUser || showProfile || showAbout || showShareDialog; if (isModalOpen) { const scrollY = window.scrollY; document.body.classList.add('modal-open'); @@ -708,7 +252,7 @@ function AppContent() { document.body.classList.remove('modal-open'); document.body.style.top = ''; }; - }, [selectedMed, selectedUser, showProfile, showAbout, showShareDialog, showEditModal]); + }, [selectedMed, selectedUser, showProfile, showAbout, showShareDialog]); // Update selectedMed when meds change (e.g., after refill) useEffect(() => { @@ -724,389 +268,22 @@ function AppContent() { } }, [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 handleSubmitStockCorrection = async (medId: number) => { + if (!selectedMed) return; + await ctx.submitStockCorrection(medId, selectedMed, loadMeds); }; - 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); - } - - // Submit a stock correction - user says how many pills they have RIGHT NOW - // The server sets lastStockCorrectionAt, so consumed doses before now won't count anymore - async function submitStockCorrection(medId: number) { - if (!selectedMed) return; - setEditStockSaving(true); - try { - // Auto-convert: handle full blister and negative partial blister - let finalFullBlisters = editStockFullBlisters; - let finalPartialPills = editStockPartialBlisterPills; - - // Handle full blister: e.g. 9 pills in a 9-pill blister = +1 full blister, 0 partial - if (finalPartialPills >= selectedMed.pillsPerBlister) { - finalFullBlisters += 1; - finalPartialPills = 0; - } - - // Handle negative partial: e.g. -3 with 136 full = 135 full, 6 partial (for 9-pill blister) - if (finalPartialPills < 0 && finalFullBlisters > 0) { - finalFullBlisters -= 1; - finalPartialPills = selectedMed.pillsPerBlister + finalPartialPills; - } - - // Ensure we don't go negative - if (finalPartialPills < 0) finalPartialPills = 0; - if (finalFullBlisters < 0) finalFullBlisters = 0; - - // What the user says they have RIGHT NOW = the new DB total - // The server will set lastStockCorrectionAt, so all previous consumed doses are ignored - const desiredTotal = finalFullBlisters * selectedMed.pillsPerBlister + finalPartialPills; - - // The "base" from DB structure (without any stockAdjustment) - const baseTotal = selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + selectedMed.looseTablets; - - // stockAdjustment = what we need to make getMedTotal() return desiredTotal - const newStockAdjustment = desiredTotal - baseTotal; - - console.log('submitStockCorrection:', { - input: { fullBlisters: editStockFullBlisters, partial: editStockPartialBlisterPills }, - final: { fullBlisters: finalFullBlisters, partial: finalPartialPills }, - desiredTotal, - baseTotal, - newStockAdjustment - }); - - // Use the PATCH endpoint - it sets stockAdjustment AND lastStockCorrectionAt - const res = await fetch(`/api/medications/${medId}/stock-adjustment`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ stockAdjustment: newStockAdjustment }), - }); - console.log('PATCH response:', res.status, res.ok); - if (res.ok) { - // Close edit stock modal via history back - if (showEditStockModal) { - window.history.back(); - } - // Reload medications to get updated stock - loadMeds(); - } - } catch { - // ignore - } - setEditStockSaving(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() { + // For MedDetailModal: refill without form update (not editing) + const handleSubmitRefill = async (medId: number) => { + await ctx.submitRefill(medId, null, () => {}, loadMeds); + }; + + // Wrapper for openEditStockModal (provides selectedMed and coverage) + const handleOpenEditStockModal = () => { if (selectedMed) { - window.history.back(); + openEditStockModal(selectedMed, coverage); } - } - - // 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 openEditStockModal() { - if (!selectedMed) return; - // Get current stock from coverage (after consumption) - const medCoverage = coverage.all.find(c => c.name === selectedMed.name); - const dbTotal = getMedTotal(selectedMed); - const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal; - - // Simply divide into full blisters and partial - const fullBlisters = Math.floor(currentStock / selectedMed.pillsPerBlister); - const partialPills = currentStock % selectedMed.pillsPerBlister; - - // Pre-fill with current values - setEditStockFullBlisters(fullBlisters); - setEditStockPartialBlisterPills(partialPills); - setShowEditStockModal(true); - window.history.pushState({ modal: 'editStock' }, ''); - } - function closeEditStockModal() { - if (showEditStockModal) { - window.history.back(); - } - } - - function openEditModal() { - setShowEditModal(true); - window.history.pushState({ modal: 'edit' }, ''); - } - function closeEditModal() { - if (showEditModal) { - window.history.back(); - } - } + }; function openProfile() { setShowProfile(true); @@ -1121,22 +298,6 @@ function AppContent() { function openAbout() { setShowAbout(true); window.history.pushState({ modal: 'about' }, ''); - // Fetch backend version when opening - fetch('/api/health') - .then(res => res.json()) - .then(data => setBackendVersion(data.version || 'unknown')) - .catch(() => setBackendVersion('unknown')); - // Restore cached update check result from sessionStorage - const cached = sessionStorage.getItem('updateCheckResult'); - if (cached) { - try { - const parsed = JSON.parse(cached); - // Only use cache if less than 1 hour old - if (parsed.lastChecked && Date.now() - new Date(parsed.lastChecked).getTime() < 60 * 60 * 1000) { - setUpdateCheckResult(parsed); - } - } catch { /* ignore */ } - } } function closeAbout() { if (showAbout) { @@ -1144,4255 +305,100 @@ function AppContent() { } } - async function checkForUpdates() { - setUpdateCheckResult({ status: 'checking' }); - try { - const res = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`); - if (!res.ok) throw new Error('Failed to fetch'); - const data = await res.json(); - const latestVersion = data.tag_name?.replace(/^v/, '') || data.name?.replace(/^v/, ''); - const lastChecked = new Date().toISOString(); - - // Compare with current version (use frontend version as reference) - const currentVersion = FRONTEND_VERSION; - const needsUpdate = compareSemver(currentVersion, latestVersion) < 0; - - const result = { - status: needsUpdate ? 'update-available' as const : 'up-to-date' as const, - latestVersion, - lastChecked, - }; - setUpdateCheckResult(result); - // Cache result in sessionStorage - sessionStorage.setItem('updateCheckResult', JSON.stringify(result)); - } catch { - setUpdateCheckResult({ status: 'error' }); - } - } - - 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(includeImages: boolean = true) { - setExporting(true); - try { - const res = await fetch(`/api/export?includeSensitive=true&includeImages=${includeImages}`, { - 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), - }); - - // Get the response text first to handle non-JSON responses - const text = await res.text(); - let data; - try { - data = text ? JSON.parse(text) : {}; - } catch { - console.error("Import response parse error:", text); - alert(t('exportImport.importError') + ": Server returned invalid response"); - return; - } - - if (!res.ok) { - alert(t('exportImport.importError') + ": " + (data.error || `HTTP ${res.status}`)); - return; - } - - // Show success message in UI instead of browser alert - setImportResult({ - medications: data.imported?.medications || 0, - doses: data.imported?.doseHistory || 0, - shares: data.imported?.shareLinks || 0, - }); - - // 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()} /> -
-
- )} + {/* About Modal */} - {showAbout && ( -
closeAbout()}> -
e.stopPropagation()}> - -
-
- - - - - -
-

{t('about.appName', 'MedAssist')}

-

{t('about.description', 'Personal medication tracking and reminder app')}

-
-
-
- {t('about.frontendVersion', 'Frontend')} - {FRONTEND_VERSION} -
-
- {t('about.backendVersion', 'Backend')} - {backendVersion || '...'} -
-
-
- - {updateCheckResult && updateCheckResult.status !== 'checking' && ( -
- {updateCheckResult.status === 'up-to-date' && ( - ✓ {t('about.upToDate', 'You are up to date!')} - )} - {updateCheckResult.status === 'update-available' && ( - - ⬆ {t('about.updateAvailable', 'Update available')}: v{updateCheckResult.latestVersion} - - {t('about.downloadUpdate', 'Download')} - - - )} - {updateCheckResult.status === 'error' && ( - ⚠ {t('about.checkFailed', 'Could not check for updates')} - )} - {updateCheckResult.lastChecked && ( - - {t('about.lastChecked', 'Last checked')}: {new Date(updateCheckResult.lastChecked).toLocaleString()} - - )} -
- )} -
- -
-

{t('about.copyright', '© {{year}} Daniel Volz', { year: new Date().getFullYear() })}

-

{t('about.license', 'GPL-3.0 License')}

-
-
-
- )} + } /> - - {(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 ? getMedTotal(med) : 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 ? getMedTotal(med) : 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')}: {getPackageSize(med)} {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')}

-
-
- - - - - - - - - - - - {/* 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')} - )} -
-
- )} - -