feat: add planner routes and email functionality; update settings and App component for new stock thresholds and email reminders

This commit is contained in:
Daniel Volz
2025-12-20 17:26:46 +01:00
parent ce02ab8372
commit 5cb5e2ba26
6 changed files with 764 additions and 48 deletions
+291 -46
View File
@@ -81,6 +81,9 @@ export default function App() {
emailEnabled: false,
notificationEmail: "",
reminderDaysBefore: 7,
lowStockDays: 30,
normalStockDays: 90,
highStockDays: 180,
smtpHost: "",
smtpPort: 587,
smtpUser: "",
@@ -95,11 +98,24 @@ export default function App() {
const [settingsSaved, setSettingsSaved] = useState(false);
const [testingEmail, setTestingEmail] = useState(false);
const [testEmailResult, setTestEmailResult] = 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 [lastReminderSent, setLastReminderSent] = useState<string | null>(() => {
if (typeof window !== "undefined") {
return localStorage.getItem("lastReminderSent");
}
return null;
});
// Check if settings have changed
const settingsChanged = settings.emailEnabled !== savedSettings.emailEnabled ||
settings.notificationEmail !== savedSettings.notificationEmail ||
settings.reminderDaysBefore !== savedSettings.reminderDaysBefore;
settings.reminderDaysBefore !== savedSettings.reminderDaysBefore ||
settings.lowStockDays !== savedSettings.lowStockDays ||
settings.normalStockDays !== savedSettings.normalStockDays ||
settings.highStockDays !== savedSettings.highStockDays;
const schedule = useMemo(() => buildSchedulePreview(meds), [meds]);
const totalTablets = useMemo(() => deriveTotal(form), [form]);
@@ -156,6 +172,9 @@ export default function App() {
emailEnabled: settings.emailEnabled,
notificationEmail: settings.notificationEmail,
reminderDaysBefore: settings.reminderDaysBefore,
lowStockDays: settings.lowStockDays,
normalStockDays: settings.normalStockDays,
highStockDays: settings.highStockDays,
smtpHost: settings.smtpHost,
smtpPort: settings.smtpPort,
smtpUser: settings.smtpUser,
@@ -198,6 +217,63 @@ export default function App() {
setTestingEmail(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) {
const sentDate = new Date().toLocaleDateString([], { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
setLastReminderSent(sentDate);
localStorage.setItem("lastReminderSent", sentDate);
setReminderEmailResult({ success: true, message: data.message || "Email sent!" });
} 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();
@@ -321,6 +397,15 @@ export default function App() {
<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">
Email reminders active Next check: <strong>{getNextReminderDate(settings.reminderDaysBefore, coverage.low)}</strong>
</span>
<span className="email-status-recipient"> {settings.notificationEmail}</span>
</section>
)}
<section className="grid">
<article className="card">
<div className="card-head">
@@ -330,24 +415,45 @@ export default function App() {
{coverage.low.length === 0 ? (
<p className="success-text">All good, enough stock.</p>
) : (
<div className="table">
<div className="table-head">
<span>Name</span>
<span>Current pills</span>
<span>Days left</span>
<span>Runs out</span>
<span>Next dose</span>
</div>
{coverage.low.map((row) => (
<div key={row.name} className="table-row">
<span>{row.name}</span>
<span className={row.medsLeft <= 0 ? "danger-text" : ""}>{formatNumber(row.medsLeft)}</span>
<span className={row.daysLeft !== null && row.daysLeft <= 0 ? "danger-text" : ""}>{formatNumber(row.daysLeft)}</span>
<span>{row.depletionDate ?? "-"}</span>
<span>{row.nextDose ?? "-"}</span>
<>
<div className="table table-7">
<div className="table-head">
<span>Name</span>
<span>Current pills</span>
<span>Days left</span>
<span>Status</span>
<span>Runs out</span>
<span>Next reminder</span>
<span>Email sent</span>
</div>
))}
</div>
{coverage.low.map((row) => {
const status = getStockStatus(row.daysLeft, row.medsLeft, settings);
return (
<div key={row.name} className="table-row">
<span>{row.name}</span>
<span className={row.medsLeft <= 0 ? "danger-text" : ""}>{formatNumber(row.medsLeft)}</span>
<span className={status.className === "danger" ? "danger-text" : status.className === "warning" ? "warning-text" : ""}>{formatNumber(row.daysLeft)}</span>
<span className={`status-chip ${status.className}`}>{status.label}</span>
<span>{row.depletionDate ?? "-"}</span>
<span className="next-reminder-date">{getNextReminderForMed(row, settings.reminderDaysBefore)}</span>
<span className="email-sent-status">{lastReminderSent ?? "—"}</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 Email"}
</button>
{reminderEmailResult && (
<span className={reminderEmailResult.success ? "success-text" : "danger-text"}>
{reminderEmailResult.message}
</span>
)}
</div>
)}
</>
)}
</article>
</section>
@@ -358,21 +464,26 @@ export default function App() {
<h2>Medication Overview</h2>
<span className="pill neutral">Stock</span>
</div>
<div className="table table-4">
<div className="table table-5">
<div className="table-head">
<span>Name</span>
<span>Current pills</span>
<span>Days left</span>
<span>Runs out</span>
<span>Status</span>
</div>
{coverage.all.map((row) => (
<div key={row.name} className="table-row">
<span>{row.name}</span>
<span className={row.medsLeft <= 0 ? "danger-text" : ""}>{formatNumber(row.medsLeft)}</span>
<span>{formatNumber(row.daysLeft)}</span>
<span>{row.depletionDate ?? "-"}</span>
</div>
))}
{coverage.all.map((row) => {
const status = getStockStatus(row.daysLeft, row.medsLeft, settings);
return (
<div key={row.name} className="table-row">
<span>{row.name}</span>
<span className={row.medsLeft <= 0 ? "danger-text" : ""}>{formatNumber(row.medsLeft)}</span>
<span className={status.className === "danger" ? "danger-text" : status.className === "warning" ? "warning-text" : ""}>{formatNumber(row.daysLeft)}</span>
<span>{row.depletionDate ?? "-"}</span>
<span className={`status-chip ${status.className}`}>{status.label}</span>
</div>
);
})}
</div>
</article>
</section>
@@ -547,24 +658,38 @@ export default function App() {
</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) => (
<div key={row.medicationId} className="table-row">
<span>{row.medicationName}</span>
<span><strong>{row.plannerUsage}</strong> pills</span>
<span>{row.stripsNeeded} × {row.stripSize}</span>
<span>{row.stripsAvailable}</span>
<span className={row.enough ? "status-chip success" : "status-chip danger"}>{row.enough ? "Enough" : "Low"}</span>
<>
<div className="table">
<div className="table-head">
<span>Medication</span>
<span>Usage</span>
<span>Blisters needed</span>
<span>Available</span>
<span>Status</span>
</div>
))}
</div>
{plannerRows.map((row) => (
<div key={row.medicationId} className="table-row">
<span>{row.medicationName}</span>
<span><strong>{row.plannerUsage}</strong> pills</span>
<span>{row.stripsNeeded} × {row.stripSize}</span>
<span>{row.stripsAvailable} blisters</span>
<span 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>
@@ -619,10 +744,43 @@ export default function App() {
/>
</label>
</div>
</>
)}
<div className="setting-section">
<h3>Stock Thresholds</h3>
<p className="setting-hint">Define stock levels 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>
{settings.emailEnabled && (
<>
<div className="setting-section">
<h3>SMTP Configuration</h3>
<p className="setting-hint">Diese Einstellungen werden in der <code>.env</code> Datei konfiguriert.</p>
<p className="setting-hint">These settings are configured in the <code>.env</code> file.</p>
<div className="smtp-readonly">
<div className="smtp-field">
<span className="smtp-label">Host</span>
@@ -646,7 +804,7 @@ export default function App() {
</div>
<div className="smtp-field">
<span className="smtp-label">SSL/TLS</span>
<span className="smtp-value">{settings.smtpSecure ? "Ja" : "Nein"}</span>
<span className="smtp-value">{settings.smtpSecure ? "Yes" : "No"}</span>
</div>
</div>
</div>
@@ -784,3 +942,90 @@ function calculateCoverage(meds: Medication[], events: Array<{ medName: string;
const low = coverage.filter((c) => c.medsLeft <= 0 || (c.daysLeft !== null && c.daysLeft <= 3));
return { low, all: coverage };
}
function getNextReminderDate(reminderDaysBefore: number, lowStock: Coverage[]): string {
// Find the earliest depletion date among low stock items
const earliestDepletion = lowStock
.filter((c) => c.depletionTime !== null)
.sort((a, b) => (a.depletionTime ?? 0) - (b.depletionTime ?? 0))[0];
if (earliestDepletion && earliestDepletion.depletionTime) {
// Reminder would be sent X days before depletion
const reminderTime = earliestDepletion.depletionTime - reminderDaysBefore * 86_400_000;
const now = Date.now();
if (reminderTime <= now) {
// Reminder is due now or overdue
return "Today";
}
return new Date(reminderTime).toLocaleDateString([], {
weekday: "short",
day: "2-digit",
month: "short",
});
}
// No low stock - check daily (next day at 9am)
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(9, 0, 0, 0);
return tomorrow.toLocaleDateString([], {
weekday: "short",
day: "2-digit",
month: "short",
});
}
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" };
}
+110
View File
@@ -18,6 +18,8 @@
--success-bg: rgba(57, 217, 138, 0.12);
--danger: #fca5a5;
--danger-bg: rgba(255, 94, 94, 0.12);
--warning: #fcd34d;
--warning-bg: rgba(252, 211, 77, 0.12);
--shadow: rgba(0, 0, 0, 0.25);
}
@@ -39,6 +41,8 @@
--success-bg: rgba(16, 185, 129, 0.1);
--danger: #ef4444;
--danger-bg: rgba(239, 68, 68, 0.1);
--warning: #f59e0b;
--warning-bg: rgba(245, 158, 11, 0.1);
--shadow: rgba(0, 0, 0, 0.08);
}
@@ -101,6 +105,50 @@ body {
.sub { color: var(--text-secondary); margin: 0; }
.eyebrow { letter-spacing: 0.06em; text-transform: uppercase; color: #7ca7ff; font-size: 0.75rem; margin: 0; font-weight: 500; }
/* Email status bar */
.email-status-bar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--accent-bg);
border: 1px solid var(--border-primary);
border-radius: 10px;
margin-bottom: 1rem;
font-size: 0.85rem;
}
.email-status-icon {
font-size: 1.1rem;
}
.email-status-text {
color: var(--text-secondary);
flex: 1;
}
.email-status-text strong {
color: var(--accent-light);
}
.email-status-recipient {
color: var(--text-muted);
font-size: 0.8rem;
padding: 0.25rem 0.6rem;
background: var(--bg-tertiary);
border-radius: 6px;
}
@media (max-width: 600px) {
.email-status-bar {
flex-wrap: wrap;
}
.email-status-recipient {
width: 100%;
text-align: center;
}
}
.tabs { display: flex; gap: 0.5rem; }
.tabs .pill { cursor: pointer; transition: all 150ms ease; }
.tabs .pill:hover { background: rgba(47, 134, 246, 0.15); }
@@ -153,9 +201,11 @@ body {
.tag.subtle { background: rgba(255, 255, 255, 0.04); color: var(--text-secondary); font-size: 0.85rem; }
[data-theme=\"light\"] .tag.subtle { background: rgba(0, 0, 0, 0.04); }
.tag.success { background: var(--success-bg); color: var(--success); border: 1px solid rgba(57, 217, 138, 0.25); }
.tag.warning { background: var(--warning-bg); color: var(--warning); border: 1px solid rgba(252, 211, 77, 0.3); }
.tag.danger { background: var(--danger-bg); color: var(--danger); border: 1px solid rgba(255, 94, 94, 0.3); }
.tag-row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; margin-top: 0.25rem; }
.danger-text { color: var(--danger); font-weight: 700; }
.warning-text { color: var(--warning); font-weight: 700; }
.success-text { color: var(--success); font-weight: 700; }
.med-actions { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
@@ -291,6 +341,25 @@ input:focus, select:focus {
.table-4 .table-head, .table-4 .table-row {
grid-template-columns: minmax(200px, 2.2fr) 150px 130px 170px;
}
.table-5 .table-head, .table-5 .table-row {
grid-template-columns: minmax(180px, 2fr) 120px 100px 130px 130px;
}
.table-6 .table-head, .table-6 .table-row {
grid-template-columns: minmax(160px, 2fr) 100px 80px 110px 110px 110px;
}
.table-7 .table-head, .table-7 .table-row {
grid-template-columns: minmax(140px, 1.5fr) 90px 70px 100px 100px 90px 90px;
}
.email-sent-status {
font-size: 0.8rem;
color: var(--success);
}
.next-reminder-date {
font-size: 0.8rem;
color: var(--accent-light);
}
.status-chip {
display: inline-flex;
@@ -319,6 +388,23 @@ input:focus, select:focus {
content: "!";
font-weight: 700;
}
.status-chip.warning {
background: rgba(252, 211, 77, 0.15);
color: #fcd34d;
border: 1px solid rgba(252, 211, 77, 0.3);
}
.status-chip.warning::before {
content: "!";
font-weight: 700;
}
.status-chip.high {
background: rgba(57, 217, 138, 0.15);
color: #6ee7b7;
border: 1px solid rgba(57, 217, 138, 0.3);
}
.status-chip.high::before {
content: "";
}
@media (max-width: 760px) {
.table-head, .table-row {
@@ -364,6 +450,24 @@ input:focus, select:focus {
margin-top: 0.5rem;
}
.planner-email-action {
display: flex;
align-items: center;
gap: 1rem;
padding-top: 1rem;
margin-top: 0.5rem;
border-top: 1px solid var(--border-primary);
}
.email-send-action {
display: flex;
align-items: center;
gap: 1rem;
padding-top: 1rem;
margin-top: 0.5rem;
border-top: 1px solid var(--border-primary);
}
@media (max-width: 600px) {
.planner { grid-template-columns: 1fr; }
}
@@ -465,6 +569,12 @@ input:focus, select:focus {
font-size: 0.85em;
}
.input-hint {
font-size: 0.75rem;
color: var(--text-secondary);
margin-top: 0.25rem;
}
.smtp-readonly {
display: grid;
grid-template-columns: repeat(2, 1fr);