feat: add planner routes and email functionality; update settings and App component for new stock thresholds and email reminders
This commit is contained in:
@@ -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` |
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => `
|
||||
<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; ${
|
||||
row.enough
|
||||
? "background: #d1fae5; color: #065f46;"
|
||||
: "background: #fee2e2; color: #991b1b;"
|
||||
}">
|
||||
${row.enough ? "✓ Enough" : "⚠ Out of Stock"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
.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 = `
|
||||
<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="padding: 12px 16px; border-radius: 8px; margin-bottom: 20px; ${
|
||||
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;">
|
||||
${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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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) => `
|
||||
<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>
|
||||
</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="padding: 12px 16px; border-radius: 8px; margin-bottom: 20px; background: #fef2f2; border: 1px solid #fecaca;">
|
||||
<p style="margin: 0; color: #991b1b; font-weight: 500;">
|
||||
⚠️ ${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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
+291
-46
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user