From 5cb5e2ba2653f432c8bc50adb876c5cc7c9c9c46 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Sat, 20 Dec 2025 17:26:46 +0100 Subject: [PATCH] feat: add planner routes and email functionality; update settings and App component for new stock thresholds and email reminders --- .github/copilot-instructions.md | 75 +++++++ backend/src/index.ts | 2 + backend/src/routes/planner.ts | 264 +++++++++++++++++++++++++ backend/src/routes/settings.ts | 24 ++- frontend/src/App.tsx | 337 +++++++++++++++++++++++++++----- frontend/src/styles.css | 110 +++++++++++ 6 files changed, 764 insertions(+), 48 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 backend/src/routes/planner.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..d239e48 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,75 @@ +# MedAssist - AI Coding Instructions + +## Architecture Overview + +MedAssist is a **medication tracking and planning app** with a monorepo structure: + +- **Backend**: Fastify 5 + TypeScript + SQLite (Drizzle ORM) at `backend/` +- **Frontend**: React 18 + Vite + TypeScript at `frontend/` +- **Database**: SQLite with migrations in `backend/src/db/migrations/` +- **Deployment**: Docker Compose with separate dev containers + +### Data Flow +``` +Frontend (React) → /api/* proxy → Backend (Fastify) → SQLite + ↓ (Vite rewrites /api to /) +``` + +The Vite proxy at `frontend/vite.config.ts` rewrites `/api/*` to `/` - so frontend calls `/api/medications` but backend route is just `/medications`. + +## Development Commands + +```bash +# Start dev environment (preferred) +docker compose up + +# Or run services separately: +cd backend && npm run dev # tsx watch on port 3000 +cd frontend && npm run dev # Vite on port 5173 + +# Database migrations +cd backend && npm run migrate +``` + +## Key Patterns + +### Backend Routes (`backend/src/routes/`) +- Routes register directly on app without `/api` prefix +- Use Fastify's type-safe body/params: `app.put<{ Body: MyType }>()` +- Settings: notification config → JSON file (`data/notification-settings.json`), SMTP → `.env` + +### Frontend (`frontend/src/App.tsx`) +- Single-file React app with all components and state +- Uses React Router for navigation (`/dashboard`, `/medications`, `/planner`, `/settings`) +- API calls use `/api/` prefix (proxied by Vite) +- Medication scheduling logic with "slices" (usage patterns) + +### Database Schema (`backend/src/db/schema.ts`) +- `medications`: tracks count, strips, pack inventory, usage schedules as JSON +- `users`, `refreshTokens`: JWT auth with rotating refresh tokens +- `settings`: legacy table (SMTP now from `.env`, notifications from JSON file) + +### Settings Architecture +``` +SMTP config: .env file (read-only in UI, loaded via env_file in docker-compose) +Notifications: data/notification-settings.json (editable via UI) +``` + +## Conventions + +- **TypeScript**: Strict mode, ESM modules (`"type": "module"`) +- **Styling**: CSS custom properties in `frontend/src/styles.css`, dark/light theme via `data-theme` +- **API responses**: Return objects directly, Fastify serializes to JSON +- **Environment**: Copy `.env.example` → `.env`, secrets must be 10+ chars + +## File Locations + +| Purpose | Location | +|---------|----------| +| Backend entry | `backend/src/index.ts` | +| Database schema | `backend/src/db/schema.ts` | +| Migrations | `backend/src/db/migrations/*.sql` | +| Frontend app | `frontend/src/App.tsx` | +| Styles | `frontend/src/styles.css` | +| Docker dev setup | `docker-compose.yml` | +| Env template | `.env.example` | diff --git a/backend/src/index.ts b/backend/src/index.ts index ba931eb..aeeb97c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -10,6 +10,7 @@ import { healthRoutes } from "./routes/health.js"; import { authRoutes } from "./routes/auth.js"; import { medicationRoutes } from "./routes/medications.js"; import { settingsRoutes } from "./routes/settings.js"; +import { plannerRoutes } from "./routes/planner.js"; const app = Fastify({ logger: { @@ -58,6 +59,7 @@ await app.register(healthRoutes); await app.register(authRoutes); await app.register(medicationRoutes); await app.register(settingsRoutes); +await app.register(plannerRoutes); const start = async () => { try { diff --git a/backend/src/routes/planner.ts b/backend/src/routes/planner.ts new file mode 100644 index 0000000..9e75b93 --- /dev/null +++ b/backend/src/routes/planner.ts @@ -0,0 +1,264 @@ +import { FastifyInstance } from "fastify"; +import nodemailer from "nodemailer"; + +type PlannerRow = { + medicationId: number; + medicationName: string; + plannerUsage: number; + stripSize: number; + stripsNeeded: number; + stripsAvailable: number; + enough: boolean; +}; + +type SendEmailBody = { + email: string; + from: string; + until: string; + rows: PlannerRow[]; +}; + +type LowStockItem = { + name: string; + medsLeft: number; + daysLeft: number | null; + depletionDate: string | null; +}; + +type ReminderEmailBody = { + email: string; + lowStock: LowStockItem[]; +}; + +export async function plannerRoutes(app: FastifyInstance) { + app.post<{ Body: SendEmailBody }>("/planner/send-email", async (request, reply) => { + const { email, from, until, rows } = request.body; + + if (!email || !rows || rows.length === 0) { + return reply.status(400).send({ error: "Missing email or planner data" }); + } + + const smtpHost = process.env.SMTP_HOST; + const smtpUser = process.env.SMTP_USER; + const smtpPass = process.env.SMTP_PASS; + const smtpPort = parseInt(process.env.SMTP_PORT ?? "587"); + const smtpSecure = process.env.SMTP_SECURE === "true"; + const smtpFrom = process.env.SMTP_FROM ?? smtpUser; + + if (!smtpHost || !smtpUser) { + return reply.status(400).send({ error: "SMTP not configured" }); + } + + // Format dates for display + const fromDate = new Date(from).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); + const untilDate = new Date(until).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); + + // Build HTML table + const tableRows = rows + .map( + (row) => ` + + ${row.medicationName} + ${row.plannerUsage} pills + ${row.stripsNeeded} × ${row.stripSize} + ${row.stripsAvailable} blisters + + + ${row.enough ? "✓ Enough" : "⚠ Out of Stock"} + + + + ` + ) + .join(""); + + const outOfStockCount = rows.filter((r) => !r.enough).length; + const summaryText = + outOfStockCount > 0 + ? `⚠️ ${outOfStockCount} medication${outOfStockCount > 1 ? "s" : ""} will be out of stock during this period.` + : "✓ All medications have sufficient supply for this period."; + + const html = ` +
+
+

