diff --git a/backend/src/db/client.ts b/backend/src/db/client.ts index b83941e..c4a2ad3 100644 --- a/backend/src/db/client.ts +++ b/backend/src/db/client.ts @@ -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) { diff --git a/backend/src/db/migrations/0007_add_intake_reminders.sql b/backend/src/db/migrations/0007_add_intake_reminders.sql new file mode 100644 index 0000000..a5ae1e7 --- /dev/null +++ b/backend/src/db/migrations/0007_add_intake_reminders.sql @@ -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; diff --git a/backend/src/db/migrations/meta/_journal.json b/backend/src/db/migrations/meta/_journal.json index e4c7bac..05dc333 100644 --- a/backend/src/db/migrations/meta/_journal.json +++ b/backend/src/db/migrations/meta/_journal.json @@ -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 } ] } diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 660d730..4fb90a0 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -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`), }); diff --git a/backend/src/index.ts b/backend/src/index.ts index ec9701e..c3814af 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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); diff --git a/backend/src/routes/medications.ts b/backend/src/routes/medications.ts index 3d391e1..eb95100 100644 --- a/backend/src/routes/medications.ts +++ b/backend/src/routes/medications.ts @@ -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, }; }); diff --git a/backend/src/services/intake-reminder-scheduler.ts b/backend/src/services/intake-reminder-scheduler.ts new file mode 100644 index 0000000..895ef18 --- /dev/null +++ b/backend/src/services/intake-reminder-scheduler.ts @@ -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) => ` + + ${intake.medName} + ${intake.usage} pills + ${intake.intakeTimeStr} + + ` + ) + .join(""); + + const html = ` +
+
+

💊 MedAssist - Intake Reminder

+

Time to take your medication in ${REMINDER_MINUTES_BEFORE} minutes:

+ +
+

+ 💊 ${intakes.length} medication${intakes.length > 1 ? "s" : ""} scheduled +

+
+ + + + + + + + + + + ${tableRows} + +
MedicationDosageTime
+ +
+

+ MedAssist Medication Planner +

+
+
+ `; + + 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 { + 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; + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dd497a2..9bf6d58 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,7 @@ type Medication = { imageUrl?: string | null; expiryDate?: string | null; notes?: string | null; + intakeRemindersEnabled?: boolean; updatedAt: string | number | null; }; @@ -47,12 +48,13 @@ type FormState = { looseTablets: string; expiryDate: string; notes: string; + intakeRemindersEnabled: boolean; slices: FormSlice[]; }; const defaultSlice = (): FormSlice => ({ usage: "1", every: "1", start: toInputValue(new Date().toISOString()) }); -const defaultForm = (): FormState => ({ name: "", genericName: "", packCount: "1", stripsPerPack: "1", tabsPerStrip: "1", looseTablets: "0", expiryDate: "", notes: "", slices: [defaultSlice()] }); +const defaultForm = (): FormState => ({ name: "", genericName: "", packCount: "1", stripsPerPack: "1", tabsPerStrip: "1", looseTablets: "0", expiryDate: "", notes: "", intakeRemindersEnabled: false, slices: [defaultSlice()] }); const todayIso = () => new Date().toISOString(); const plusDaysIso = (days: number) => { @@ -434,6 +436,7 @@ export default function App() { looseTablets: String(med.looseTablets ?? 0), expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "", notes: med.notes ?? "", + intakeRemindersEnabled: med.intakeRemindersEnabled ?? false, slices: med.slices.map((s) => ({ usage: String(s.usage), every: String(s.every), start: toInputValue(s.start) })), }); } @@ -461,6 +464,7 @@ export default function App() { looseTablets: Math.max(0, Number(form.looseTablets) || 0), expiryDate: form.expiryDate || null, notes: form.notes.trim() || null, + intakeRemindersEnabled: form.intakeRemindersEnabled, slices: form.slices.map((s) => ({ usage: Number(s.usage) || 0, every: Math.max(1, Number(s.every) || 1), start: toIsoString(s.start) })), }; @@ -785,7 +789,17 @@ export default function App() {

Intake schedule

- +
+ + +
{form.slices.map((s, idx) => (
@@ -1273,15 +1287,31 @@ function deriveTotal(form: FormState) { function toIsoString(value: string) { if (!value) return new Date().toISOString(); + // datetime-local input gives us local time without timezone info + // We need to treat it as local time and convert to ISO const date = new Date(value); return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString(); } function toInputValue(value: string) { const date = new Date(value); - if (Number.isNaN(date.getTime())) return new Date().toISOString().slice(0, 16); - const iso = date.toISOString(); - return iso.slice(0, 16); + if (Number.isNaN(date.getTime())) { + // Return current local time in datetime-local format + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const hours = String(now.getHours()).padStart(2, '0'); + const minutes = String(now.getMinutes()).padStart(2, '0'); + return `${year}-${month}-${day}T${hours}:${minutes}`; + } + // Convert to local time format for datetime-local input + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${year}-${month}-${day}T${hours}:${minutes}`; } function formatDateTime(value: string) { diff --git a/frontend/src/styles.css b/frontend/src/styles.css index fed1347..71aa84a 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -260,6 +260,49 @@ body { .slices h3 { margin: 0; } .gap { gap: 0.6rem; } +/* Slices header actions */ +.slices-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +/* Inline checkbox for compact layout */ +.inline-checkbox { + display: flex !important; + flex-direction: row !important; + align-items: center; + gap: 0.4rem; + cursor: pointer; + font-size: 0.8rem; + color: var(--text-secondary); + padding: 0.35rem 0.6rem; + border-radius: 6px; + transition: background 0.15s, color 0.15s; + text-transform: none; + font-weight: 500; + letter-spacing: 0; + white-space: nowrap; +} + +.inline-checkbox:hover { + background: var(--accent-bg); + color: var(--text-primary); +} + +.inline-checkbox:has(input:checked) { + background: var(--accent-bg); + color: var(--accent); +} + +.inline-checkbox input[type="checkbox"] { + width: 14px; + height: 14px; + accent-color: var(--accent); + cursor: pointer; + margin: 0; +} + button { padding: 0.7rem 1.25rem; border-radius: 8px;