feat: add reminder functionality with daily email notifications

- Implemented reminder scheduler service to check for low stock medications and send email notifications.
- Added repeat daily reminders option in settings to allow users to receive daily emails while stock is low.
- Updated backend settings route to include new reminder state and settings.
- Enhanced frontend to manage and display reminder settings, including last automatic email sent.
- Improved UI for better user experience with new styles for settings and notifications.
This commit is contained in:
Daniel Volz
2025-12-20 19:48:23 +01:00
parent c643bfcc47
commit b588fb2f95
11 changed files with 690 additions and 196 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
# Backend build
FROM node:25-slim AS builder
WORKDIR /app
COPY package.json tsconfig.json drizzle.config.ts ./
COPY package.json tsconfig.json ./
COPY src ./src
RUN npm install
RUN npm run build
-12
View File
@@ -1,12 +0,0 @@
import { defineConfig } from "drizzle-kit";
const dbUrl = process.env.DATABASE_URL || "file:./data/medassist.db";
export default defineConfig({
dialect: "libsql",
schema: "./src/db/schema.ts",
out: "./drizzle",
dbCredentials: {
url: dbUrl,
},
});
View File
-50
View File
@@ -1,50 +0,0 @@
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
email text NOT NULL UNIQUE,
password_hash text NOT NULL,
role text NOT NULL DEFAULT 'user',
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE TABLE IF NOT EXISTS medications (
id integer PRIMARY KEY AUTOINCREMENT,
name text NOT NULL UNIQUE,
count integer NOT NULL DEFAULT 0,
strips integer NOT NULL DEFAULT 0,
pack_count integer NOT NULL DEFAULT 1,
strips_per_pack integer NOT NULL DEFAULT 1,
tabs_per_strip integer NOT NULL DEFAULT 1,
loose_tablets integer NOT NULL DEFAULT 0,
usage_json text NOT NULL DEFAULT '[]',
every_json text NOT NULL DEFAULT '[]',
start_json text NOT NULL DEFAULT '[]',
strip_size integer NOT NULL DEFAULT 1,
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
token_id text NOT NULL UNIQUE,
expires_at integer NOT NULL,
rotated_at integer,
revoked integer NOT NULL DEFAULT 0,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS settings (
id integer PRIMARY KEY AUTOINCREMENT,
smtp_host text,
smtp_port integer,
smtp_user text,
smtp_pass_encrypted text,
smtp_from text,
smtp_secure integer NOT NULL DEFAULT 0,
emails_per_day integer NOT NULL DEFAULT 3,
email_enabled integer NOT NULL DEFAULT 0,
notification_email text,
reminder_days_before integer NOT NULL DEFAULT 7,
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
);
@@ -1,5 +0,0 @@
{
"entries": [
{ "idx": 0, "version": 1, "when": 1734633120, "tag": "0000_init", "breakpoint": false }
]
}
+7
View File
@@ -11,6 +11,7 @@ import { authRoutes } from "./routes/auth.js";
import { medicationRoutes } from "./routes/medications.js";
import { settingsRoutes } from "./routes/settings.js";
import { plannerRoutes } from "./routes/planner.js";
import { startReminderScheduler } from "./services/reminder-scheduler.js";
const app = Fastify({
logger: {
@@ -65,6 +66,12 @@ const start = async () => {
try {
await app.listen({ port: env.PORT, host: "0.0.0.0" });
app.log.info(`Server running on ${env.PORT}`);
// Start the automatic reminder scheduler
startReminderScheduler({
info: (msg) => app.log.info(msg),
error: (msg) => app.log.error(msg),
});
} catch (err) {
app.log.error(err);
process.exit(1);
+64 -55
View File
@@ -1,5 +1,6 @@
import { FastifyInstance } from "fastify";
import nodemailer from "nodemailer";
import { updateReminderSentTime } from "../services/reminder-scheduler.js";
type PlannerRow = {
medicationId: number;
@@ -61,22 +62,22 @@ export async function plannerRoutes(app: FastifyInstance) {
day: "numeric",
});
// Build HTML table
// Build HTML table with horizontal scroll for mobile
const tableRows = rows
.map(
(row) => `
<tr>
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb;">${row.medicationName}</td>
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb; text-align: center;"><strong>${row.plannerUsage}</strong> pills</td>
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb; text-align: center;">${row.stripsNeeded} × ${row.stripSize}</td>
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb; text-align: center;">${row.stripsAvailable} blisters</td>
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb; text-align: center;">
<span style="padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 600; ${
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${row.medicationName}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${row.plannerUsage}</strong> pills</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.stripsNeeded} × ${row.stripSize}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.stripsAvailable}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">
<span style="display: inline-block; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; ${
row.enough
? "background: #d1fae5; color: #065f46;"
: "background: #fee2e2; color: #991b1b;"
}">
${row.enough ? "✓ Enough" : "⚠ Out of Stock"}
${row.enough ? "✓ OK" : "⚠ Low"}
</span>
</td>
</tr>
@@ -91,38 +92,40 @@ export async function plannerRoutes(app: FastifyInstance) {
: "✓ All medications have sufficient supply for this period.";
const html = `
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 700px; margin: 0 auto; padding: 20px; background: #f9fafb;">
<div style="background: white; border-radius: 12px; padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<h2 style="color: #1f2937; margin: 0 0 8px;">MedAssist - Demand Calculator</h2>
<p style="color: #6b7280; margin: 0 0 24px;">Supply overview from <strong>${fromDate}</strong> to <strong>${untilDate}</strong></p>
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">MedAssist - Demand Calculator</h2>
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">Supply overview from <strong>${fromDate}</strong> to <strong>${untilDate}</strong></p>
<div style="padding: 12px 16px; border-radius: 8px; margin-bottom: 20px; ${
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; ${
outOfStockCount > 0
? "background: #fef2f2; border: 1px solid #fecaca;"
: "background: #f0fdf4; border: 1px solid #bbf7d0;"
}">
<p style="margin: 0; color: ${outOfStockCount > 0 ? "#991b1b" : "#166534"}; font-weight: 500;">
<p style="margin: 0; color: ${outOfStockCount > 0 ? "#991b1b" : "#166534"}; font-weight: 500; font-size: 13px;">
${summaryText}
</p>
</div>
<table style="width: 100%; border-collapse: collapse; background: white;">
<thead>
<tr style="background: #f3f4f6;">
<th style="padding: 12px; text-align: left; font-size: 12px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em;">Medication</th>
<th style="padding: 12px; text-align: center; font-size: 12px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em;">Usage</th>
<th style="padding: 12px; text-align: center; font-size: 12px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em;">Blisters Needed</th>
<th style="padding: 12px; text-align: center; font-size: 12px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em;">Available</th>
<th style="padding: 12px; text-align: center; font-size: 12px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em;">Status</th>
</tr>
</thead>
<tbody>
${tableRows}
</tbody>
</table>
<div style="overflow-x: auto; -webkit-overflow-scrolling: touch;">
<table style="width: 100%; border-collapse: collapse; background: white; min-width: 500px;">
<thead>
<tr style="background: #f3f4f6;">
<th style="padding: 10px 12px; text-align: left; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">Medication</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">Usage</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">Needed</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">Available</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">Status</th>
</tr>
</thead>
<tbody>
${tableRows}
</tbody>
</table>
</div>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;" />
<p style="color: #9ca3af; font-size: 12px; margin: 0;">Sent from MedAssist Medication Planner</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
<p style="color: #9ca3af; font-size: 11px; margin: 0;">Sent from MedAssist Medication Planner</p>
</div>
</div>
`;
@@ -182,47 +185,50 @@ Sent from MedAssist Medication Planner`;
return reply.status(400).send({ error: "SMTP not configured" });
}
// Build HTML table with horizontal scroll for mobile
const tableRows = lowStock
.map(
(row) => `
<tr>
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb;">${row.name}</td>
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb; text-align: center;"><strong>${row.medsLeft}</strong> pills</td>
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb; text-align: center;">${row.daysLeft ?? 0} days</td>
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb; text-align: center;">${row.depletionDate ?? "-"}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${row.name}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${row.medsLeft}</strong></td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.daysLeft ?? 0}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.depletionDate ?? "-"}</td>
</tr>
`
)
.join("");
const html = `
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; background: #f9fafb;">
<div style="background: white; border-radius: 12px; padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<h2 style="color: #1f2937; margin: 0 0 8px;">⚠️ MedAssist - Reorder Reminder</h2>
<p style="color: #6b7280; margin: 0 0 24px;">The following medications are running low and need to be reordered:</p>
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">⚠️ MedAssist - Reorder Reminder</h2>
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">The following medications are running low and need to be reordered:</p>
<div style="padding: 12px 16px; border-radius: 8px; margin-bottom: 20px; background: #fef2f2; border: 1px solid #fecaca;">
<p style="margin: 0; color: #991b1b; font-weight: 500;">
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #fef2f2; border: 1px solid #fecaca;">
<p style="margin: 0; color: #991b1b; font-weight: 500; font-size: 13px;">
⚠️ ${lowStock.length} medication${lowStock.length > 1 ? "s" : ""} running low!
</p>
</div>
<table style="width: 100%; border-collapse: collapse; background: white;">
<thead>
<tr style="background: #f3f4f6;">
<th style="padding: 12px; text-align: left; font-size: 12px; text-transform: uppercase; color: #6b7280;">Medication</th>
<th style="padding: 12px; text-align: center; font-size: 12px; text-transform: uppercase; color: #6b7280;">Current Pills</th>
<th style="padding: 12px; text-align: center; font-size: 12px; text-transform: uppercase; color: #6b7280;">Days Left</th>
<th style="padding: 12px; text-align: center; font-size: 12px; text-transform: uppercase; color: #6b7280;">Runs Out</th>
</tr>
</thead>
<tbody>
${tableRows}
</tbody>
</table>
<div style="overflow-x: auto; -webkit-overflow-scrolling: touch;">
<table style="width: 100%; border-collapse: collapse; background: white; min-width: 400px;">
<thead>
<tr style="background: #f3f4f6;">
<th style="padding: 10px 12px; text-align: left; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Medication</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Pills</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Days</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Runs Out</th>
</tr>
</thead>
<tbody>
${tableRows}
</tbody>
</table>
</div>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;" />
<p style="color: #9ca3af; font-size: 12px; margin: 0;">Sent from MedAssist Medication Planner</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
<p style="color: #9ca3af; font-size: 11px; margin: 0;">Sent from MedAssist Medication Planner</p>
</div>
</div>
`;
@@ -255,6 +261,9 @@ Sent from MedAssist Medication Planner`;
html,
});
// Update the reminder state to record this email was sent
updateReminderSentTime();
return reply.send({ success: true, message: "Reminder email sent" });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
+10 -1
View File
@@ -2,11 +2,13 @@ import { FastifyInstance } from "fastify";
import nodemailer from "nodemailer";
import { readFileSync, writeFileSync, existsSync } from "fs";
import { resolve } from "path";
import { getReminderState } from "../services/reminder-scheduler.js";
type SettingsBody = {
emailEnabled: boolean;
notificationEmail: string;
reminderDaysBefore: number;
repeatDailyReminders: boolean;
lowStockDays: number;
normalStockDays: number;
highStockDays: number;
@@ -24,6 +26,7 @@ type NotificationSettings = {
emailEnabled: boolean;
notificationEmail: string;
reminderDaysBefore: number;
repeatDailyReminders: boolean;
lowStockDays: number;
normalStockDays: number;
highStockDays: number;
@@ -37,6 +40,7 @@ function loadNotificationSettings(): NotificationSettings {
emailEnabled: saved.emailEnabled ?? false,
notificationEmail: saved.notificationEmail ?? "",
reminderDaysBefore: saved.reminderDaysBefore ?? 7,
repeatDailyReminders: saved.repeatDailyReminders ?? false,
lowStockDays: saved.lowStockDays ?? 30,
normalStockDays: saved.normalStockDays ?? 90,
highStockDays: saved.highStockDays ?? 180,
@@ -45,7 +49,7 @@ function loadNotificationSettings(): NotificationSettings {
} catch {
// ignore
}
return { emailEnabled: false, notificationEmail: "", reminderDaysBefore: 7, lowStockDays: 30, normalStockDays: 90, highStockDays: 180 };
return { emailEnabled: false, notificationEmail: "", reminderDaysBefore: 7, repeatDailyReminders: false, lowStockDays: 30, normalStockDays: 90, highStockDays: 180 };
}
function saveNotificationSettings(settings: NotificationSettings): void {
@@ -56,12 +60,14 @@ export async function settingsRoutes(app: FastifyInstance) {
// Get settings - notification from JSON file, SMTP from process.env
app.get("/settings", async (_request, reply) => {
const notification = loadNotificationSettings();
const reminderState = getReminderState();
return reply.send({
// Notification settings (user-configurable, stored in JSON)
emailEnabled: notification.emailEnabled,
notificationEmail: notification.notificationEmail,
reminderDaysBefore: notification.reminderDaysBefore,
repeatDailyReminders: notification.repeatDailyReminders,
lowStockDays: notification.lowStockDays,
normalStockDays: notification.normalStockDays,
highStockDays: notification.highStockDays,
@@ -72,6 +78,8 @@ export async function settingsRoutes(app: FastifyInstance) {
smtpFrom: process.env.SMTP_FROM ?? "",
smtpSecure: process.env.SMTP_SECURE === "true",
hasSmtpPassword: !!process.env.SMTP_PASS,
// Reminder state
lastAutoEmailSent: reminderState.lastAutoEmailSent,
});
});
@@ -84,6 +92,7 @@ export async function settingsRoutes(app: FastifyInstance) {
emailEnabled: body.emailEnabled,
notificationEmail: body.notificationEmail,
reminderDaysBefore: body.reminderDaysBefore,
repeatDailyReminders: body.repeatDailyReminders ?? false,
lowStockDays: body.lowStockDays ?? 30,
normalStockDays: body.normalStockDays ?? 90,
highStockDays: body.highStockDays ?? 180,
+336
View File
@@ -0,0 +1,336 @@
import nodemailer from "nodemailer";
import { db } from "../db/client.js";
import { medications } from "../db/schema.js";
import { readFileSync, writeFileSync, existsSync } from "fs";
import { resolve } from "path";
type Slice = { usage: number; every: number; start: string };
type NotificationSettings = {
emailEnabled: boolean;
notificationEmail: string;
reminderDaysBefore: number;
repeatDailyReminders: boolean;
lowStockDays: number;
normalStockDays: number;
highStockDays: number;
};
type ReminderState = {
lastAutoEmailSent: string | null; // ISO date string
lastAutoEmailDate: string | null; // YYYY-MM-DD - to track if we already sent today
notifiedMedications: string[]; // List of medication names that have been notified (cleared when restocked)
};
const notificationSettingsFile = resolve(process.cwd(), "data", "notification-settings.json");
const reminderStateFile = resolve(process.cwd(), "data", "reminder-state.json");
function loadNotificationSettings(): NotificationSettings {
try {
if (existsSync(notificationSettingsFile)) {
const saved = JSON.parse(readFileSync(notificationSettingsFile, "utf-8"));
return {
emailEnabled: saved.emailEnabled ?? false,
notificationEmail: saved.notificationEmail ?? "",
reminderDaysBefore: saved.reminderDaysBefore ?? 7,
repeatDailyReminders: saved.repeatDailyReminders ?? false,
lowStockDays: saved.lowStockDays ?? 30,
normalStockDays: saved.normalStockDays ?? 90,
highStockDays: saved.highStockDays ?? 180,
};
}
} catch {
// ignore
}
return { emailEnabled: false, notificationEmail: "", reminderDaysBefore: 7, repeatDailyReminders: false, lowStockDays: 30, normalStockDays: 90, highStockDays: 180 };
}
function loadReminderState(): ReminderState {
try {
if (existsSync(reminderStateFile)) {
const saved = JSON.parse(readFileSync(reminderStateFile, "utf-8"));
return {
lastAutoEmailSent: saved.lastAutoEmailSent ?? null,
lastAutoEmailDate: saved.lastAutoEmailDate ?? null,
notifiedMedications: saved.notifiedMedications ?? [],
};
}
} catch {
// ignore
}
return { lastAutoEmailSent: null, lastAutoEmailDate: null, notifiedMedications: [] };
}
function saveReminderState(state: ReminderState): void {
writeFileSync(reminderStateFile, JSON.stringify(state, null, 2));
}
export function getReminderState(): ReminderState {
return loadReminderState();
}
export function updateReminderSentTime(): void {
const state = loadReminderState();
const today = new Date().toISOString().split("T")[0];
saveReminderState({
...state,
lastAutoEmailSent: new Date().toISOString(),
lastAutoEmailDate: today,
});
}
function parseSlices(row: { usageJson: string; everyJson: string; startJson: string }): Slice[] {
try {
const usage = JSON.parse(row.usageJson) as number[];
const every = JSON.parse(row.everyJson) as number[];
const start = JSON.parse(row.startJson) as string[];
const len = Math.min(usage.length, every.length, start.length);
const slices: Slice[] = [];
for (let i = 0; i < len; i++) {
slices.push({ usage: usage[i], every: every[i], start: start[i] });
}
return slices;
} catch {
return [];
}
}
function calculateDailyUsage(slices: Slice[]): number {
return slices.reduce((sum, s) => sum + s.usage / s.every, 0);
}
function calculateDepletionInfo(med: { count: number; slices: Slice[] }): { daysLeft: number | null; depletionDate: string | null } {
const dailyUsage = calculateDailyUsage(med.slices);
if (dailyUsage <= 0) return { daysLeft: null, depletionDate: null };
const daysLeft = Math.floor(med.count / dailyUsage);
const depletionMs = Date.now() + daysLeft * 86_400_000;
const depletionDate = new Date(depletionMs).toLocaleDateString("en-US", {
weekday: "short",
day: "2-digit",
month: "short",
});
return { daysLeft, depletionDate };
}
type LowStockItem = {
name: string;
medsLeft: number;
daysLeft: number | null;
depletionDate: string | null;
};
async function getMedicationsNeedingReminder(reminderDaysBefore: number): Promise<LowStockItem[]> {
const rows = await db.select().from(medications).orderBy(medications.id);
const lowStock: LowStockItem[] = [];
for (const row of rows) {
const slices = parseSlices(row);
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: row.count, slices });
// Check if medication runs out within reminderDaysBefore days
if (daysLeft !== null && daysLeft <= reminderDaysBefore) {
lowStock.push({
name: row.name,
medsLeft: row.count,
daysLeft,
depletionDate,
});
}
}
return lowStock;
}
async function sendReminderEmail(email: string, lowStock: LowStockItem[]): Promise<{ success: boolean; error?: string }> {
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 { success: false, error: "SMTP not configured" };
}
const tableRows = lowStock
.map(
(row) => `
<tr>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${row.name}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${row.medsLeft}</strong></td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.daysLeft ?? 0}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.depletionDate ?? "-"}</td>
</tr>
`
)
.join("");
const html = `
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">⚠️ MedAssist - Automatic Reorder Reminder</h2>
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">The following medications are running low and need to be reordered:</p>
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #fef2f2; border: 1px solid #fecaca;">
<p style="margin: 0; color: #991b1b; font-weight: 500; font-size: 13px;">
⚠️ ${lowStock.length} medication${lowStock.length > 1 ? "s" : ""} running low!
</p>
</div>
<div style="overflow-x: auto; -webkit-overflow-scrolling: touch;">
<table style="width: 100%; border-collapse: collapse; background: white; min-width: 400px;">
<thead>
<tr style="background: #f3f4f6;">
<th style="padding: 10px 12px; text-align: left; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Medication</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Pills</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Days</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Runs Out</th>
</tr>
</thead>
<tbody>
${tableRows}
</tbody>
</table>
</div>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
<p style="color: #9ca3af; font-size: 11px; margin: 0;">
🤖 Automatic reminder from MedAssist
</p>
</div>
</div>
`;
const plainText = `MedAssist - Automatic 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")}
---
Automatic reminder from MedAssist`;
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 Auto-Reminder: ${lowStock.length} Medication${lowStock.length > 1 ? "s" : ""} Running Low`,
text: plainText,
html,
});
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return { success: false, error: errorMessage };
}
}
async function checkAndSendReminder(logger: { info: (msg: string) => void; error: (msg: string) => void }): Promise<void> {
const settings = loadNotificationSettings();
// Check if email reminders are enabled
if (!settings.emailEnabled || !settings.notificationEmail) {
logger.info("[Reminder] Email reminders disabled or no email configured");
return;
}
const state = loadReminderState();
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
// Get all medications that need a reminder
const allLowStock = await getMedicationsNeedingReminder(settings.reminderDaysBefore);
if (allLowStock.length === 0) {
// No low stock - clear the notified list (medications have been restocked)
if (state.notifiedMedications.length > 0) {
saveReminderState({
...state,
notifiedMedications: [],
});
logger.info("[Reminder] Cleared notified medications list (all restocked)");
}
logger.info("[Reminder] No medications need reminder");
return;
}
// Get names of currently low stock medications
const currentLowStockNames = allLowStock.map((m) => m.name);
// Remove medications from notified list that are no longer low stock (restocked)
const stillLowStock = state.notifiedMedications.filter((name) => currentLowStockNames.includes(name));
// Find NEW medications that haven't been notified yet
const newLowStock = allLowStock.filter((m) => !state.notifiedMedications.includes(m.name));
// Determine what to send
let medsToNotify: LowStockItem[] = [];
if (settings.repeatDailyReminders) {
// Daily reminders enabled - send for ALL low stock, but only once per day
if (state.lastAutoEmailDate === today) {
logger.info("[Reminder] Daily reminder already sent today, skipping");
return;
}
medsToNotify = allLowStock;
} else {
// Only notify NEW medications (not previously notified)
if (newLowStock.length === 0) {
logger.info("[Reminder] No new medications to notify (already notified previously)");
return;
}
medsToNotify = newLowStock;
}
logger.info(`[Reminder] Sending reminder for ${medsToNotify.length} medications...`);
const result = await sendReminderEmail(settings.notificationEmail, medsToNotify);
if (result.success) {
// Update state
saveReminderState({
lastAutoEmailSent: new Date().toISOString(),
lastAutoEmailDate: today,
notifiedMedications: [...new Set([...stillLowStock, ...medsToNotify.map((m) => m.name)])],
});
logger.info(`[Reminder] Email sent successfully to ${settings.notificationEmail}`);
} else {
logger.error(`[Reminder] Failed to send email: ${result.error}`);
}
}
let schedulerInterval: NodeJS.Timeout | null = null;
export function startReminderScheduler(logger: { info: (msg: string) => void; error: (msg: string) => void }): void {
// Run check immediately on startup
logger.info("[Reminder] Starting reminder scheduler...");
checkAndSendReminder(logger).catch((err) => logger.error(`[Reminder] Error: ${err}`));
// Then run every hour to check (will only send once per day)
schedulerInterval = setInterval(() => {
checkAndSendReminder(logger).catch((err) => logger.error(`[Reminder] Error: ${err}`));
}, 60 * 60 * 1000); // Every hour
logger.info("[Reminder] Scheduler started - checking hourly, sending max once per day");
}
export function stopReminderScheduler(): void {
if (schedulerInterval) {
clearInterval(schedulerInterval);
schedulerInterval = null;
}
}
+102 -65
View File
@@ -81,6 +81,7 @@ export default function App() {
emailEnabled: false,
notificationEmail: "",
reminderDaysBefore: 7,
repeatDailyReminders: false,
lowStockDays: 30,
normalStockDays: 90,
highStockDays: 180,
@@ -91,6 +92,7 @@ export default function App() {
smtpFrom: "",
smtpSecure: false,
hasSmtpPassword: false,
lastAutoEmailSent: null as string | null,
});
const [savedSettings, setSavedSettings] = useState(settings);
const [settingsLoading, setSettingsLoading] = useState(false);
@@ -102,17 +104,12 @@ export default function App() {
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.repeatDailyReminders !== savedSettings.repeatDailyReminders ||
settings.lowStockDays !== savedSettings.lowStockDays ||
settings.normalStockDays !== savedSettings.normalStockDays ||
settings.highStockDays !== savedSettings.highStockDays;
@@ -172,6 +169,7 @@ export default function App() {
emailEnabled: settings.emailEnabled,
notificationEmail: settings.notificationEmail,
reminderDaysBefore: settings.reminderDaysBefore,
repeatDailyReminders: settings.repeatDailyReminders,
lowStockDays: settings.lowStockDays,
normalStockDays: settings.normalStockDays,
highStockDays: settings.highStockDays,
@@ -261,10 +259,9 @@ export default function App() {
});
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!" });
// Reload settings to get updated lastAutoEmailSent
loadSettings();
} else {
setReminderEmailResult({ success: false, message: data.error || "Failed to send" });
}
@@ -401,7 +398,7 @@ export default function App() {
<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>
Automatic reminders active {getReminderStatusText(settings.reminderDaysBefore, coverage.low, settings.lastAutoEmailSent)}
</span>
<span className="email-status-recipient"> {settings.notificationEmail}</span>
</section>
@@ -418,27 +415,25 @@ export default function App() {
<p className="success-text">All good, enough stock.</p>
) : (
<>
<div className="table table-7">
<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>Next reminder</span>
<span>Email sent</span>
<span>Auto-remind</span>
</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>
<span data-label="Name">{row.name}</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>
);
})}
@@ -446,7 +441,7 @@ export default function App() {
{settings.emailEnabled && settings.notificationEmail && (
<div className="email-send-action">
<button type="button" className="ghost" onClick={sendReminderEmail} disabled={sendingReminderEmail}>
{sendingReminderEmail ? "Sending..." : "📧 Send Reminder Email"}
{sendingReminderEmail ? "Sending..." : "📧 Send Reminder Now"}
</button>
{reminderEmailResult && (
<span className={reminderEmailResult.success ? "success-text" : "danger-text"}>
@@ -478,11 +473,11 @@ export default function App() {
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>
<span data-label="Name">{row.name}</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="Status" className={`status-chip ${status.className}`}>{status.label}</span>
</div>
);
})}
@@ -671,11 +666,11 @@ export default function App() {
</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>
<span data-label="Medication">{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>
@@ -701,8 +696,8 @@ export default function App() {
<section className="grid">
<article className="card">
<div className="card-head">
<h2>Email Notifications</h2>
<span className="pill neutral">Reminder settings</span>
<h2>Automatic Email Reminders</h2>
<span className="pill neutral">Daily check</span>
</div>
{settingsLoading ? (
<p>Loading settings...</p>
@@ -710,8 +705,8 @@ export default function App() {
<form className="settings-form" onSubmit={saveSettings}>
<div className="setting-row">
<div className="setting-info">
<label className="setting-label">Enable Email Reminders</label>
<p className="setting-desc">Get notified when medication is running low</p>
<label className="setting-label">Enable Automatic Reminders</label>
<p className="setting-desc">Automatically send email when medications are running low</p>
</div>
<label className="toggle-switch">
<input
@@ -725,9 +720,12 @@ export default function App() {
{settings.emailEnabled && (
<>
<div className="setting-info-box">
<p>🤖 <strong>How it works:</strong> The server checks hourly. When a medication drops below the threshold, you get an email.</p>
</div>
<div className="setting-group">
<label>
Notification Email
Send reminder to
<input
type="email"
value={settings.notificationEmail}
@@ -736,16 +734,35 @@ export default function App() {
/>
</label>
<label>
Remind me (days before)
When stock lasts less than (days)
<input
type="number"
min="1"
max="30"
max="90"
value={settings.reminderDaysBefore}
onChange={(e) => setSettings({ ...settings, reminderDaysBefore: Number(e.target.value) || 7 })}
/>
</label>
</div>
<div className="setting-row">
<div className="setting-info">
<label className="setting-label">Repeat daily reminders</label>
<p className="setting-desc">Send daily emails 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>
{settings.lastAutoEmailSent && (
<div className="setting-info-box success">
<p> Last automatic email: <strong>{new Date(settings.lastAutoEmailSent).toLocaleString()}</strong></p>
</div>
)}
</>
)}
@@ -945,38 +962,58 @@ function calculateCoverage(meds: Medication[], events: Array<{ medName: string;
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];
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));
if (earliestDepletion && earliestDepletion.depletionTime) {
// Reminder would be sent X days before depletion
const reminderTime = earliestDepletion.depletionTime - reminderDaysBefore * 86_400_000;
const now = Date.now();
const formatLastSent = (iso: string) => {
const date = new Date(iso);
return date.toLocaleDateString([], { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
};
if (reminderTime <= now) {
// Reminder is due now or overdue
return "Today";
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 email: {formatLastSent(lastSent)}
</>
);
}
return new Date(reminderTime).toLocaleDateString([], {
weekday: "short",
day: "2-digit",
month: "short",
});
return <strong className="warning-text"> {medsNeedingReminder.length} med{medsNeedingReminder.length > 1 ? "s" : ""} need reorder waiting for first check</strong>;
}
// 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",
});
// 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 email: {formatLastSent(lastSent)}
</>
);
}
return <span className="success-text"> All stock OK no reminders needed</span>;
}
function getNextReminderForMed(med: Coverage, reminderDaysBefore: number): string {
+169 -6
View File
@@ -47,12 +47,14 @@
}
* { box-sizing: border-box; }
html { overflow-x: hidden; }
body {
margin: 0;
font-family: "Space Grotesk", "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
background: var(--bg-gradient);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
transition: background 200ms ease, color 200ms ease;
}
@@ -167,6 +169,7 @@ body {
border-radius: 14px;
padding: 1.25rem;
box-shadow: 0 14px 36px var(--shadow);
overflow-x: auto;
transition: background 200ms ease, border-color 200ms ease;
}
@@ -192,7 +195,21 @@ body {
.small { font-size: 0.9rem; }
.slice-list { display: flex; flex-direction: column; gap: 0.4rem; margin-top: 0.5rem; width: 100%; }
.slice-row-simple { color: var(--text-muted); font-size: 0.9rem; padding: 0.5rem 0.75rem; background: var(--bg-tertiary); border: 1px solid var(--border-primary); border-radius: 6px; width: 100%; transition: background 200ms ease; }
.slice-row-simple {
color: var(--text-muted);
font-size: 0.9rem;
padding: 0.6rem 0.75rem 0.6rem 1rem;
background: linear-gradient(90deg, var(--accent-bg) 0%, var(--bg-input) 100%);
border: 1px solid var(--border-secondary);
border-left: 3px solid var(--accent);
border-radius: 6px;
width: 100%;
transition: background 200ms ease, border-color 200ms ease;
}
.slice-row-simple:hover {
background: linear-gradient(90deg, rgba(47, 134, 246, 0.18) 0%, var(--bg-input) 100%);
border-left-color: var(--accent-light);
}
@@ -312,7 +329,7 @@ input:focus, select:focus {
.card p { margin: 0; }
.table { width: 100%; display: flex; flex-direction: column; gap: 0; margin-top: 0.5rem; }
.table { width: 100%; display: flex; flex-direction: column; gap: 0; margin-top: 0.5rem; overflow-x: auto; }
.table-head, .table-row {
display: grid;
grid-template-columns: minmax(180px, 2fr) 100px 140px 140px 120px;
@@ -408,11 +425,128 @@ input:focus, select:focus {
@media (max-width: 760px) {
.table-head, .table-row {
grid-template-columns: 1.6fr 1fr 1fr;
grid-auto-flow: row;
grid-auto-rows: auto;
grid-template-columns: 1fr;
gap: 0.25rem;
}
.table-head { display: none; }
.table-row {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.table-row span {
display: flex;
justify-content: space-between;
align-items: center;
}
.table-row span::before {
content: attr(data-label);
font-weight: 600;
color: var(--accent-light);
font-size: 0.75rem;
text-transform: uppercase;
margin-right: 1rem;
flex-shrink: 0;
}
.table-4 .table-head, .table-4 .table-row,
.table-5 .table-head, .table-5 .table-row,
.table-6 .table-head, .table-6 .table-row,
.table-7 .table-head, .table-7 .table-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 600px) {
.page {
padding: 1rem 0.75rem 2rem;
}
.hero {
flex-direction: column;
align-items: flex-start;
padding: 1rem;
gap: 1rem;
}
.hero h1 {
font-size: 1.3rem;
}
.header-actions {
width: 100%;
justify-content: space-between;
}
.tabs {
flex-wrap: wrap;
gap: 0.4rem;
}
.tabs .pill {
font-size: 0.75rem;
padding: 0.3rem 0.6rem;
}
.card {
padding: 1rem;
border-radius: 10px;
}
.card-head {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.card h2 {
font-size: 1.05rem;
}
.grid {
grid-template-columns: 1fr;
}
.time-row {
grid-template-columns: 1fr;
gap: 0.5rem;
}
.time-chip {
font-size: 0.8rem;
padding: 0.4rem 0.6rem;
}
.day-block {
padding: 0.75rem;
}
.status-chip {
font-size: 0.7rem;
padding: 0.3rem 0.5rem;
}
.med-details {
grid-template-columns: 1fr;
gap: 0.2rem;
}
button {
padding: 0.6rem 1rem;
font-size: 0.85rem;
}
.planner-email-action,
.email-send-action {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
}
.planner-email-action button,
.email-send-action button {
width: 100%;
}
.table-head span:nth-child(n+4), .table-row span:nth-child(n+4) { display: none; }
}
@media (max-width: 900px) {
@@ -575,6 +709,35 @@ input:focus, select:focus {
margin-top: 0.25rem;
}
.setting-info-box {
background: var(--accent-bg);
border: 1px solid var(--border-secondary);
border-left: 3px solid var(--accent);
border-radius: 8px;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
}
.setting-info-box p {
margin: 0;
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.5;
}
.setting-info-box strong {
color: var(--text-primary);
}
.setting-info-box.success {
background: var(--success-bg);
border-left-color: var(--success);
}
.setting-info-box.success p {
color: var(--success);
}
.smtp-readonly {
display: grid;
grid-template-columns: repeat(2, 1fr);