From 583704da6f567457f7ddccd09480e2e4e791b1ab Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Wed, 24 Dec 2025 02:37:45 +0100 Subject: [PATCH] feat: update docker-compose and nginx configurations for improved service connectivity and security --- docker-compose.prod.yml | 10 + frontend/nginx.conf | 2 +- frontend/src/App.tsx | 1819 +++++------------------------------- frontend/src/styles.css | 1941 +-------------------------------------- 4 files changed, 311 insertions(+), 3461 deletions(-) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 426380b..3dd1c46 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -5,12 +5,15 @@ services: backend: image: git.danielvolz.org/daniel/medassist/backend:0.0.1 + container_name: medassist-backend env_file: - .env volumes: - ./data:/app/data ports: - "4000:3000" + networks: + - medassist-net # Security options security_opt: - no-new-privileges:true @@ -28,8 +31,11 @@ services: frontend: image: git.danielvolz.org/daniel/medassist/frontend:0.0.1 + container_name: medassist-frontend ports: - "4174:8080" + networks: + - medassist-net depends_on: backend: condition: service_healthy @@ -43,3 +49,7 @@ services: - /var/run:noexec,nosuid,size=64m cap_drop: - ALL + +networks: + medassist-net: + driver: bridge diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 24b6963..15a5d70 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -20,7 +20,7 @@ server { } location /api/ { - proxy_pass http://backend:3000/; + proxy_pass http://medassist-backend:3000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fab9430..6fd8ccb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,4 @@ import { useEffect, useMemo, useState } from "react"; -import { Routes, Route, useNavigate, useLocation, Navigate } from "react-router-dom"; -import { useTranslation } from "react-i18next"; type Slice = { usage: number; @@ -11,8 +9,6 @@ type Slice = { type Medication = { id: number; name: string; - genericName?: string | null; - takenBy?: string | null; count: number; strips: number; stripSize: number; @@ -20,19 +16,13 @@ type Medication = { stripsPerPack?: number; tabsPerStrip?: number; looseTablets?: number; - pillWeightMg?: number | null; slices: Slice[]; - imageUrl?: string | null; - expiryDate?: string | null; - notes?: string | null; - intakeRemindersEnabled?: boolean; updatedAt: string | number | null; }; type PlannerRow = { medicationId: number; medicationName: string; - totalPills: number; plannerUsage: number; stripSize: number; stripsNeeded: number; @@ -44,22 +34,16 @@ type FormSlice = { usage: string; every: string; start: string }; type FormState = { name: string; - genericName: string; - takenBy: string; packCount: string; stripsPerPack: string; tabsPerStrip: string; looseTablets: string; - pillWeightMg: string; - expiryDate: string; - notes: string; - intakeRemindersEnabled: boolean; slices: FormSlice[]; }; const defaultSlice = (): FormSlice => ({ usage: "1", every: "1", start: toInputValue(new Date().toISOString()) }); -const defaultForm = (): FormState => ({ name: "", genericName: "", takenBy: "", packCount: "1", stripsPerPack: "1", tabsPerStrip: "1", looseTablets: "0", pillWeightMg: "", expiryDate: "", notes: "", intakeRemindersEnabled: false, slices: [defaultSlice()] }); +const defaultForm = (): FormState => ({ name: "", packCount: "1", stripsPerPack: "1", tabsPerStrip: "1", looseTablets: "0", slices: [defaultSlice()] }); const todayIso = () => new Date().toISOString(); const plusDaysIso = (days: number) => { @@ -78,171 +62,36 @@ type Coverage = { }; export default function App() { - const { t, i18n } = useTranslation(); const [meds, setMeds] = useState([]); - const [plannerRows, setPlannerRows] = useState(() => { - if (typeof window !== "undefined") { - const saved = localStorage.getItem("plannerRows"); - if (saved) { - try { return JSON.parse(saved); } catch { return []; } - } - } - return []; - }); + const [plannerRows, setPlannerRows] = useState([]); const [plannerLoading, setPlannerLoading] = useState(false); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [editingId, setEditingId] = useState(null); const [form, setForm] = useState(defaultForm()); - const [range, setRange] = useState<{ start: string; end: string }>(() => { - if (typeof window !== "undefined") { - const saved = localStorage.getItem("plannerRange"); - if (saved) { - try { return JSON.parse(saved); } catch { /* ignore */ } - } - } - return { start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) }; - }); + const [range, setRange] = useState<{ start: string; end: string }>({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) }); + const [view, setView] = useState<"dashboard" | "medications" | "planner">("dashboard"); - const navigate = useNavigate(); - const location = useLocation(); - const currentPath = location.pathname; - - // Settings state - const [settings, setSettings] = useState({ - emailEnabled: false, - notificationEmail: "", - reminderDaysBefore: 7, - repeatDailyReminders: false, - lowStockDays: 30, - normalStockDays: 90, - highStockDays: 180, - smtpHost: "", - smtpPort: 587, - smtpUser: "", - smtpPass: "", - smtpFrom: "", - smtpSecure: false, - hasSmtpPassword: false, - lastAutoEmailSent: null as string | null, - nextScheduledCheck: null as string | null, - // Shoutrrr/ntfy settings - shoutrrrEnabled: false, - shoutrrrUrl: "", - // Granular notification settings - emailStockReminders: true, - emailIntakeReminders: true, - shoutrrrStockReminders: true, - shoutrrrIntakeReminders: true, - }); - const [savedSettings, setSavedSettings] = useState(settings); - const [settingsLoading, setSettingsLoading] = useState(false); - const [settingsSaving, setSettingsSaving] = useState(false); - const [settingsSaved, setSettingsSaved] = useState(false); - const [testingEmail, setTestingEmail] = useState(false); - const [testEmailResult, setTestEmailResult] = useState<{ success: boolean; message: string } | null>(null); - const [testingShoutrrr, setTestingShoutrrr] = useState(false); - const [testShoutrrrResult, setTestShoutrrrResult] = useState<{ success: boolean; message: string } | null>(null); - const [sendingPlannerEmail, setSendingPlannerEmail] = useState(false); - const [plannerEmailResult, setPlannerEmailResult] = useState<{ success: boolean; message: string } | null>(null); - const [sendingReminderEmail, setSendingReminderEmail] = useState(false); - const [reminderEmailResult, setReminderEmailResult] = useState<{ success: boolean; message: string } | null>(null); - const [uploadingImage, setUploadingImage] = useState(false); - const [selectedMed, setSelectedMed] = useState(null); - const [showImageLightbox, setShowImageLightbox] = useState(false); - const [selectedUser, setSelectedUser] = useState(null); - const [scheduleDays, setScheduleDays] = useState(() => { - const stored = localStorage.getItem("scheduleDays"); - return stored ? Number(stored) : 30; - }); - - // Track taken doses (stored in localStorage) - const [takenDoses, setTakenDoses] = useState>(() => { - try { - const stored = localStorage.getItem("takenDoses"); - if (stored) { - const parsed = JSON.parse(stored); - // Clean up old entries (older than 7 days) - const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; - const filtered = parsed.filter((item: { id: string; timestamp: number }) => item.timestamp > weekAgo); - return new Set(filtered.map((item: { id: string }) => item.id)); - } - } catch {} - return new Set(); - }); - - function markDoseTaken(doseId: string) { - setTakenDoses((prev) => { - const next = new Set(prev); - next.add(doseId); - // Persist with timestamp for cleanup - const items = Array.from(next).map((id) => ({ id, timestamp: Date.now() })); - localStorage.setItem("takenDoses", JSON.stringify(items)); - return next; - }); - } - - function undoDoseTaken(doseId: string) { - setTakenDoses((prev) => { - const next = new Set(prev); - next.delete(doseId); - const items = Array.from(next).map((id) => ({ id, timestamp: Date.now() })); - localStorage.setItem("takenDoses", JSON.stringify(items)); - return next; - }); - } - - // Close modal on Escape key - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === "Escape") { - if (showImageLightbox) { - setShowImageLightbox(false); - } else if (selectedUser) { - setSelectedUser(null); - } else if (selectedMed) { - setSelectedMed(null); - } - } - }; - document.addEventListener("keydown", handleEscape); - return () => document.removeEventListener("keydown", handleEscape); - }, [selectedMed, showImageLightbox, selectedUser]); - - // Check if settings have changed - const settingsChanged = settings.emailEnabled !== savedSettings.emailEnabled || - settings.notificationEmail !== savedSettings.notificationEmail || - settings.reminderDaysBefore !== savedSettings.reminderDaysBefore || - settings.repeatDailyReminders !== savedSettings.repeatDailyReminders || - settings.lowStockDays !== savedSettings.lowStockDays || - settings.normalStockDays !== savedSettings.normalStockDays || - settings.highStockDays !== savedSettings.highStockDays || - settings.shoutrrrEnabled !== savedSettings.shoutrrrEnabled || - settings.shoutrrrUrl !== savedSettings.shoutrrrUrl; - - const schedule = useMemo(() => buildSchedulePreview(meds, i18n.language), [meds, i18n.language]); + const schedule = useMemo(() => buildSchedulePreview(meds), [meds]); const totalTablets = useMemo(() => deriveTotal(form), [form]); - const coverage = useMemo(() => calculateCoverage(meds, schedule.events, i18n.language), [meds, schedule.events, i18n.language]); + const coverage = useMemo(() => calculateCoverage(meds, schedule.events), [meds, schedule.events]); const depletionByMed = useMemo(() => Object.fromEntries(coverage.all.map((c) => [c.name, c.depletionTime])), [coverage.all]); - const coverageByMed = useMemo(() => Object.fromEntries(coverage.all.map((c) => [c.name, c])), [coverage.all]); const groupedSchedule = useMemo(() => { - type DoseInfo = { id: string; timeStr: string; when: number; usage: number }; - const days = new Map }>(); - schedule.events.slice(0, 2000).forEach((event) => { + const days = new Map }>(); + schedule.events.slice(0, 30).forEach((event) => { const day = days.get(event.dateStr) ?? { dateStr: event.dateStr, meds: new Map() }; - const medEntry = day.meds.get(event.medName) ?? { medName: event.medName, total: 0, doses: [], lastWhen: event.when }; + const medEntry = day.meds.get(event.medName) ?? { medName: event.medName, total: 0, times: [], lastWhen: event.when }; medEntry.total += event.usage; - medEntry.doses.push({ id: event.id, timeStr: event.timeStr, when: event.when, usage: event.usage }); + medEntry.times.push(event.timeStr); medEntry.lastWhen = Math.max(medEntry.lastWhen, event.when); day.meds.set(event.medName, medEntry); days.set(event.dateStr, day); }); - return Array.from(days.values()).map((d) => ({ dateStr: d.dateStr, meds: Array.from(d.meds.values()) })).slice(0, scheduleDays); - }, [schedule.events, scheduleDays]); + return Array.from(days.values()).map((d) => ({ dateStr: d.dateStr, meds: Array.from(d.meds.values()) })); + }, [schedule.events]); useEffect(() => { loadMeds(); - loadSettings(); }, []); function loadMeds() { @@ -254,204 +103,12 @@ export default function App() { .finally(() => setLoading(false)); } - function loadSettings() { - setSettingsLoading(true); - fetch("/api/settings") - .then((res) => res.json()) - .then((data) => { - const newSettings = { ...settings, ...data, smtpPass: "" }; - setSettings(newSettings); - setSavedSettings(newSettings); - setSettingsSaved(false); - }) - .catch(() => {}) - .finally(() => setSettingsLoading(false)); - } - - async function saveSettings(e: React.FormEvent) { - e.preventDefault(); - - // Validate email if email notifications are enabled - if (settings.emailEnabled && settings.notificationEmail) { - const emailRegex = /^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$/i; - if (!emailRegex.test(settings.notificationEmail)) { - setTestEmailResult({ success: false, message: "Invalid email address" }); - return; - } - } - - setSettingsSaving(true); - setTestEmailResult(null); - - const payload = { - emailEnabled: settings.emailEnabled, - notificationEmail: settings.notificationEmail, - reminderDaysBefore: settings.reminderDaysBefore, - repeatDailyReminders: settings.repeatDailyReminders, - lowStockDays: settings.lowStockDays, - normalStockDays: settings.normalStockDays, - highStockDays: settings.highStockDays, - shoutrrrEnabled: settings.shoutrrrEnabled, - shoutrrrUrl: settings.shoutrrrUrl, - // Granular notification settings - emailStockReminders: settings.emailStockReminders, - emailIntakeReminders: settings.emailIntakeReminders, - shoutrrrStockReminders: settings.shoutrrrStockReminders, - shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders, - // Language setting (for backend notifications) - language: i18n.language, - // SMTP (legacy - not saved, read from .env) - smtpHost: settings.smtpHost, - smtpPort: settings.smtpPort, - smtpUser: settings.smtpUser, - smtpPass: settings.smtpPass || undefined, - smtpFrom: settings.smtpFrom, - smtpSecure: settings.smtpSecure, - }; - - await fetch("/api/settings", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }).catch(() => null); - - setSettingsSaving(false); - setSavedSettings(settings); - setSettingsSaved(true); - } - - async function testEmail() { - if (!settings.notificationEmail) return; - setTestingEmail(true); - setTestEmailResult(null); - - try { - const res = await fetch("/api/settings/test-email", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: settings.notificationEmail }), - }); - const data = await res.json(); - if (res.ok) { - setTestEmailResult({ success: true, message: data.message || "Email sent!" }); - } else { - setTestEmailResult({ success: false, message: data.error || "Failed to send" }); - } - } catch { - setTestEmailResult({ success: false, message: "Network error" }); - } - setTestingEmail(false); - } - - async function testShoutrrr() { - if (!settings.shoutrrrUrl) return; - setTestingShoutrrr(true); - setTestShoutrrrResult(null); - - try { - const res = await fetch("/api/settings/test-shoutrrr", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url: settings.shoutrrrUrl }), - }); - const data = await res.json(); - if (res.ok) { - setTestShoutrrrResult({ success: true, message: data.message || "Notification sent!" }); - } else { - setTestShoutrrrResult({ success: false, message: data.error || "Failed to send" }); - } - } catch { - setTestShoutrrrResult({ success: false, message: "Network error" }); - } - setTestingShoutrrr(false); - } - - async function sendPlannerEmail() { - if (!settings.notificationEmail || plannerRows.length === 0) return; - setSendingPlannerEmail(true); - setPlannerEmailResult(null); - - try { - const res = await fetch("/api/planner/send-email", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - email: settings.notificationEmail, - from: range.start, - until: range.end, - rows: plannerRows, - }), - }); - const data = await res.json(); - if (res.ok) { - setPlannerEmailResult({ success: true, message: data.message || "Email sent!" }); - } else { - setPlannerEmailResult({ success: false, message: data.error || "Failed to send" }); - } - } catch { - setPlannerEmailResult({ success: false, message: "Network error" }); - } - setSendingPlannerEmail(false); - } - - async function sendReminderEmail() { - if (!settings.notificationEmail || coverage.low.length === 0) return; - setSendingReminderEmail(true); - setReminderEmailResult(null); - - try { - const res = await fetch("/api/reminder/send-email", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - email: settings.notificationEmail, - lowStock: coverage.low, - }), - }); - const data = await res.json(); - if (res.ok) { - setReminderEmailResult({ success: true, message: data.message || "Email sent!" }); - // Reload settings to get updated lastAutoEmailSent - loadSettings(); - } else { - setReminderEmailResult({ success: false, message: data.error || "Failed to send" }); - } - } catch { - setReminderEmailResult({ success: false, message: "Network error" }); - } - setSendingReminderEmail(false); - } - async function deleteMed(id: number) { await fetch(`/api/medications/${id}`, { method: "DELETE" }).catch(() => null); if (editingId === id) resetForm(); loadMeds(); } - async function uploadMedImage(medId: number, file: File) { - setUploadingImage(true); - const formData = new FormData(); - formData.append("file", file); - - try { - const res = await fetch(`/api/medications/${medId}/image`, { - method: "POST", - body: formData, - }); - if (res.ok) { - loadMeds(); - } - } catch { - // ignore - } - setUploadingImage(false); - } - - async function deleteMedImage(medId: number) { - await fetch(`/api/medications/${medId}/image`, { method: "DELETE" }).catch(() => null); - loadMeds(); - } - function setSliceValue(idx: number, field: keyof FormSlice, value: string) { setForm((prev) => { const next = [...prev.slices]; @@ -472,16 +129,10 @@ export default function App() { setEditingId(med.id); setForm({ name: med.name, - genericName: med.genericName ?? "", - takenBy: med.takenBy ?? "", packCount: String(med.packCount ?? 1), stripsPerPack: String(med.stripsPerPack ?? med.strips ?? 1), tabsPerStrip: String(med.tabsPerStrip ?? med.stripSize ?? 1), looseTablets: String(med.looseTablets ?? 0), - pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "", - expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "", - notes: med.notes ?? "", - intakeRemindersEnabled: med.intakeRemindersEnabled ?? false, slices: med.slices.map((s) => ({ usage: String(s.usage), every: String(s.every), start: toInputValue(s.start) })), }); } @@ -502,16 +153,10 @@ export default function App() { const payload = { name: form.name.trim(), - genericName: form.genericName.trim() || null, - takenBy: form.takenBy.trim() || null, packCount: Number(form.packCount) || 0, stripsPerPack: Math.max(1, Number(form.stripsPerPack) || 1), tabsPerStrip: Math.max(1, Number(form.tabsPerStrip) || 1), looseTablets: Math.max(0, Number(form.looseTablets) || 0), - pillWeightMg: form.pillWeightMg ? Number(form.pillWeightMg) : null, - expiryDate: form.expiryDate || null, - notes: form.notes.trim() || null, - intakeRemindersEnabled: form.intakeRemindersEnabled, slices: form.slices.map((s) => ({ usage: Number(s.usage) || 0, every: Math.max(1, Number(s.every) || 1), start: toIsoString(s.start) })), }; @@ -534,1038 +179,276 @@ export default function App() { .catch(() => []) as PlannerRow[]; setPlannerRows(rows); setPlannerLoading(false); - // Save to localStorage - localStorage.setItem("plannerRange", JSON.stringify(range)); - localStorage.setItem("plannerRows", JSON.stringify(rows)); } function resetRange() { setRange({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) }); setPlannerRows([]); - localStorage.removeItem("plannerRange"); - localStorage.removeItem("plannerRows"); } - const [theme, setTheme] = useState<"light" | "dark">(() => { - if (typeof window !== "undefined") { - return (localStorage.getItem("theme") as "light" | "dark") || "dark"; - } - return "dark"; - }); - - useEffect(() => { - document.documentElement.setAttribute("data-theme", theme); - localStorage.setItem("theme", theme); - }, [theme]); - - function toggleTheme() { - setTheme((prev) => (prev === "dark" ? "light" : "dark")); - } - - // Page titles based on current route - const pageInfo = { - "/dashboard": { eyebrow: t('header.eyebrow.overview'), title: t('nav.dashboard') }, - "/medications": { eyebrow: t('header.eyebrow.inventory'), title: t('nav.medications') }, - "/planner": { eyebrow: t('header.eyebrow.planner'), title: t('nav.planner') }, - "/settings": { eyebrow: t('header.eyebrow.settings'), title: t('nav.settings') }, - "/schedule": { eyebrow: t('header.eyebrow.schedule'), title: t('dashboard.schedules.title') }, - }[currentPath] || { eyebrow: t('header.eyebrow.overview'), title: t('nav.dashboard') }; - return (
-
- MedAssist -
-

