feat: add intake reminders feature with email notifications and UI integration

This commit is contained in:
Daniel Volz
2025-12-21 09:18:03 +01:00
parent 2054fc0b56
commit f06904f8ae
9 changed files with 414 additions and 8 deletions
+1
View File
@@ -16,6 +16,7 @@ async function runMigrations() {
{ name: "expiry_date", sql: "ALTER TABLE medications ADD COLUMN expiry_date TEXT" },
{ name: "notes", sql: "ALTER TABLE medications ADD COLUMN notes TEXT" },
{ name: "generic_name", sql: "ALTER TABLE medications ADD COLUMN generic_name TEXT" },
{ name: "intake_reminders_enabled", sql: "ALTER TABLE medications ADD COLUMN intake_reminders_enabled INTEGER NOT NULL DEFAULT 0" },
];
for (const migration of migrations) {
@@ -0,0 +1,2 @@
-- Add intake_reminders_enabled column to medications table
ALTER TABLE medications ADD COLUMN intake_reminders_enabled INTEGER NOT NULL DEFAULT 0;
+2 -1
View File
@@ -6,6 +6,7 @@
{ "idx": 3, "version": 1, "when": 1734900000, "tag": "0003_add_image_url", "breakpoint": false },
{ "idx": 4, "version": 1, "when": 1735000000, "tag": "0004_add_expiry_date", "breakpoint": false },
{ "idx": 5, "version": 1, "when": 1735100000, "tag": "0005_add_notes", "breakpoint": false },
{ "idx": 6, "version": 1, "when": 1735200000, "tag": "0006_add_generic_name", "breakpoint": false }
{ "idx": 6, "version": 1, "when": 1735200000, "tag": "0006_add_generic_name", "breakpoint": false },
{ "idx": 7, "version": 1, "when": 1735300000, "tag": "0007_add_intake_reminders", "breakpoint": false }
]
}
+1
View File
@@ -27,6 +27,7 @@ export const medications = sqliteTable("medications", {
imageUrl: text("image_url"),
expiryDate: text("expiry_date"),
notes: text("notes"),
intakeRemindersEnabled: integer("intake_reminders_enabled", { mode: "boolean" }).notNull().default(false),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
+7
View File
@@ -17,6 +17,7 @@ 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";
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
// Wait for database migrations before anything else
await migrationsReady;
@@ -93,6 +94,12 @@ const start = async () => {
info: (msg) => app.log.info(msg),
error: (msg) => app.log.error(msg),
});
// Start the intake reminder scheduler (checks every minute)
startIntakeReminderScheduler({
info: (msg) => app.log.info(msg),
error: (msg) => app.log.error(msg),
});
} catch (err) {
app.log.error(err);
process.exit(1);
+8 -2
View File
@@ -24,6 +24,7 @@ const medicationSchema = z.object({
looseTablets: z.number().int().min(0).default(0),
expiryDate: z.string().nullable().optional(),
notes: z.string().max(500).nullable().optional(),
intakeRemindersEnabled: z.boolean().default(false),
// count will be derived on the backend
slices: z.array(sliceSchema).min(1).max(12),
});
@@ -66,6 +67,7 @@ export async function medicationRoutes(app: FastifyInstance) {
imageUrl: row.imageUrl,
expiryDate: row.expiryDate,
notes: row.notes,
intakeRemindersEnabled: row.intakeRemindersEnabled ?? false,
updatedAt: row.updatedAt,
}));
});
@@ -74,7 +76,7 @@ export async function medicationRoutes(app: FastifyInstance) {
const parsed = medicationSchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send(parsed.error.format());
const { name, genericName, packCount, stripsPerPack, tabsPerStrip, looseTablets, expiryDate, notes, slices } = parsed.data;
const { name, genericName, packCount, stripsPerPack, tabsPerStrip, looseTablets, expiryDate, notes, intakeRemindersEnabled, slices } = parsed.data;
const usageJson = JSON.stringify(slices.map((s) => s.usage));
const everyJson = JSON.stringify(slices.map((s) => s.every));
const startJson = JSON.stringify(slices.map((s) => s.start));
@@ -95,6 +97,7 @@ export async function medicationRoutes(app: FastifyInstance) {
looseTablets,
expiryDate: expiryDate || null,
notes: notes || null,
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
usageJson,
everyJson,
startJson,
@@ -116,6 +119,7 @@ export async function medicationRoutes(app: FastifyInstance) {
imageUrl: inserted.imageUrl,
expiryDate: inserted.expiryDate,
notes: inserted.notes,
intakeRemindersEnabled: inserted.intakeRemindersEnabled,
updatedAt: inserted.updatedAt,
};
});
@@ -126,7 +130,7 @@ export async function medicationRoutes(app: FastifyInstance) {
const idNum = Number(req.params.id);
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
const { name, genericName, packCount, stripsPerPack, tabsPerStrip, looseTablets, expiryDate, notes, slices } = parsed.data;
const { name, genericName, packCount, stripsPerPack, tabsPerStrip, looseTablets, expiryDate, notes, intakeRemindersEnabled, slices } = parsed.data;
const usageJson = JSON.stringify(slices.map((s) => s.usage));
const everyJson = JSON.stringify(slices.map((s) => s.every));
const startJson = JSON.stringify(slices.map((s) => s.start));
@@ -147,6 +151,7 @@ export async function medicationRoutes(app: FastifyInstance) {
looseTablets,
expiryDate: expiryDate || null,
notes: notes || null,
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
usageJson,
everyJson,
startJson,
@@ -172,6 +177,7 @@ export async function medicationRoutes(app: FastifyInstance) {
imageUrl: result[0].imageUrl,
expiryDate: result[0].expiryDate,
notes: result[0].notes,
intakeRemindersEnabled: result[0].intakeRemindersEnabled,
updatedAt: result[0].updatedAt,
};
});
@@ -0,0 +1,315 @@
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";
import { loadNotificationSettings, sendShoutrrrNotification } from "../routes/settings.js";
type Slice = { usage: number; every: number; start: string };
type IntakeReminderState = {
sentReminders: string[]; // Array of "medName:timestamp" to track sent reminders
};
const REMINDER_MINUTES_BEFORE = 15;
const CHECK_INTERVAL_MS = 60 * 1000; // Check every 1 minute
// Get current timezone from TZ env variable or default to UTC
function getTimezone(): string {
return process.env.TZ || "UTC";
}
const intakeReminderStateFile = resolve(process.cwd(), "data", "intake-reminder-state.json");
function loadIntakeReminderState(): IntakeReminderState {
try {
if (existsSync(intakeReminderStateFile)) {
const saved = JSON.parse(readFileSync(intakeReminderStateFile, "utf-8"));
return {
sentReminders: saved.sentReminders ?? [],
};
}
} catch {
// ignore
}
return { sentReminders: [] };
}
function saveIntakeReminderState(state: IntakeReminderState): void {
writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2));
}
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 [];
}
}
type UpcomingIntake = {
medName: string;
usage: number;
intakeTime: Date;
intakeTimeStr: string;
};
function getUpcomingIntakes(medName: string, slices: Slice[], minutesBefore: number): UpcomingIntake[] {
const now = Date.now();
const windowStart = now; // Now
const windowEnd = now + (minutesBefore + 1) * 60 * 1000; // minutesBefore + 1 minute from now
const upcoming: UpcomingIntake[] = [];
for (const slice of slices) {
const startTime = new Date(slice.start).getTime();
const intervalMs = slice.every * 24 * 60 * 60 * 1000;
if (intervalMs <= 0) continue;
// Find the next scheduled time for this slice
let nextTime = startTime;
// If start is in the past, calculate the next occurrence
if (nextTime < now) {
const elapsed = now - startTime;
const intervals = Math.floor(elapsed / intervalMs);
nextTime = startTime + (intervals + 1) * intervalMs;
}
// Check if this falls in our window (now to minutesBefore from now)
// We want to notify when the intake is minutesBefore minutes away
const notifyTime = nextTime - minutesBefore * 60 * 1000;
if (notifyTime >= windowStart && notifyTime <= windowEnd) {
const intakeDate = new Date(nextTime);
upcoming.push({
medName,
usage: slice.usage,
intakeTime: intakeDate,
intakeTimeStr: intakeDate.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
timeZone: getTimezone()
}),
});
}
}
return upcoming;
}
async function sendIntakeReminderEmail(email: string, intakes: UpcomingIntake[]): 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 = intakes
.map(
(intake) => `
<tr>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb;">${intake.medName}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center;"><strong>${intake.usage}</strong> pills</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center;">${intake.intakeTimeStr}</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 - Intake Reminder</h2>
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">Time to take your medication in ${REMINDER_MINUTES_BEFORE} minutes:</p>
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #eff6ff; border: 1px solid #bfdbfe;">
<p style="margin: 0; color: #1e40af; font-weight: 500; font-size: 13px;">
💊 ${intakes.length} medication${intakes.length > 1 ? "s" : ""} scheduled
</p>
</div>
<table style="width: 100%; border-collapse: collapse; background: white;">
<thead>
<tr style="background: #f3f4f6;">
<th style="padding: 10px 12px; text-align: left; font-size: 11px; text-transform: uppercase; color: #6b7280;">Medication</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280;">Dosage</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280;">Time</th>
</tr>
</thead>
<tbody>
${tableRows}
</tbody>
</table>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
<p style="color: #9ca3af; font-size: 11px; margin: 0;">
MedAssist Medication Planner
</p>
</div>
</div>
`;
const plainText = `MedAssist - Intake Reminder
Time to take your medication in ${REMINDER_MINUTES_BEFORE} minutes:
${intakes.map((i) => `${i.medName}: ${i.usage} pills at ${i.intakeTimeStr}`).join("\n")}
---
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: Medication Reminder - ${intakes.map(i => i.medName).join(", ")}`,
text: plainText,
html,
});
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return { success: false, error: errorMessage };
}
}
async function checkAndSendIntakeReminders(logger: { info: (msg: string) => void; error: (msg: string) => void }): Promise<void> {
const settings = loadNotificationSettings();
// Check if any notifications are enabled
const emailEnabled = settings.emailEnabled && settings.notificationEmail;
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl;
if (!emailEnabled && !shoutrrrEnabled) {
return; // No notifications enabled, skip silently
}
// Get all medications with intake reminders enabled
const rows = await db.select().from(medications).orderBy(medications.id);
const medsWithReminders = rows.filter(row => row.intakeRemindersEnabled);
if (medsWithReminders.length === 0) {
return; // No medications have reminders enabled
}
const state = loadIntakeReminderState();
const allUpcoming: UpcomingIntake[] = [];
// Find all upcoming intakes across all medications
for (const med of medsWithReminders) {
const slices = parseSlices(med);
const upcoming = getUpcomingIntakes(med.name, slices, REMINDER_MINUTES_BEFORE);
allUpcoming.push(...upcoming);
}
if (allUpcoming.length === 0) {
return; // No upcoming intakes in the window
}
// Filter out already-sent reminders
const newReminders = allUpcoming.filter(intake => {
const key = `${intake.medName}:${intake.intakeTime.getTime()}`;
return !state.sentReminders.includes(key);
});
if (newReminders.length === 0) {
return; // All reminders already sent
}
logger.info(`[IntakeReminder] Sending reminder for ${newReminders.length} upcoming intakes...`);
let emailSuccess = false;
let shoutrrrSuccess = false;
// Send email if enabled
if (emailEnabled) {
const result = await sendIntakeReminderEmail(settings.notificationEmail, newReminders);
emailSuccess = result.success;
if (result.success) {
logger.info(`[IntakeReminder] Email sent successfully`);
} else {
logger.error(`[IntakeReminder] Failed to send email: ${result.error}`);
}
}
// Send Shoutrrr notification if enabled
if (shoutrrrEnabled) {
const title = `Medication Reminder in ${REMINDER_MINUTES_BEFORE} min`;
const message = newReminders
.map((i) => `- ${i.medName}: ${i.usage} pills at ${i.intakeTimeStr}`)
.join("\n");
const result = await sendShoutrrrNotification(settings.shoutrrrUrl, title, message);
shoutrrrSuccess = result.success;
if (result.success) {
logger.info(`[IntakeReminder] Push notification sent successfully`);
} else {
logger.error(`[IntakeReminder] Failed to send push: ${result.error}`);
}
}
// Update state if any notification was sent successfully
if (emailSuccess || shoutrrrSuccess) {
const newKeys = newReminders.map(i => `${i.medName}:${i.intakeTime.getTime()}`);
// Clean up old entries (older than 24 hours)
const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
const cleanedReminders = state.sentReminders.filter(key => {
const timestamp = parseInt(key.split(":").pop() || "0", 10);
return timestamp > oneDayAgo;
});
saveIntakeReminderState({
sentReminders: [...cleanedReminders, ...newKeys],
});
}
}
let intakeCheckInterval: NodeJS.Timeout | null = null;
export function startIntakeReminderScheduler(logger: { info: (msg: string) => void; error: (msg: string) => void }): void {
logger.info(`[IntakeReminder] Starting intake reminder scheduler (checks every minute)...`);
// Run immediately on start
checkAndSendIntakeReminders(logger).catch((err) => logger.error(`[IntakeReminder] Error: ${err}`));
// Then run every minute
intakeCheckInterval = setInterval(() => {
checkAndSendIntakeReminders(logger).catch((err) => logger.error(`[IntakeReminder] Error: ${err}`));
}, CHECK_INTERVAL_MS);
logger.info(`[IntakeReminder] Scheduler started - checking every minute for upcoming intakes`);
}
export function stopIntakeReminderScheduler(): void {
if (intakeCheckInterval) {
clearInterval(intakeCheckInterval);
intakeCheckInterval = null;
}
}