@@ -330,24 +415,45 @@ export default function App() {
{coverage.low.length === 0 ? (
All good, enough stock.
) : (
-
-
- Name
- Current pills
- Days left
- Runs out
- Next dose
-
- {coverage.low.map((row) => (
-
-
{row.name}
-
{formatNumber(row.medsLeft)}
-
{formatNumber(row.daysLeft)}
-
{row.depletionDate ?? "-"}
-
{row.nextDose ?? "-"}
+ <>
+
+
+ Name
+ Current pills
+ Days left
+ Status
+ Runs out
+ Next reminder
+ Email sent
- ))}
-
+ {coverage.low.map((row) => {
+ const status = getStockStatus(row.daysLeft, row.medsLeft, settings);
+ return (
+
+ {row.name}
+ {formatNumber(row.medsLeft)}
+ {formatNumber(row.daysLeft)}
+ {status.label}
+ {row.depletionDate ?? "-"}
+ {getNextReminderForMed(row, settings.reminderDaysBefore)}
+ {lastReminderSent ?? "—"}
+
+ );
+ })}
+
+ {settings.emailEnabled && settings.notificationEmail && (
+
+
+ {reminderEmailResult && (
+
+ {reminderEmailResult.message}
+
+ )}
+
+ )}
+ >
)}
@@ -358,21 +464,26 @@ export default function App() {
Medication Overview
Stock
-
+
Name
Current pills
Days left
Runs out
+ Status
- {coverage.all.map((row) => (
-
- {row.name}
- {formatNumber(row.medsLeft)}
- {formatNumber(row.daysLeft)}
- {row.depletionDate ?? "-"}
-
- ))}
+ {coverage.all.map((row) => {
+ const status = getStockStatus(row.daysLeft, row.medsLeft, settings);
+ return (
+
+ {row.name}
+ {formatNumber(row.medsLeft)}
+ {formatNumber(row.daysLeft)}
+ {row.depletionDate ?? "-"}
+ {status.label}
+
+ );
+ })}
@@ -547,24 +658,38 @@ export default function App() {
{plannerRows.length > 0 && (
-
-
- Medication
- Usage
- Blisters needed
- Available
- Status
-
- {plannerRows.map((row) => (
-
-
{row.medicationName}
-
{row.plannerUsage} pills
-
{row.stripsNeeded} × {row.stripSize}
-
{row.stripsAvailable}
-
{row.enough ? "Enough" : "Low"}
+ <>
+
+
+ Medication
+ Usage
+ Blisters needed
+ Available
+ Status
- ))}
-
+ {plannerRows.map((row) => (
+
+ {row.medicationName}
+ {row.plannerUsage} pills
+ {row.stripsNeeded} × {row.stripSize}
+ {row.stripsAvailable} blisters
+ {row.enough ? "✓ Enough" : "⚠ Out of Stock"}
+
+ ))}
+
+ {settings.emailEnabled && settings.notificationEmail && (
+
+
+ {plannerEmailResult && (
+
+ {plannerEmailResult.message}
+
+ )}
+
+ )}
+ >
)}
@@ -619,10 +744,43 @@ export default function App() {
/>
+ >
+ )}
+
+
Stock Thresholds
+
Define stock levels based on how many days of medication you have left.
+
+
+
+
+
+
+ {settings.emailEnabled && (
+ <>
SMTP Configuration
-
Diese Einstellungen werden in der .env Datei konfiguriert.
+
These settings are configured in the .env file.
Host
@@ -646,7 +804,7 @@ export default function App() {
SSL/TLS
- {settings.smtpSecure ? "Ja" : "Nein"}
+ {settings.smtpSecure ? "Yes" : "No"}
@@ -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" };
+}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index d7d7541..96dfc09 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -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);