MedAssist - Demand Calculator

+

Supply overview from ${fromDate} to ${untilDate}

+ +
+

+ ${summaryText} +

+
+ + + + + + + + + + + + + ${tableRows} + +
MedicationUsageBlisters NeededAvailableStatus
+ +
+

Sent from MedAssist Medication Planner

+
+
+ `; + + const plainText = `MedAssist - Demand Calculator +Supply overview from ${fromDate} to ${untilDate} + +${summaryText} + +${rows.map((r) => `${r.medicationName}: ${r.plannerUsage} pills needed, ${r.stripsAvailable} blisters available (${r.stripsNeeded} needed) - ${r.enough ? "Enough" : "OUT OF STOCK"}`).join("\n")} + +--- +Sent from MedAssist Medication Planner`; + + try { + const transporter = nodemailer.createTransport({ + host: smtpHost, + port: smtpPort, + secure: smtpSecure, + auth: { + user: smtpUser, + pass: smtpPass ?? "", + }, + }); + + await transporter.sendMail({ + from: smtpFrom, + to: email, + subject: `MedAssist - Supply Overview (${fromDate} - ${untilDate})`, + text: plainText, + html, + }); + + return reply.send({ success: true, message: "Email sent successfully" }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + return reply.status(500).send({ error: `Failed to send email: ${errorMessage}` }); + } + }); + + // Reminder email for low stock medications + app.post<{ Body: ReminderEmailBody }>("/reminder/send-email", async (request, reply) => { + const { email, lowStock } = request.body; + + if (!email || !lowStock || lowStock.length === 0) { + return reply.status(400).send({ error: "Missing email or low stock data" }); + } + + const smtpHost = process.env.SMTP_HOST; + const smtpUser = process.env.SMTP_USER; + const smtpPass = process.env.SMTP_PASS; + const smtpPort = parseInt(process.env.SMTP_PORT ?? "587"); + const smtpSecure = process.env.SMTP_SECURE === "true"; + const smtpFrom = process.env.SMTP_FROM ?? smtpUser; + + if (!smtpHost || !smtpUser) { + return reply.status(400).send({ error: "SMTP not configured" }); + } + + const tableRows = lowStock + .map( + (row) => ` + + ${row.name} + ${row.medsLeft} pills + ${row.daysLeft ?? 0} days + ${row.depletionDate ?? "-"} + + ` + ) + .join(""); + + const html = ` +
+
+

