diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6fd8ccb..fab9430 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,6 @@ 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; @@ -9,6 +11,8 @@ type Slice = { type Medication = { id: number; name: string; + genericName?: string | null; + takenBy?: string | null; count: number; strips: number; stripSize: number; @@ -16,13 +20,19 @@ 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; @@ -34,16 +44,22 @@ 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: "", packCount: "1", stripsPerPack: "1", tabsPerStrip: "1", looseTablets: "0", slices: [defaultSlice()] }); +const defaultForm = (): FormState => ({ name: "", genericName: "", takenBy: "", packCount: "1", stripsPerPack: "1", tabsPerStrip: "1", looseTablets: "0", pillWeightMg: "", expiryDate: "", notes: "", intakeRemindersEnabled: false, slices: [defaultSlice()] }); const todayIso = () => new Date().toISOString(); const plusDaysIso = (days: number) => { @@ -62,36 +78,171 @@ type Coverage = { }; export default function App() { + const { t, i18n } = useTranslation(); const [meds, setMeds] = useState([]); - const [plannerRows, setPlannerRows] = 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 [plannerLoading, setPlannerLoading] = useState(false); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [editingId, setEditingId] = useState(null); const [form, setForm] = useState(defaultForm()); - const [range, setRange] = useState<{ start: string; end: string }>({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) }); - const [view, setView] = useState<"dashboard" | "medications" | "planner">("dashboard"); + 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 schedule = useMemo(() => buildSchedulePreview(meds), [meds]); + 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 totalTablets = useMemo(() => deriveTotal(form), [form]); - const coverage = useMemo(() => calculateCoverage(meds, schedule.events), [meds, schedule.events]); + const coverage = useMemo(() => calculateCoverage(meds, schedule.events, i18n.language), [meds, schedule.events, i18n.language]); 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(() => { - const days = new Map }>(); - schedule.events.slice(0, 30).forEach((event) => { + type DoseInfo = { id: string; timeStr: string; when: number; usage: number }; + const days = new Map }>(); + schedule.events.slice(0, 2000).forEach((event) => { const day = days.get(event.dateStr) ?? { dateStr: event.dateStr, meds: new Map() }; - const medEntry = day.meds.get(event.medName) ?? { medName: event.medName, total: 0, times: [], lastWhen: event.when }; + const medEntry = day.meds.get(event.medName) ?? { medName: event.medName, total: 0, doses: [], lastWhen: event.when }; medEntry.total += event.usage; - medEntry.times.push(event.timeStr); + medEntry.doses.push({ id: event.id, timeStr: event.timeStr, when: event.when, usage: event.usage }); medEntry.lastWhen = Math.max(medEntry.lastWhen, event.when); day.meds.set(event.medName, medEntry); days.set(event.dateStr, day); }); - return Array.from(days.values()).map((d) => ({ dateStr: d.dateStr, meds: Array.from(d.meds.values()) })); - }, [schedule.events]); + return Array.from(days.values()).map((d) => ({ dateStr: d.dateStr, meds: Array.from(d.meds.values()) })).slice(0, scheduleDays); + }, [schedule.events, scheduleDays]); useEffect(() => { loadMeds(); + loadSettings(); }, []); function loadMeds() { @@ -103,12 +254,204 @@ 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]; @@ -129,10 +472,16 @@ 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) })), }); } @@ -153,10 +502,16 @@ 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) })), }; @@ -179,276 +534,1038 @@ 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 · Planner

-

Manage medication plans

+
+ MedAssist +
+

{pageInfo.eyebrow}

+

{pageInfo.title}

+
-
- - - +
+
+ + + +
+ +
- {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) && ( +
+ {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)} +
+ ); + })} +
+ {(settings.emailEnabled || settings.shoutrrrEnabled) && ( +
+ + {reminderEmailResult && ( + + {reminderEmailResult.message} + + )} +
+ )} + )}
-
-
-
-

Medication Overview

- Stock -
-
-
- Name - Current pills - Days left - Runs out +
+
+
+

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

+ {t('dashboard.overview.badge')}
- {coverage.all.map((row) => ( -
- {row.name} - {formatNumber(row.medsLeft)} - {formatNumber(row.daysLeft)} - {row.depletionDate ?? "-"} +
+
+ {t('table.name')} + {t('table.currentPills')} + {t('table.daysLeft')} + {t('table.runsOut')} + {t('table.expiry')} + {t('table.status')}
- ))} -
-
-
- -
-
-
-

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; + {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" : ""; return ( -
-
-
{item.medName}
-
- {item.total} pills total - - {outOfStock ? "⚠ No pills left" : "✓ Stock OK"} - -
-
-
-
{item.times.join(" · ")}
-
+
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)}
); })}
- ))} -
-
-
- - )} +
+
- {view === "medications" && ( -
-
-
-

Medication list

- {loading ? "Loading..." : `${meds.length} entries`} -
-
- {meds.map((med) => ( -
-
-
-
{med.name}
-
- Packs: {med.packCount ?? 1} - Blisters per pack: {med.stripsPerPack ?? med.strips ?? 1} - Pills per blister: {med.tabsPerStrip ?? med.stripSize} - Loose: {med.looseTablets ?? 0} -
-
Total: {med.count} pills
+
+
+
+

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)} +
+ ))}
-
- {med.slices.map((s, idx) => ( -
- {s.usage} {s.usage === 1 ? "pill" : "pills"} · every {s.every} {s.every === 1 ? "day" : "days"} · from {formatDateTime(s.start)} + ))} +
+
+ +
+
+

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

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