1510 lines
58 KiB
TypeScript
1510 lines
58 KiB
TypeScript
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<Medication[]>([]);
|
||
const [plannerRows, setPlannerRows] = useState<PlannerRow[]>([]);
|
||
const [plannerLoading, setPlannerLoading] = useState(false);
|
||
const [loading, setLoading] = useState(false);
|
||
const [saving, setSaving] = useState(false);
|
||
const [editingId, setEditingId] = useState<number | null>(null);
|
||
const [form, setForm] = useState<FormState>(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<Medication | null>(null);
|
||
const [showImageLightbox, setShowImageLightbox] = useState(false);
|
||
|
||
// Track taken doses (stored in localStorage)
|
||
const [takenDoses, setTakenDoses] = useState<Set<string>>(() => {
|
||
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<string, { dateStr: string; meds: Map<string, { medName: string; total: number; doses: DoseInfo[]; lastWhen: number }> }>();
|
||
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<K extends keyof FormState>(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 (
|
||
<main className="page">
|
||
<header className="hero">
|
||
<div className="hero-title">
|
||
<img src="/favicon.svg" alt="MedAssist" className="hero-logo" />
|
||
<div>
|
||
<p className="eyebrow">{pageInfo.eyebrow}</p>
|
||
<h1>{pageInfo.title}</h1>
|
||
</div>
|
||
</div>
|
||
<div className="header-actions">
|
||
<div className="tabs">
|
||
<button className={currentPath === "/dashboard" || currentPath === "/" ? "pill primary" : "pill"} onClick={() => navigate("/dashboard")}>Dashboard</button>
|
||
<button className={currentPath === "/medications" ? "pill primary" : "pill"} onClick={() => navigate("/medications")}>Medications</button>
|
||
<button className={currentPath === "/planner" ? "pill primary" : "pill"} onClick={() => navigate("/planner")}>Planner</button>
|
||
</div>
|
||
<button className={`icon-btn ${currentPath === "/settings" ? "active" : ""}`} onClick={() => navigate("/settings")} title="Settings">⚙️</button>
|
||
<button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}>
|
||
{theme === "dark" ? "☀️" : "🌙"}
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<Routes>
|
||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||
<Route path="/dashboard" element={
|
||
<>
|
||
{settings.emailEnabled && settings.notificationEmail && (
|
||
<section className="email-status-bar">
|
||
<span className="email-status-icon">📧</span>
|
||
<span className="email-status-text">
|
||
Automatic reminders active — {getReminderStatusText(settings.reminderDaysBefore, coverage.low, settings.lastAutoEmailSent)}
|
||
</span>
|
||
<span className="email-status-recipient">→ {settings.notificationEmail}</span>
|
||
</section>
|
||
)}
|
||
<section className="grid">
|
||
<article className="card">
|
||
<div className="card-head">
|
||
<h2>Reorder Reminder</h2>
|
||
<span className="pill neutral">Stock watch</span>
|
||
</div>
|
||
{meds.length === 0 ? (
|
||
<p className="muted">No medications configured yet.</p>
|
||
) : coverage.low.length === 0 ? (
|
||
<p className="success-text">All good, enough stock.</p>
|
||
) : (
|
||
<>
|
||
<div className="table table-6">
|
||
<div className="table-head">
|
||
<span>Name</span>
|
||
<span>Current pills</span>
|
||
<span>Days left</span>
|
||
<span>Status</span>
|
||
<span>Runs out</span>
|
||
<span>Auto-remind</span>
|
||
</div>
|
||
{coverage.low.map((row) => {
|
||
const status = getStockStatus(row.daysLeft, row.medsLeft, settings);
|
||
const med = meds.find(m => m.name === row.name);
|
||
return (
|
||
<div key={row.name} className="table-row clickable" onClick={() => med && setSelectedMed(med)}>
|
||
<span data-label="Name" className="cell-with-avatar"><MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />{row.name}{med?.notes && <span className="notes-icon" title="Has notes">📝</span>}</span>
|
||
<span data-label="Pills" className={row.medsLeft <= 0 ? "danger-text" : ""}>{formatNumber(row.medsLeft)}</span>
|
||
<span data-label="Days" className={status.className === "danger" ? "danger-text" : status.className === "warning" ? "warning-text" : ""}>{formatNumber(row.daysLeft)}</span>
|
||
<span data-label="Status" className={`status-chip ${status.className}`}>{status.label}</span>
|
||
<span data-label="Runs out">{row.depletionDate ?? "-"}</span>
|
||
<span data-label="Auto-remind" className="next-reminder-date">{getNextReminderForMed(row, settings.reminderDaysBefore)}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
{settings.emailEnabled && settings.notificationEmail && (
|
||
<div className="email-send-action">
|
||
<button type="button" className="ghost" onClick={sendReminderEmail} disabled={sendingReminderEmail}>
|
||
{sendingReminderEmail ? "Sending..." : "📧 Send Reminder Now"}
|
||
</button>
|
||
{reminderEmailResult && (
|
||
<span className={reminderEmailResult.success ? "success-text" : "danger-text"}>
|
||
{reminderEmailResult.message}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</article>
|
||
</section>
|
||
|
||
<section className="grid">
|
||
<article className="card">
|
||
<div className="card-head">
|
||
<h2>Medication Overview</h2>
|
||
<span className="pill neutral">Stock</span>
|
||
</div>
|
||
<div className="table table-6">
|
||
<div className="table-head">
|
||
<span>Name</span>
|
||
<span>Current pills</span>
|
||
<span>Days left</span>
|
||
<span>Runs out</span>
|
||
<span>Expiry</span>
|
||
<span>Status</span>
|
||
</div>
|
||
{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 (
|
||
<div key={row.name} className="table-row clickable" onClick={() => med && setSelectedMed(med)}>
|
||
<span data-label="Name" className="cell-with-avatar"><MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />{row.name}{med?.notes && <span className="notes-icon" title="Has notes">📝</span>}</span>
|
||
<span data-label="Pills" className={row.medsLeft <= 0 ? "danger-text" : ""}>{formatNumber(row.medsLeft)}</span>
|
||
<span data-label="Days left" className={status.className === "danger" ? "danger-text" : status.className === "warning" ? "warning-text" : ""}>{formatNumber(row.daysLeft)}</span>
|
||
<span data-label="Runs out">{row.depletionDate ?? "-"}</span>
|
||
<span data-label="Expiry" className={expiryClass}>{med?.expiryDate ? new Date(med.expiryDate).toLocaleDateString([], { day: "2-digit", month: "short", year: "2-digit" }) : "-"}</span>
|
||
<span data-label="Status" className={`status-chip ${status.className}`}>{status.label}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</article>
|
||
</section>
|
||
|
||
<section className="grid">
|
||
<article className="card">
|
||
<div className="card-head">
|
||
<h2>Upcoming Schedules</h2>
|
||
<span className="pill neutral">Next 10 days</span>
|
||
</div>
|
||
<div className="timeline">
|
||
{groupedSchedule.map((day) => (
|
||
<div key={day.dateStr} className="day-block">
|
||
<div className="day-divider">{day.dateStr}</div>
|
||
{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 (
|
||
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
||
<div className="time-main">
|
||
<div className="med-name"><MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />{item.medName}</div>
|
||
<div className="tag-row">
|
||
<span className="tag subtle">{item.total} pills total</span>
|
||
<span className={`tag ${outOfStock ? "danger" : "success"}`}>
|
||
{outOfStock ? "⚠ No pills left" : "✓ Stock OK"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="doses-col">
|
||
{item.doses.map((dose) => {
|
||
const isTaken = takenDoses.has(dose.id);
|
||
return (
|
||
<div key={dose.id} className={`dose-item ${isTaken ? "taken" : ""}`}>
|
||
<span className="dose-time">{dose.timeStr}</span>
|
||
<span className="dose-usage">{dose.usage} pill{dose.usage !== 1 ? "s" : ""}</span>
|
||
{isTaken ? (
|
||
<button className="dose-btn undo" onClick={() => undoDoseTaken(dose.id)} title="Undo">↩</button>
|
||
) : (
|
||
<button className="dose-btn take" onClick={() => markDoseTaken(dose.id)} title="Mark as taken">✓</button>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</article>
|
||
</section>
|
||
</>
|
||
} />
|
||
|
||
<Route path="/medications" element={
|
||
<section className="grid">
|
||
<article className="card meds">
|
||
<div className="card-head">
|
||
<h2>Medication list</h2>
|
||
<span className="pill neutral">{loading ? "Loading..." : `${meds.length} entries`}</span>
|
||
</div>
|
||
<div className="med-list">
|
||
{meds.map((med) => (
|
||
<div key={med.id} className="med-row">
|
||
<div className="med-header">
|
||
<div className="med-info">
|
||
<div className="med-name-row">
|
||
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="lg" />
|
||
<div className="med-name">{med.name}</div>
|
||
</div>
|
||
<div className="med-details">
|
||
<span>Packs: <strong>{med.packCount ?? 1}</strong></span>
|
||
<span>Blisters per pack: <strong>{med.stripsPerPack ?? med.strips ?? 1}</strong></span>
|
||
<span>Pills per blister: <strong>{med.tabsPerStrip ?? med.stripSize}</strong></span>
|
||
<span>Loose: <strong>{med.looseTablets ?? 0}</strong></span>
|
||
</div>
|
||
<div className="med-total">Total: {med.count} pills</div>
|
||
</div>
|
||
<div className="med-actions">
|
||
<button className="ghost" onClick={() => startEdit(med)}>Edit</button>
|
||
<button className="ghost danger" onClick={() => deleteMed(med.id)}>Delete</button>
|
||
</div>
|
||
</div>
|
||
<div className="slice-list">
|
||
{med.slices.map((s, idx) => (
|
||
<div key={`${med.id}-${idx}`} className="slice-row-simple">
|
||
{s.usage} {s.usage === 1 ? "pill" : "pills"} · every {s.every} {s.every === 1 ? "day" : "days"} · from {formatDateTime(s.start)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</article>
|
||
|
||
<article className="card form">
|
||
<div className="card-head">
|
||
<h2>{editingId ? "Edit entry" : "New entry"}</h2>
|
||
<span className="pill">Packs + loose pills</span>
|
||
</div>
|
||
<form className="form-grid" onSubmit={saveMedication}>
|
||
<label>
|
||
Commercial Name
|
||
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="e.g. Ozempic" required />
|
||
</label>
|
||
<label>
|
||
Generic Name
|
||
<input value={form.genericName} onChange={(e) => setForm({ ...form, genericName: e.target.value })} placeholder="e.g. Semaglutide (optional)" />
|
||
</label>
|
||
<label>
|
||
Packs
|
||
<input type="number" min="0" value={form.packCount} onChange={(e) => handleValueChange("packCount", e.target.value)} />
|
||
</label>
|
||
<label>
|
||
Blisters per pack
|
||
<input type="number" min="1" value={form.stripsPerPack} onChange={(e) => handleValueChange("stripsPerPack", e.target.value)} />
|
||
</label>
|
||
<label>
|
||
Pills per blister
|
||
<input type="number" min="1" value={form.tabsPerStrip} onChange={(e) => handleValueChange("tabsPerStrip", e.target.value)} />
|
||
</label>
|
||
<label>
|
||
Loose pills
|
||
<input type="number" min="0" value={form.looseTablets} onChange={(e) => handleValueChange("looseTablets", e.target.value)} />
|
||
</label>
|
||
<label>
|
||
Total (pills)
|
||
<div className="static-value">{formatNumber(totalTablets)}</div>
|
||
</label>
|
||
<label>
|
||
Expiry Date
|
||
<input type="date" value={form.expiryDate} onChange={(e) => handleValueChange("expiryDate", e.target.value)} placeholder="optional" />
|
||
</label>
|
||
|
||
<label className="full">
|
||
Notes
|
||
<textarea
|
||
value={form.notes}
|
||
onChange={(e) => handleValueChange("notes", e.target.value)}
|
||
placeholder="e.g. Take with food, avoid alcohol... (optional)"
|
||
rows={2}
|
||
maxLength={500}
|
||
/>
|
||
</label>
|
||
|
||
<div className="full slices">
|
||
<div className="card-head">
|
||
<h3>Intake schedule</h3>
|
||
<button type="button" className="ghost" onClick={addSlice}>+ Intake</button>
|
||
</div>
|
||
{form.slices.map((s, idx) => (
|
||
<div key={idx} className="slice-row">
|
||
<div className="slice-inputs">
|
||
<label>
|
||
Usage (pills)
|
||
<input type="number" min="0" step="0.1" value={s.usage} onChange={(e) => setSliceValue(idx, "usage", e.target.value)} />
|
||
</label>
|
||
<label>
|
||
Every (days)
|
||
<input type="number" min="1" value={s.every} onChange={(e) => setSliceValue(idx, "every", e.target.value)} />
|
||
</label>
|
||
<label>
|
||
Start (date/time)
|
||
<input type="datetime-local" step="60" value={s.start} onChange={(e) => setSliceValue(idx, "start", e.target.value)} />
|
||
</label>
|
||
</div>
|
||
{form.slices.length > 1 && (
|
||
<button type="button" className="ghost" onClick={() => removeSlice(idx)}>Remove</button>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{editingId && (
|
||
<div className="full image-upload-section">
|
||
<label className="setting-label">Medication Image</label>
|
||
{(() => {
|
||
const currentMed = meds.find(m => m.id === editingId);
|
||
if (currentMed?.imageUrl) {
|
||
return (
|
||
<div className="image-preview">
|
||
<img src={`/api/images/${currentMed.imageUrl}`} alt={currentMed.name} />
|
||
<button type="button" className="ghost danger" onClick={() => deleteMedImage(editingId)}>Remove Image</button>
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<input
|
||
type="file"
|
||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||
onChange={(e) => e.target.files?.[0] && uploadMedImage(editingId, e.target.files[0])}
|
||
disabled={uploadingImage}
|
||
/>
|
||
);
|
||
})()}
|
||
</div>
|
||
)}
|
||
|
||
<div className="full align-end gap">
|
||
{editingId && (
|
||
<button type="button" className="ghost" onClick={resetForm}>
|
||
Cancel
|
||
</button>
|
||
)}
|
||
<button type="submit" disabled={saving}>{saving ? "Saving..." : "Save"}</button>
|
||
</div>
|
||
</form>
|
||
</article>
|
||
</section>
|
||
} />
|
||
|
||
<Route path="/planner" element={
|
||
<section className="grid">
|
||
<article className="card">
|
||
<div className="card-head">
|
||
<h2>Demand Calculator</h2>
|
||
<span className="pill neutral">Plan your supply</span>
|
||
</div>
|
||
<form className="planner" onSubmit={runPlanner}>
|
||
<label>
|
||
From
|
||
<input type="datetime-local" step="60" value={range.start} onChange={(e) => setRange({ ...range, start: e.target.value })} />
|
||
</label>
|
||
<label>
|
||
Until
|
||
<input type="datetime-local" step="60" value={range.end} onChange={(e) => setRange({ ...range, end: e.target.value })} />
|
||
</label>
|
||
<div className="planner-actions">
|
||
<button type="button" className="ghost" onClick={resetRange}>Reset</button>
|
||
<button type="submit" disabled={plannerLoading}>{plannerLoading ? "Calculating..." : "Calculate"}</button>
|
||
</div>
|
||
</form>
|
||
{plannerRows.length > 0 && (
|
||
<>
|
||
<div className="table">
|
||
<div className="table-head">
|
||
<span>Medication</span>
|
||
<span>Usage</span>
|
||
<span>Blisters needed</span>
|
||
<span>Available</span>
|
||
<span>Status</span>
|
||
</div>
|
||
{plannerRows.map((row) => {
|
||
const med = meds.find(m => m.name === row.medicationName);
|
||
return (
|
||
<div key={row.medicationId} className="table-row clickable" onClick={() => med && setSelectedMed(med)}>
|
||
<span data-label="Medication" className="cell-with-avatar"><MedicationAvatar name={row.medicationName} imageUrl={med?.imageUrl} />{row.medicationName}</span>
|
||
<span data-label="Usage"><strong>{row.plannerUsage}</strong> pills</span>
|
||
<span data-label="Blisters">{row.stripsNeeded} × {row.stripSize}</span>
|
||
<span data-label="Available">{row.stripsAvailable} blisters</span>
|
||
<span data-label="Status" className={row.enough ? "status-chip success" : "status-chip danger"}>{row.enough ? "✓ Enough" : "⚠ Out of Stock"}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
{settings.emailEnabled && settings.notificationEmail && (
|
||
<div className="planner-email-action">
|
||
<button type="button" className="ghost" onClick={sendPlannerEmail} disabled={sendingPlannerEmail}>
|
||
{sendingPlannerEmail ? "Sending..." : "📧 Send via Email"}
|
||
</button>
|
||
{plannerEmailResult && (
|
||
<span className={plannerEmailResult.success ? "success-text" : "danger-text"}>
|
||
{plannerEmailResult.message}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</article>
|
||
</section>
|
||
} />
|
||
|
||
<Route path="/settings" element={
|
||
<section className="grid">
|
||
<article className="card">
|
||
<div className="card-head">
|
||
<h2>Automatic Reminders</h2>
|
||
<span className="pill neutral">Daily check at 6:00 AM</span>
|
||
</div>
|
||
{settingsLoading ? (
|
||
<p>Loading settings...</p>
|
||
) : (
|
||
<form className="settings-form" onSubmit={saveSettings}>
|
||
<div className="setting-info-box">
|
||
<p>🤖 <strong>How it works:</strong> The server checks daily at 6:00 AM. When a medication drops below the threshold, you get notified via <strong>all enabled channels</strong> below.</p>
|
||
<div className="enabled-channels" style={{ marginTop: "0.5rem", display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||
<button
|
||
type="button"
|
||
className={`pill clickable ${settings.emailEnabled ? "success" : "neutral"}`}
|
||
onClick={async () => {
|
||
const newSettings = { ...settings, emailEnabled: !settings.emailEnabled };
|
||
setSettings(newSettings);
|
||
try {
|
||
await fetch("/api/settings/notifications", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(newSettings),
|
||
});
|
||
} catch (e) { console.error(e); }
|
||
}}
|
||
style={{ cursor: "pointer", border: "none" }}
|
||
>
|
||
📧 Email: {settings.emailEnabled ? "ON" : "OFF"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`pill clickable ${settings.shoutrrrEnabled ? "success" : "neutral"}`}
|
||
onClick={async () => {
|
||
const newSettings = { ...settings, shoutrrrEnabled: !settings.shoutrrrEnabled };
|
||
setSettings(newSettings);
|
||
try {
|
||
await fetch("/api/settings/notifications", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(newSettings),
|
||
});
|
||
} catch (e) { console.error(e); }
|
||
}}
|
||
style={{ cursor: "pointer", border: "none" }}
|
||
>
|
||
🔔 Push: {settings.shoutrrrEnabled ? "ON" : "OFF"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="setting-section">
|
||
<h3>⚙️ Reminder Settings</h3>
|
||
<p className="setting-hint">These settings apply to both Email and Push notifications.</p>
|
||
<div className="setting-group">
|
||
<label>
|
||
Reminder threshold (days)
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
max="90"
|
||
value={settings.reminderDaysBefore}
|
||
onChange={(e) => setSettings({ ...settings, reminderDaysBefore: Number(e.target.value) || 7 })}
|
||
/>
|
||
<span className="input-hint">Send reminder when stock lasts less than this</span>
|
||
</label>
|
||
</div>
|
||
<div className="setting-row">
|
||
<div className="setting-info">
|
||
<label className="setting-label">Repeat daily reminders</label>
|
||
<p className="setting-desc">Send daily notifications while stock is low (otherwise only once per medication)</p>
|
||
</div>
|
||
<label className="toggle-switch small">
|
||
<input
|
||
type="checkbox"
|
||
checked={settings.repeatDailyReminders}
|
||
onChange={(e) => setSettings({ ...settings, repeatDailyReminders: e.target.checked })}
|
||
/>
|
||
<span className="toggle-slider"></span>
|
||
</label>
|
||
</div>
|
||
<div className="setting-info-box">
|
||
<p>⏰ <strong>Next automatic check:</strong> {settings.nextScheduledCheck ? new Date(settings.nextScheduledCheck).toLocaleString() : "—"}</p>
|
||
{settings.lastAutoEmailSent && (
|
||
<p style={{ marginTop: "0.5rem" }}>✓ Last notification sent: <strong>{new Date(settings.lastAutoEmailSent).toLocaleString()}</strong></p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="setting-section">
|
||
<h3>📧 Email Notifications</h3>
|
||
<div className="setting-row">
|
||
<div className="setting-info">
|
||
<label className="setting-label">Enable Email Notifications</label>
|
||
<p className="setting-desc">Receive reminders via email</p>
|
||
</div>
|
||
<label className="toggle-switch">
|
||
<input
|
||
type="checkbox"
|
||
checked={settings.emailEnabled}
|
||
onChange={(e) => setSettings({ ...settings, emailEnabled: e.target.checked })}
|
||
/>
|
||
<span className="toggle-slider"></span>
|
||
</label>
|
||
</div>
|
||
|
||
{settings.emailEnabled && (
|
||
<>
|
||
<div className="setting-group">
|
||
<label className="full">
|
||
Email address
|
||
<input
|
||
type="email"
|
||
value={settings.notificationEmail}
|
||
onChange={(e) => setSettings({ ...settings, notificationEmail: e.target.value })}
|
||
placeholder="your@email.com"
|
||
/>
|
||
</label>
|
||
</div>
|
||
<div className="smtp-readonly">
|
||
<div className="smtp-field">
|
||
<span className="smtp-label">SMTP Host</span>
|
||
<span className="smtp-value">{settings.smtpHost || "—"}</span>
|
||
</div>
|
||
<div className="smtp-field">
|
||
<span className="smtp-label">Port</span>
|
||
<span className="smtp-value">{settings.smtpPort}</span>
|
||
</div>
|
||
<div className="smtp-field">
|
||
<span className="smtp-label">From</span>
|
||
<span className="smtp-value">{settings.smtpFrom || "—"}</span>
|
||
</div>
|
||
<div className="smtp-field">
|
||
<span className="smtp-label">Status</span>
|
||
<span className="smtp-value">{settings.hasSmtpPassword ? "✓ Configured" : "Not configured"}</span>
|
||
</div>
|
||
</div>
|
||
<p className="setting-hint" style={{ marginTop: "0.5rem" }}>SMTP is configured via <code>.env</code> file</p>
|
||
<div className="setting-actions">
|
||
<button type="button" className="ghost" onClick={testEmail} disabled={testingEmail || !settings.notificationEmail}>
|
||
{testingEmail ? "Sending..." : "Send Test Email"}
|
||
</button>
|
||
{testEmailResult && (
|
||
<span className={testEmailResult.success ? "success-text" : "danger-text"}>
|
||
{testEmailResult.message}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<div className="setting-section">
|
||
<h3>🔔 Shoutrrr Push Notifications</h3>
|
||
<p className="setting-hint">Send push notifications via Shoutrrr-compatible services (ntfy, Discord, Telegram, Slack, etc.). Uses the same reminder threshold.</p>
|
||
|
||
<div className="setting-row">
|
||
<div className="setting-info">
|
||
<label className="setting-label">Enable Shoutrrr Notifications</label>
|
||
<p className="setting-desc">Receive reminders via push notification services</p>
|
||
</div>
|
||
<label className="toggle-switch">
|
||
<input
|
||
type="checkbox"
|
||
checked={settings.shoutrrrEnabled}
|
||
onChange={(e) => setSettings({ ...settings, shoutrrrEnabled: e.target.checked })}
|
||
/>
|
||
<span className="toggle-slider"></span>
|
||
</label>
|
||
</div>
|
||
|
||
{settings.shoutrrrEnabled && (
|
||
<>
|
||
<div className="setting-group">
|
||
<label className="full">
|
||
Notification URL
|
||
<input
|
||
type="url"
|
||
value={settings.shoutrrrUrl}
|
||
onChange={(e) => setSettings({ ...settings, shoutrrrUrl: e.target.value })}
|
||
placeholder="https://ntfy.sh/your-topic"
|
||
pattern="(https?|ntfy|discord|telegram|slack):\/\/.+"
|
||
/>
|
||
<span className="input-hint">
|
||
Examples: <code>https://ntfy.sh/mytopic</code> · <code>ntfy://ntfy.sh/mytopic</code> · <code>discord://token@id</code>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
<div className="setting-actions">
|
||
<button type="button" className="ghost" onClick={testShoutrrr} disabled={testingShoutrrr || !settings.shoutrrrUrl}>
|
||
{testingShoutrrr ? "Sending..." : "Send Test Notification"}
|
||
</button>
|
||
{testShoutrrrResult && (
|
||
<span className={testShoutrrrResult.success ? "success-text" : "danger-text"}>
|
||
{testShoutrrrResult.message}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<div className="setting-section">
|
||
<h3>📊 Stock Thresholds</h3>
|
||
<p className="setting-hint">Define stock level colors based on how many days of medication you have left.</p>
|
||
<div className="setting-group">
|
||
<label>
|
||
Low Stock (days)
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
max="365"
|
||
value={settings.lowStockDays}
|
||
onChange={(e) => setSettings({ ...settings, lowStockDays: Number(e.target.value) || 30 })}
|
||
/>
|
||
<span className="input-hint">⚠ Yellow below this</span>
|
||
</label>
|
||
<label>
|
||
High Stock (days)
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
max="730"
|
||
value={settings.highStockDays}
|
||
onChange={(e) => setSettings({ ...settings, highStockDays: Number(e.target.value) || 180 })}
|
||
/>
|
||
<span className="input-hint">★ Green with star above this</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="form-footer">
|
||
<button type="submit" disabled={settingsSaving || (!settingsChanged && settingsSaved)}>
|
||
{settingsSaving ? "Saving..." : settingsSaved && !settingsChanged ? "Saved ✓" : "Save Settings"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
)}
|
||
</article>
|
||
</section>
|
||
} />
|
||
</Routes>
|
||
|
||
{/* Medication Detail Modal */}
|
||
{selectedMed && (
|
||
<div className="modal-overlay" onClick={() => setSelectedMed(null)}>
|
||
<div className="modal-content med-detail-modal" onClick={(e) => e.stopPropagation()}>
|
||
<button className="modal-close" onClick={() => setSelectedMed(null)}>×</button>
|
||
|
||
<div className="med-detail-header">
|
||
<div
|
||
className={`med-detail-avatar-wrapper ${selectedMed.imageUrl ? 'clickable' : ''}`}
|
||
onClick={() => selectedMed.imageUrl && setShowImageLightbox(true)}
|
||
>
|
||
<MedicationAvatar name={selectedMed.name} imageUrl={selectedMed.imageUrl} size="lg" />
|
||
{selectedMed.imageUrl && <span className="expand-icon">🔍</span>}
|
||
</div>
|
||
<div className="med-detail-titles">
|
||
<h2>{selectedMed.name}</h2>
|
||
{selectedMed.genericName && <span className="med-generic-name">{selectedMed.genericName}</span>}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="med-detail-body">
|
||
<div className="med-detail-section">
|
||
<h3>Stock Information</h3>
|
||
<div className="med-detail-grid">
|
||
<div className="med-detail-item">
|
||
<span className="med-detail-label">Total Pills</span>
|
||
<span className="med-detail-value">{formatNumber(selectedMed.count)}</span>
|
||
</div>
|
||
<div className="med-detail-item">
|
||
<span className="med-detail-label">Packs</span>
|
||
<span className="med-detail-value">{selectedMed.packCount ?? 0}</span>
|
||
</div>
|
||
<div className="med-detail-item">
|
||
<span className="med-detail-label">Blisters/Pack</span>
|
||
<span className="med-detail-value">{selectedMed.stripsPerPack ?? 0}</span>
|
||
</div>
|
||
<div className="med-detail-item">
|
||
<span className="med-detail-label">Pills/Blister</span>
|
||
<span className="med-detail-value">{selectedMed.tabsPerStrip ?? 1}</span>
|
||
</div>
|
||
<div className="med-detail-item">
|
||
<span className="med-detail-label">Loose Pills</span>
|
||
<span className="med-detail-value">{selectedMed.looseTablets ?? 0}</span>
|
||
</div>
|
||
<div className="med-detail-item">
|
||
<span className="med-detail-label">Expiry Date</span>
|
||
<span className={`med-detail-value ${selectedMed.expiryDate && new Date(selectedMed.expiryDate) < new Date() ? 'danger-text' : ''}`}>
|
||
{selectedMed.expiryDate ? new Date(selectedMed.expiryDate).toLocaleDateString([], { day: "2-digit", month: "short", year: "numeric" }) : "—"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{selectedMed.slices.length > 0 && (
|
||
<div className="med-detail-section">
|
||
<h3>Intake Schedule</h3>
|
||
<div className="med-detail-schedules">
|
||
{selectedMed.slices.map((slice, idx) => (
|
||
<div key={idx} className="med-schedule-item">
|
||
<span className="med-schedule-usage">{slice.usage} pill{slice.usage !== 1 ? "s" : ""}</span>
|
||
<span className="med-schedule-freq">every {slice.every} day{slice.every !== 1 ? "s" : ""}</span>
|
||
<span className="med-schedule-time">at {new Date(slice.start).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{(() => {
|
||
const medCoverage = coverage.all.find(c => c.name === selectedMed.name);
|
||
if (!medCoverage) return null;
|
||
const status = getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings);
|
||
return (
|
||
<div className="med-detail-section">
|
||
<h3>Coverage Status</h3>
|
||
<div className="med-detail-grid">
|
||
<div className="med-detail-item">
|
||
<span className="med-detail-label">Days Left</span>
|
||
<span className="med-detail-value">{medCoverage.daysLeft !== null ? formatNumber(medCoverage.daysLeft) : "—"}</span>
|
||
</div>
|
||
<div className="med-detail-item">
|
||
<span className="med-detail-label">Runs Out</span>
|
||
<span className="med-detail-value">{medCoverage.depletionDate ?? "—"}</span>
|
||
</div>
|
||
<div className="med-detail-item full-width">
|
||
<span className="med-detail-label">Status</span>
|
||
<span className={`status-chip ${status.className}`}>{status.label}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{selectedMed.notes && (
|
||
<div className="med-detail-section">
|
||
<h3>📝 Notes</h3>
|
||
<div className="med-notes-content">
|
||
{selectedMed.notes}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="med-detail-footer">
|
||
<button className="ghost" onClick={() => { setSelectedMed(null); setShowImageLightbox(false); navigate("/medications"); setEditingId(selectedMed.id); }}>
|
||
Edit Medication
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Image Lightbox */}
|
||
{showImageLightbox && selectedMed.imageUrl && (
|
||
<div className="lightbox-overlay" onClick={() => setShowImageLightbox(false)}>
|
||
<button className="lightbox-close" onClick={() => setShowImageLightbox(false)}>×</button>
|
||
<img
|
||
src={`/api/images/${selectedMed.imageUrl}`}
|
||
alt={selectedMed.name}
|
||
className="lightbox-image"
|
||
onClick={(e) => e.stopPropagation()}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</main>
|
||
);
|
||
}
|
||
|
||
function deriveTotal(form: FormState) {
|
||
const packCount = Number(form.packCount) || 0;
|
||
const stripsPerPack = Number(form.stripsPerPack) || 0;
|
||
const tabsPerStrip = Number(form.tabsPerStrip) || 1;
|
||
const looseTablets = Number(form.looseTablets) || 0;
|
||
return packCount * stripsPerPack * tabsPerStrip + looseTablets;
|
||
}
|
||
|
||
function toIsoString(value: string) {
|
||
if (!value) return new Date().toISOString();
|
||
const date = new Date(value);
|
||
return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString();
|
||
}
|
||
|
||
function toInputValue(value: string) {
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return new Date().toISOString().slice(0, 16);
|
||
const iso = date.toISOString();
|
||
return iso.slice(0, 16);
|
||
}
|
||
|
||
function formatDateTime(value: string) {
|
||
const d = new Date(value);
|
||
if (Number.isNaN(d.getTime())) return value;
|
||
return d.toLocaleString([], { weekday: "short", day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
|
||
}
|
||
|
||
function buildSchedulePreview(meds: Medication[]) {
|
||
const events: Array<{ id: string; medName: string; timeStr: string; dateStr: string; usage: number; when: number }> = [];
|
||
const now = new Date();
|
||
const end = new Date();
|
||
end.setDate(end.getDate() + 10);
|
||
|
||
meds.forEach((med) => {
|
||
med.slices.forEach((slice, idx) => {
|
||
const start = new Date(slice.start);
|
||
if (Number.isNaN(start.getTime())) return;
|
||
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + slice.every)) {
|
||
if (d < now) continue;
|
||
const whenMs = d.getTime();
|
||
events.push({
|
||
id: `${med.id}-${idx}-${whenMs}`,
|
||
medName: med.name,
|
||
usage: slice.usage,
|
||
when: whenMs,
|
||
timeStr: d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
||
dateStr: d.toLocaleDateString([], { weekday: "short", day: "2-digit", month: "short" }),
|
||
});
|
||
}
|
||
});
|
||
});
|
||
|
||
events.sort((a, b) => a.when - b.when);
|
||
|
||
const todayCount = events.filter((e) => {
|
||
const t = new Date(e.when);
|
||
const n = new Date();
|
||
return t.getFullYear() === n.getFullYear() && t.getMonth() === n.getMonth() && t.getDate() === n.getDate();
|
||
}).length;
|
||
|
||
return { events, today: todayCount, nextThree: events.length, totalSlices: meds.reduce((acc, m) => acc + m.slices.length, 0) };
|
||
}
|
||
|
||
function formatNumber(value: number | null) {
|
||
if (value === null || Number.isNaN(value)) return "-";
|
||
if (Math.abs(value % 1) < 0.05) return Math.round(value).toLocaleString();
|
||
return value.toFixed(1);
|
||
}
|
||
|
||
function getExpiryClass(expiryDate: string | null | undefined): string {
|
||
if (!expiryDate) return "";
|
||
const now = new Date();
|
||
const expiry = new Date(expiryDate);
|
||
const diffMs = expiry.getTime() - now.getTime();
|
||
const diffDays = diffMs / (1000 * 60 * 60 * 24);
|
||
|
||
if (diffDays <= 7) return "danger-text"; // 1 week or less (or expired)
|
||
if (diffDays <= 30) return "warning-text"; // 1 month or less
|
||
return "success-text"; // more than 1 month
|
||
}
|
||
|
||
function calculateCoverage(meds: Medication[], events: Array<{ medName: string; when: number }>) {
|
||
const MS_PER_DAY = 86_400_000;
|
||
const now = Date.now();
|
||
|
||
const coverage: Coverage[] = meds.map((m) => {
|
||
const dailyRate = m.slices.reduce((sum, s) => sum + (s.every > 0 ? s.usage / s.every : 0), 0);
|
||
|
||
let consumed = 0;
|
||
m.slices.forEach((s) => {
|
||
const start = new Date(s.start).getTime();
|
||
if (Number.isNaN(start) || start > now) return;
|
||
const period = Math.max(1, s.every) * MS_PER_DAY;
|
||
const occurrences = Math.floor((now - start) / period) + 1; // include today if started
|
||
consumed += occurrences * s.usage;
|
||
});
|
||
|
||
const medsLeft = Math.max(0, m.count - consumed);
|
||
const rawDaysLeft = dailyRate > 0 ? medsLeft / dailyRate : null;
|
||
const daysLeft = rawDaysLeft !== null ? Math.max(0, Math.floor(rawDaysLeft)) : null; // conservative: round down
|
||
const depletionMs = daysLeft !== null ? now + daysLeft * MS_PER_DAY : null;
|
||
const depletionDate = depletionMs !== null ? new Date(depletionMs).toLocaleDateString([], { weekday: "short", day: "2-digit", month: "short" }) : null;
|
||
const nextEvent = events.find((e) => e.medName === m.name);
|
||
|
||
return {
|
||
name: m.name,
|
||
medsLeft: Number(medsLeft.toFixed(1)),
|
||
daysLeft,
|
||
depletionDate,
|
||
depletionTime: depletionMs,
|
||
nextDose: nextEvent ? new Date(nextEvent.when).toLocaleString([], { weekday: "short", day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" }) : null,
|
||
};
|
||
});
|
||
|
||
const low = coverage.filter((c) => c.medsLeft <= 0 || (c.daysLeft !== null && c.daysLeft <= 3));
|
||
return { low, all: coverage };
|
||
}
|
||
|
||
function getReminderStatusText(reminderDaysBefore: number, lowStock: Coverage[], lastSent: string | null): React.ReactNode {
|
||
// Find the earliest medication that needs a reminder (based on reminderDaysBefore)
|
||
const medsNeedingReminder = lowStock
|
||
.filter((c) => c.depletionTime !== null && c.daysLeft !== null && c.daysLeft <= reminderDaysBefore)
|
||
.sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0));
|
||
|
||
const formatLastSent = (iso: string) => {
|
||
const date = new Date(iso);
|
||
return date.toLocaleDateString([], { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
|
||
};
|
||
|
||
if (medsNeedingReminder.length > 0) {
|
||
// There are medications that need reminders
|
||
if (lastSent) {
|
||
return (
|
||
<>
|
||
<strong className="warning-text">⚠ {medsNeedingReminder.length} med{medsNeedingReminder.length > 1 ? "s" : ""} need reorder</strong>
|
||
{" · "}Last reminder: {formatLastSent(lastSent)}
|
||
</>
|
||
);
|
||
}
|
||
return <strong className="warning-text">⚠ {medsNeedingReminder.length} med{medsNeedingReminder.length > 1 ? "s" : ""} need reorder — waiting for first check</strong>;
|
||
}
|
||
|
||
// Calculate when next reminder would be triggered
|
||
const allWithDepletion = lowStock
|
||
.filter((c) => c.depletionTime !== null && c.daysLeft !== null)
|
||
.sort((a, b) => (a.daysLeft ?? Infinity) - (b.daysLeft ?? Infinity));
|
||
|
||
if (allWithDepletion.length > 0) {
|
||
const nextMed = allWithDepletion[0];
|
||
const daysUntilReminder = (nextMed.daysLeft ?? 0) - reminderDaysBefore;
|
||
if (daysUntilReminder > 0) {
|
||
return (
|
||
<>
|
||
<span className="success-text">✓ All OK</span>
|
||
{" · "}Next: <strong>{nextMed.name}</strong> in {daysUntilReminder} days
|
||
</>
|
||
);
|
||
}
|
||
}
|
||
|
||
// No low stock medications at all
|
||
if (lastSent) {
|
||
return (
|
||
<>
|
||
<span className="success-text">✓ All stock OK</span>
|
||
{" · "}Last reminder: {formatLastSent(lastSent)}
|
||
</>
|
||
);
|
||
}
|
||
return <span className="success-text">✓ All stock OK — no reminders needed</span>;
|
||
}
|
||
|
||
function getNextReminderForMed(med: Coverage, reminderDaysBefore: number): string {
|
||
if (!med.depletionTime) return "—";
|
||
|
||
const reminderTime = med.depletionTime - reminderDaysBefore * 86_400_000;
|
||
const now = Date.now();
|
||
|
||
if (reminderTime <= now) {
|
||
return "Due now";
|
||
}
|
||
|
||
return new Date(reminderTime).toLocaleDateString([], {
|
||
day: "2-digit",
|
||
month: "short",
|
||
});
|
||
}
|
||
|
||
type StockStatus = {
|
||
level: "out-of-stock" | "low" | "normal" | "high";
|
||
className: string;
|
||
label: string;
|
||
};
|
||
|
||
type StockThresholds = {
|
||
lowStockDays: number;
|
||
normalStockDays: number;
|
||
highStockDays: number;
|
||
};
|
||
|
||
function getStockStatus(daysLeft: number | null, medsLeft: number, thresholds: StockThresholds): StockStatus {
|
||
// Out of stock: 0 pills
|
||
if (medsLeft <= 0 || daysLeft === 0) {
|
||
return { level: "out-of-stock", className: "danger", label: "Out of Stock" };
|
||
}
|
||
|
||
// No schedule set (no daysLeft calculation possible)
|
||
if (daysLeft === null) {
|
||
return { level: "normal", className: "success", label: "No Schedule" };
|
||
}
|
||
|
||
// High stock: > highStockDays (e.g. > 180 days)
|
||
if (daysLeft > thresholds.highStockDays) {
|
||
return { level: "high", className: "high", label: "★ High Stock" };
|
||
}
|
||
|
||
// Normal stock: between lowStockDays and highStockDays
|
||
if (daysLeft >= thresholds.lowStockDays) {
|
||
return { level: "normal", className: "success", label: "Normal" };
|
||
}
|
||
|
||
// Low stock: < lowStockDays (e.g. < 30 days)
|
||
return { level: "low", className: "warning", label: "Low Stock" };
|
||
}
|
||
|
||
function MedicationAvatar({ name, imageUrl, size = "sm" }: { name: string; imageUrl?: string | null; size?: "sm" | "md" | "lg" }) {
|
||
const initials = name.split(" ").map(w => w[0]).join("").toUpperCase().slice(0, 2) || "?";
|
||
const sizeClass = `med-avatar med-avatar-${size}`;
|
||
|
||
if (imageUrl) {
|
||
return <img src={`/api/images/${imageUrl}`} alt={name} className={sizeClass} />;
|
||
}
|
||
return <div className={`${sizeClass} med-avatar-initials`}>{initials}</div>;
|
||
}
|