import { useEffect, useMemo, useState } from "react"; import { Routes, Route, useNavigate, useLocation, Navigate } from "react-router-dom"; type Slice = { usage: number; every: number; start: string; }; type Medication = { id: number; name: string; genericName?: string | null; count: number; strips: number; stripSize: number; packCount?: number; stripsPerPack?: number; tabsPerStrip?: number; looseTablets?: number; slices: Slice[]; imageUrl?: string | null; expiryDate?: string | null; notes?: string | null; updatedAt: string | number | null; }; type PlannerRow = { medicationId: number; medicationName: string; totalPills: number; plannerUsage: number; stripSize: number; stripsNeeded: number; stripsAvailable: number; enough: boolean; }; type FormSlice = { usage: string; every: string; start: string }; type FormState = { name: string; genericName: string; packCount: string; stripsPerPack: string; tabsPerStrip: string; looseTablets: string; expiryDate: string; notes: string; slices: FormSlice[]; }; const defaultSlice = (): FormSlice => ({ usage: "1", every: "1", start: toInputValue(new Date().toISOString()) }); const defaultForm = (): FormState => ({ name: "", genericName: "", packCount: "1", stripsPerPack: "1", tabsPerStrip: "1", looseTablets: "0", expiryDate: "", notes: "", slices: [defaultSlice()] }); 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 default function App() { const [meds, setMeds] = useState([]); const [plannerRows, setPlannerRows] = useState([]); const [plannerLoading, setPlannerLoading] = useState(false); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [editingId, setEditingId] = useState(null); const [form, setForm] = useState(defaultForm()); const [range, setRange] = useState<{ start: string; end: string }>({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) }); 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: "", }); 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); // 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 (selectedMed) { setSelectedMed(null); } } }; document.addEventListener("keydown", handleEscape); return () => document.removeEventListener("keydown", handleEscape); }, [selectedMed, showImageLightbox]); // 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), [meds]); const totalTablets = useMemo(() => deriveTotal(form), [form]); 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 groupedSchedule = useMemo(() => { type DoseInfo = { id: string; timeStr: string; when: number; usage: number }; const days = new Map }>(); schedule.events.slice(0, 200).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 }; medEntry.total += event.usage; medEntry.doses.push({ id: event.id, timeStr: event.timeStr, when: event.when, usage: event.usage }); medEntry.lastWhen = Math.max(medEntry.lastWhen, event.when); day.meds.set(event.medName, medEntry); days.set(event.dateStr, day); }); return Array.from(days.values()).map((d) => ({ dateStr: d.dateStr, meds: Array.from(d.meds.values()) })); }, [schedule.events]); useEffect(() => { loadMeds(); loadSettings(); }, []); function loadMeds() { setLoading(true); fetch("/api/medications") .then((res) => res.json()) .then((data: Medication[]) => setMeds(data)) .catch(() => setMeds([])) .finally(() => setLoading(false)); } function loadSettings() { setSettingsLoading(true); fetch("/api/settings") .then((res) => res.json()) .then((data) => { const newSettings = { ...settings, ...data, smtpPass: "" }; setSettings(newSettings); setSavedSettings(newSettings); setSettingsSaved(false); }) .catch(() => {}) .finally(() => setSettingsLoading(false)); } async function saveSettings(e: React.FormEvent) { e.preventDefault(); 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, 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]; next[idx] = { ...next[idx], [field]: value }; return { ...prev, slices: next }; }); } function addSlice() { setForm((prev) => ({ ...prev, slices: [...prev.slices, defaultSlice()] })); } function removeSlice(idx: number) { setForm((prev) => ({ ...prev, slices: prev.slices.filter((_, i) => i !== idx) })); } function startEdit(med: Medication) { setEditingId(med.id); setForm({ name: med.name, genericName: med.genericName ?? "", packCount: String(med.packCount ?? 1), stripsPerPack: String(med.stripsPerPack ?? med.strips ?? 1), tabsPerStrip: String(med.tabsPerStrip ?? med.stripSize ?? 1), looseTablets: String(med.looseTablets ?? 0), expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "", notes: med.notes ?? "", slices: med.slices.map((s) => ({ usage: String(s.usage), every: String(s.every), start: toInputValue(s.start) })), }); } function resetForm() { setEditingId(null); setForm(defaultForm()); } function handleValueChange(key: K, value: string) { setForm((prev) => ({ ...prev, [key]: value })); } async function saveMedication(e: React.FormEvent) { e.preventDefault(); if (!form.name.trim()) return; setSaving(true); const payload = { name: form.name.trim(), genericName: form.genericName.trim() || null, 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), expiryDate: form.expiryDate || null, notes: form.notes.trim() || null, slices: form.slices.map((s) => ({ usage: Number(s.usage) || 0, every: Math.max(1, Number(s.every) || 1), start: toIsoString(s.start) })), }; const method = editingId ? "PUT" : "POST"; const url = editingId ? `/api/medications/${editingId}` : "/api/medications"; await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }).catch(() => null); setSaving(false); resetForm(); loadMeds(); } async function runPlanner(e: React.FormEvent) { e.preventDefault(); setPlannerLoading(true); const body = { startDate: toIsoString(range.start), endDate: toIsoString(range.end) }; const rows = await fetch("/api/medications/usage", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }) .then((res) => res.json()) .catch(() => []) as PlannerRow[]; setPlannerRows(rows); setPlannerLoading(false); } function resetRange() { setRange({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) }); setPlannerRows([]); } 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: "MedAssist · Overview", title: "Dashboard" }, "/medications": { eyebrow: "MedAssist · Inventory", title: "Manage Medications" }, "/planner": { eyebrow: "MedAssist · Planner", title: "Demand Calculator" }, "/settings": { eyebrow: "MedAssist · Configuration", title: "Settings" }, }[currentPath] || { eyebrow: "MedAssist · Overview", title: "Dashboard" }; return (
MedAssist