{pageInfo.eyebrow}

-

{pageInfo.title}

-
+
+

Medassist · Planner

+

Manage medication plans

-
-
- - - -
- - +
+ + +
- - } /> - - {(settings.emailEnabled || settings.shoutrrrEnabled) && ( -
- {settings.emailEnabled && settings.shoutrrrEnabled ? "🔔" : settings.emailEnabled ? "📧" : "🔔"} - - {t('dashboard.reminders.active')} — {getReminderStatusText(settings.reminderDaysBefore, coverage.low, settings.lastAutoEmailSent, t, i18n.language)} - - {settings.emailEnabled && settings.notificationEmail && → {settings.notificationEmail}} -
- )} -
-
-
-

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

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

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

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

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

- ) : ( - <> -
-
- {t('table.name')} - {t('table.currentPills')} - {t('table.daysLeft')} - {t('table.status')} - {t('table.runsOut')} - {t('table.autoRemind')} -
- {coverage.low.map((row) => { - const status = getStockStatus(row.daysLeft, row.medsLeft, settings); - const med = meds.find(m => m.name === row.name); - const textClass = status.className === "danger" ? "danger-text" : status.className === "warning" ? "warning-text" : ""; - return ( -
med && setSelectedMed(med)}> - {row.name}{med?.takenBy && { e.stopPropagation(); setSelectedUser(med.takenBy!); }}>{med.takenBy}}{med?.intakeRemindersEnabled && 🔔}{med?.notes && 📝} - {formatNumber(row.medsLeft)} - {formatNumber(row.daysLeft)} - {t(status.label)} - {row.depletionDate ?? "-"} - {getNextReminderForMed(row, settings.reminderDaysBefore, i18n.language)} -
- ); - })} + {view === "dashboard" && ( + <> +
+
+
+

Reorder Reminder

+ Stock watch +
+ {coverage.low.length === 0 ? ( +

All good, enough stock.

+ ) : ( +
+
+ Name + Current pills + Days left + Runs out + Next dose +
+ {coverage.low.map((row) => ( +
+ {row.name} + {formatNumber(row.medsLeft)} + {formatNumber(row.daysLeft)} + {row.depletionDate ?? "-"} + {row.nextDose ?? "-"}
- {(settings.emailEnabled || settings.shoutrrrEnabled) && ( -
- - {reminderEmailResult && ( - - {reminderEmailResult.message} - - )} -
- )} - + ))} +
)}
-
-
-
-

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

- {t('dashboard.overview.badge')} +
+
+
+

Medication Overview

+ Stock +
+
+
+ Name + Current pills + Days left + Runs out
-
-
- {t('table.name')} - {t('table.currentPills')} - {t('table.daysLeft')} - {t('table.runsOut')} - {t('table.expiry')} - {t('table.status')} + {coverage.all.map((row) => ( +
+ {row.name} + {formatNumber(row.medsLeft)} + {formatNumber(row.daysLeft)} + {row.depletionDate ?? "-"}
- {coverage.all.map((row) => { - const status = getStockStatus(row.daysLeft, row.medsLeft, settings); - const med = meds.find(m => m.name === row.name); - const expiryClass = getExpiryClass(med?.expiryDate); - const textClass = status.className === "danger" ? "danger-text" : status.className === "warning" ? "warning-text" : ""; + ))} +
+
+
+ +
+
+
+

Upcoming Schedules

+ Next 10 +
+
+ {groupedSchedule.map((day) => ( +
+
{day.dateStr}
+ {day.meds.map((item) => { + const depletionTime = depletionByMed[item.medName]; + const outOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime; return ( -
med && setSelectedMed(med)}> - {row.name}{med?.takenBy && { e.stopPropagation(); setSelectedUser(med.takenBy!); }}>{med.takenBy}}{med?.intakeRemindersEnabled && 🔔}{med?.notes && 📝} - {formatNumber(row.medsLeft)} - {formatNumber(row.daysLeft)} - {row.depletionDate ?? "-"} - {med?.expiryDate ? new Date(med.expiryDate).toLocaleDateString(i18n.language, { day: "2-digit", month: "short", year: "2-digit" }) : "-"} - {t(status.label)} +
+
+
{item.medName}
+
+ {item.total} pills total + + {outOfStock ? "⚠ No pills left" : "✓ Stock OK"} + +
+
+
+
{item.times.join(" · ")}
+
); })}
-
-
- -
-
-
-

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

- -
-
- {groupedSchedule.map((day) => ( -
-
{day.dateStr}
- {day.meds.map((item) => { - const medCoverage = coverageByMed[item.medName]; - const med = meds.find(m => m.name === item.medName); - const depletionTime = depletionByMed[item.medName]; - // Check if this dose is scheduled after medication runs out - const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime; - const status = willBeOutOfStock - ? { className: "danger", label: "status.outOfStock" } - : medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null; - const allTaken = item.doses.every((d) => takenDoses.has(d.id)); - const takenCount = item.doses.filter((d) => takenDoses.has(d.id)).length; - return ( -
-
-
{item.medName}{med?.intakeRemindersEnabled && 🔔}
-
- {item.total} {t('common.pills')} {t('common.total')} - {status && - {t(status.label)} - } -
-
-
- {item.doses.map((dose) => { - const isTaken = takenDoses.has(dose.id); - return ( -
- {dose.timeStr} - {dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}{med?.takenBy && {t('dose.takenBy')} setSelectedUser(med.takenBy!)}>{med.takenBy}} - {isTaken ? ( - - ) : ( - - )} -
- ); - })} -
-
- ); - })} -
- ))} -
-
-
- - } /> - - -
-
-

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

- {loading ? t('common.loading') : t('medications.list.entries', { count: meds.length })} -
-
- {meds.map((med) => ( -
-
-
-
- -
{med.name}
-
-
- {t('medications.details.packs')}: {med.packCount ?? 1} - {t('medications.details.blisters')}: {med.stripsPerPack ?? med.strips ?? 1} - {t('medications.details.pillsPerBlister')}: {med.tabsPerStrip ?? med.stripSize} - {t('medications.details.loose')}: {med.looseTablets ?? 0} -
-
{t('medications.details.total')}: {med.count} {t('common.pills')}
-
-
- - -
-
-
- {med.slices.map((s, idx) => ( -
- {s.usage} {s.usage === 1 ? t('common.pill') : t('common.pills')} · {t('form.slices.every')} {s.every} {s.every === 1 ? t('common.day') : t('common.days')} · {t('form.slices.from')} {formatDateTime(s.start, i18n.language)} -
- ))} -
-
- ))} -
-
- -
-
-

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

- {t('form.badge')} -
-
- - - - - - - - - - - -