⚠️ MedAssist - Reorder Reminder

+

The following medications are running low and need to be reordered:

+ +
+

+ ⚠️ ${lowStock.length} medication${lowStock.length > 1 ? "s" : ""} running low! +

+
+ + + + + + + + + + + + ${tableRows} + +
MedicationCurrent PillsDays LeftRuns Out
+ +
+

Sent from MedAssist Medication Planner

+
+
+ `; + + const plainText = `MedAssist - Reorder Reminder + +The following medications are running low: + +${lowStock.map((r) => `${r.name}: ${r.medsLeft} pills left, ${r.daysLeft ?? 0} days remaining, runs out ${r.depletionDate ?? "soon"}`).join("\n")} + +--- +Sent from MedAssist Medication Planner`; + + try { + const transporter = nodemailer.createTransport({ + host: smtpHost, + port: smtpPort, + secure: smtpSecure, + auth: { + user: smtpUser, + pass: smtpPass ?? "", + }, + }); + + await transporter.sendMail({ + from: smtpFrom, + to: email, + subject: `⚠️ MedAssist - ${lowStock.length} Medication${lowStock.length > 1 ? "s" : ""} Running Low`, + text: plainText, + html, + }); + + return reply.send({ success: true, message: "Reminder email sent" }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + return reply.status(500).send({ error: `Failed to send email: ${errorMessage}` }); + } + }); +} diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 2eecbc9..e9138eb 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -7,6 +7,9 @@ type SettingsBody = { emailEnabled: boolean; notificationEmail: string; reminderDaysBefore: number; + lowStockDays: number; + normalStockDays: number; + highStockDays: number; }; type TestEmailBody = { @@ -21,17 +24,28 @@ type NotificationSettings = { emailEnabled: boolean; notificationEmail: string; reminderDaysBefore: number; + lowStockDays: number; + normalStockDays: number; + highStockDays: number; }; function loadNotificationSettings(): NotificationSettings { try { if (existsSync(notificationSettingsFile)) { - return JSON.parse(readFileSync(notificationSettingsFile, "utf-8")); + const saved = JSON.parse(readFileSync(notificationSettingsFile, "utf-8")); + return { + emailEnabled: saved.emailEnabled ?? false, + notificationEmail: saved.notificationEmail ?? "", + reminderDaysBefore: saved.reminderDaysBefore ?? 7, + lowStockDays: saved.lowStockDays ?? 30, + normalStockDays: saved.normalStockDays ?? 90, + highStockDays: saved.highStockDays ?? 180, + }; } } catch { // ignore } - return { emailEnabled: false, notificationEmail: "", reminderDaysBefore: 7 }; + return { emailEnabled: false, notificationEmail: "", reminderDaysBefore: 7, lowStockDays: 30, normalStockDays: 90, highStockDays: 180 }; } function saveNotificationSettings(settings: NotificationSettings): void { @@ -48,6 +62,9 @@ export async function settingsRoutes(app: FastifyInstance) { emailEnabled: notification.emailEnabled, notificationEmail: notification.notificationEmail, reminderDaysBefore: notification.reminderDaysBefore, + lowStockDays: notification.lowStockDays, + normalStockDays: notification.normalStockDays, + highStockDays: notification.highStockDays, // SMTP settings (admin-configured, from .env) smtpHost: process.env.SMTP_HOST ?? "", smtpPort: parseInt(process.env.SMTP_PORT ?? "587"), @@ -67,6 +84,9 @@ export async function settingsRoutes(app: FastifyInstance) { emailEnabled: body.emailEnabled, notificationEmail: body.notificationEmail, reminderDaysBefore: body.reminderDaysBefore, + lowStockDays: body.lowStockDays ?? 30, + normalStockDays: body.normalStockDays ?? 90, + highStockDays: body.highStockDays ?? 180, }); return reply.send({ success: true }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 640636b..bd069db 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(() => { + 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() { } /> + {settings.emailEnabled && settings.notificationEmail && ( +
+ 📧 + + Email reminders active — Next check: {getNextReminderDate(settings.reminderDaysBefore, coverage.low)} + + → {settings.notificationEmail} +
+ )}
@@ -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);