{pageInfo.eyebrow}

{pageInfo.title}

} /> {settings.emailEnabled && settings.notificationEmail && (
📧 Automatic reminders active — {getReminderStatusText(settings.reminderDaysBefore, coverage.low, settings.lastAutoEmailSent)} → {settings.notificationEmail}
)}

Reorder Reminder

Stock watch
{meds.length === 0 ? (

No medications configured yet.

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

All good, enough stock.

) : ( <>
Name Current pills Days left Status Runs out Auto-remind
{coverage.low.map((row) => { const status = getStockStatus(row.daysLeft, row.medsLeft, settings); const med = meds.find(m => m.name === row.name); return (
med && setSelectedMed(med)}> {row.name}{med?.notes && 📝} {formatNumber(row.medsLeft)} {formatNumber(row.daysLeft)} {status.label} {row.depletionDate ?? "-"} {getNextReminderForMed(row, settings.reminderDaysBefore)}
); })}
{settings.emailEnabled && settings.notificationEmail && (
{reminderEmailResult && ( {reminderEmailResult.message} )}
)} )}

Medication Overview

Stock
Name Current pills Days left Runs out Expiry 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); return (
med && setSelectedMed(med)}> {row.name}{med?.notes && 📝} {formatNumber(row.medsLeft)} {formatNumber(row.daysLeft)} {row.depletionDate ?? "-"} {med?.expiryDate ? new Date(med.expiryDate).toLocaleDateString([], { day: "2-digit", month: "short", year: "2-digit" }) : "-"} {status.label}
); })}

Upcoming Schedules

Next 10 days
{groupedSchedule.map((day) => (
{day.dateStr}
{day.meds.map((item) => { const depletionTime = depletionByMed[item.medName]; const outOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime; const med = meds.find(m => m.name === item.medName); const allTaken = item.doses.every((d) => takenDoses.has(d.id)); const takenCount = item.doses.filter((d) => takenDoses.has(d.id)).length; return (
{item.medName}
{item.total} pills total {outOfStock ? "⚠ No pills left" : "✓ Stock OK"}
{item.doses.map((dose) => { const isTaken = takenDoses.has(dose.id); return (
{dose.timeStr} {dose.usage} pill{dose.usage !== 1 ? "s" : ""} {isTaken ? ( ) : ( )}
); })}
); })}
))}
} />

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
{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 ? "Edit entry" : "New entry"}

Packs + loose pills