diff --git a/.env.example b/.env.example index 18245c8..2c06b1f 100644 --- a/.env.example +++ b/.env.example @@ -78,4 +78,44 @@ REMINDER_DAYS_BEFORE=7 # Admin settings (not editable in UI) REMINDER_HOUR=6 # 24h format (0-23), e.g. 6 = 6:00 AM, 18 = 6:00 PM REMINDER_MINUTES_BEFORE=15 # Minutes before intake to send reminder -EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning \ No newline at end of file +EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning + +# ============================================================================= +# Default User Settings (applied when new user is created) +# ============================================================================= +# These ENV values are only used as DEFAULTS when a new user is created. +# Once a user saves their settings in the app, these ENV values are ignored +# for that user - their saved preferences take precedence. +# +# Useful for server admins to pre-configure settings for all new users. +# ============================================================================= + +# Email notifications (requires SMTP config above) +# DEFAULT_EMAIL_ENABLED=false +# DEFAULT_NOTIFICATION_EMAIL= +# DEFAULT_EMAIL_STOCK_REMINDERS=true +# DEFAULT_EMAIL_INTAKE_REMINDERS=true + +# Push notifications (ntfy/gotify via Shoutrrr) +# DEFAULT_SHOUTRRR_ENABLED=false +# DEFAULT_SHOUTRRR_URL= +# DEFAULT_SHOUTRRR_STOCK_REMINDERS=true +# DEFAULT_SHOUTRRR_INTAKE_REMINDERS=true + +# Repeat/nagging reminders for missed doses +# DEFAULT_REPEAT_REMINDERS_ENABLED=false +# DEFAULT_REMINDER_REPEAT_INTERVAL_MINUTES=30 +# DEFAULT_MAX_NAGGING_REMINDERS=5 +# DEFAULT_SKIP_REMINDERS_FOR_TAKEN_DOSES=false + +# Stock reminder settings +# DEFAULT_REPEAT_DAILY_REMINDERS=false + +# Stock thresholds (days of supply) +# DEFAULT_LOW_STOCK_DAYS=30 +# DEFAULT_NORMAL_STOCK_DAYS=90 +# DEFAULT_HIGH_STOCK_DAYS=180 + +# UI defaults +# DEFAULT_LANGUAGE=en # en or de +# DEFAULT_STOCK_CALCULATION_MODE=automatic # automatic or manual \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d9a97c9..b220729 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,5 +1,10 @@ # MedAssist-ng - AI Coding Instructions +## General Rules + +- **No temporary files**: Delete temporary scripts/files immediately after use. Do not commit temporary debug scripts, test files, or one-off utilities to the repository. +- **Clean workspace**: Always clean up after yourself. If you create a file for a specific task, delete it once done. + ## Architecture Overview MedAssist-ng is a **medication tracking and planning app** with a monorepo structure: @@ -93,12 +98,15 @@ describe('Feature Name', () => { ### Test-Commands ```bash cd backend -npm test # Alle Tests ausführen -npm run test:coverage # Mit Coverage-Report -npm test -- --watch # Watch-Mode für Entwicklung +CI=true npm test # Tests einmal ausführen (IMMER so ausführen!) +CI=true npm run test:coverage # Mit Coverage-Report +npm test -- --watch # Watch-Mode für manuelle Entwicklung npm test -- -t "test name" # Einzelnen Test ausführen ``` +> ⚠️ **WICHTIG für AI-Agenten**: Tests IMMER mit `CI=true` ausführen! +> Ohne `CI=true` läuft Vitest im Watch-Mode und wartet auf Eingaben. + ## CI/CD Pipeline (GitHub Actions) ### Workflow-Übersicht @@ -283,9 +291,25 @@ Each blister defines a recurring intake: | Stock Thresholds | Warning days, critical days, expiry warning days | | Email Notifications | Enable, email address, stock/intake toggles | | Push Notifications (Shoutrrr) | Enable, URL (ntfy/gotify/etc), stock/intake toggles | -| Reminder Settings | Days before, repeat daily | +| Reminder Settings | Days before, repeat daily, skip for taken, repeat/nagging | | SMTP | Email config (read-only from .env) | +### Settings ENV Defaults +All user settings can be pre-configured via ENV variables (see `.env.example`). +These are only used as **defaults when a new user is created**. +Once a user saves settings in the app, their saved values take precedence over ENV. + +| ENV Variable | Setting | Default | +|--------------|---------|---------| +| `DEFAULT_EMAIL_ENABLED` | Email notifications | false | +| `DEFAULT_SHOUTRRR_ENABLED` | Push notifications | false | +| `DEFAULT_SHOUTRRR_URL` | ntfy/gotify URL | (empty) | +| `DEFAULT_REPEAT_REMINDERS_ENABLED` | Nagging reminders | false | +| `DEFAULT_REMINDER_REPEAT_INTERVAL_MINUTES` | Nag interval | 30 | +| `DEFAULT_MAX_NAGGING_REMINDERS` | Max nags | 5 | +| `DEFAULT_LOW_STOCK_DAYS` | Low stock threshold | 30 | +| `DEFAULT_LANGUAGE` | UI language | en | + ## Database Schema (`backend/src/db/schema.ts`) | Table | Description | @@ -347,14 +371,54 @@ Example: `5-0-1735344000000` = Medication 5, Blister 0, timestamp - **Environment**: Copy `.env.example` → `.env`, secrets must be 10+ chars - **i18n**: All UI text via `t('key')` function, translations in `frontend/src/i18n/*.json` -## Database Schema Changes +## Database Schema Changes (WICHTIG: Abwärtskompatibilität!) -When adding new database columns: +> ⚠️ **KRITISCH**: Die App MUSS abwärtskompatibel mit älteren Datenbanken bleiben! +> Nutzer upgraden ihre Docker-Container, aber behalten ihre bestehende DB. +> Die App darf NICHT abstürzen wenn alte Spalten fehlen. -1. **Update schema**: `backend/src/db/schema.ts` - Add the Drizzle column definition -2. **Update client.ts**: `backend/src/db/client.ts` - Add column to `CREATE TABLE IF NOT EXISTS` -3. **Update migrate.ts**: `backend/src/db/migrate.ts` - Same as client.ts -4. **Delete old DB**: `rm backend/data/medassist-ng.db` and restart +### Regeln für neue Spalten + +1. **IMMER mit DEFAULT-Wert**: Neue Spalten müssen `NOT NULL DEFAULT ` haben +2. **NULL-sicher im Code**: Alle Abfragen müssen `?? defaultValue` oder `?? false` verwenden +3. **Schema-SQL aktualisieren**: In diesen Dateien hinzufügen: + - `backend/src/db/schema.ts` - Drizzle Schema + - `backend/src/db/schema-sql.ts` - `getTableCreationSQL()` für neue DBs + - `backend/src/db/client.ts` - `ALTER TABLE ADD COLUMN IF NOT EXISTS` Migration +4. **Test-Schemas updaten**: Alle Test-Dateien mit eigenem Schema: + - `backend/src/test/e2e-routes.test.ts` + - `backend/src/test/integration.test.ts` + - `backend/src/test/planner.test.ts` + +### Beispiel: Neue Spalte hinzufügen + +```typescript +// 1. schema.ts - Drizzle Definition +maxNaggingReminders: integer("max_nagging_reminders").notNull().default(5), + +// 2. schema-sql.ts - Für neue Datenbanken +"max_nagging_reminders integer NOT NULL DEFAULT 5," + +// 3. client.ts - Migration für bestehende DBs (IN ensureTablesExist()) +await client.execute(`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`).catch(() => {}); + +// 4. Routes - NULL-sicher lesen +maxNaggingReminders: settings.maxNaggingReminders ?? 5, +``` + +### Was NICHT erlaubt ist + +- ❌ Spalten löschen oder umbenennen (bricht alte DBs) +- ❌ `NOT NULL` ohne `DEFAULT` (INSERT schlägt fehl) +- ❌ Spalten ohne Fallback im Code lesen +- ❌ DB löschen als "Lösung" dokumentieren + +### Wann Abwärtskompatibilität NICHT möglich ist + +Wenn eine Breaking Change unvermeidbar ist: +1. **Explizit kommunizieren**: In Release Notes dokumentieren +2. **Migration-Script**: Automatisches Upgrade-Script bereitstellen +3. **Versionsprüfung**: App sollte DB-Version prüfen und warnen ## File Locations diff --git a/backend/src/db/client.ts b/backend/src/db/client.ts index a3893a8..5093cd2 100644 --- a/backend/src/db/client.ts +++ b/backend/src/db/client.ts @@ -60,6 +60,27 @@ export async function runTableMigrations(client: Client): Promise<{ success: boo } } + // Run ALTER TABLE migrations for backward compatibility with older databases + // These add new columns to existing tables (silently fail if column already exists) + const alterMigrations = [ + // Added in v1.x - repeat reminders and nagging settings + `ALTER TABLE user_settings ADD COLUMN skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0`, + `ALTER TABLE user_settings ADD COLUMN repeat_reminders_enabled integer NOT NULL DEFAULT 0`, + `ALTER TABLE user_settings ADD COLUMN reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30`, + `ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`, + ]; + + for (const sql of alterMigrations) { + try { + await client.execute(sql); + } catch (e: any) { + // Silently ignore "duplicate column" errors - column already exists + if (!e.message?.includes("duplicate column")) { + errors.push(e.message); + } + } + } + return { success: errors.length === 0, errors }; } diff --git a/backend/src/db/schema-sql.ts b/backend/src/db/schema-sql.ts index 95ffebd..87c6e89 100644 --- a/backend/src/db/schema-sql.ts +++ b/backend/src/db/schema-sql.ts @@ -55,6 +55,10 @@ export function getTableCreationSQL(): string[] { shoutrrr_intake_reminders integer NOT NULL DEFAULT 1, reminder_days_before integer NOT NULL DEFAULT 7, repeat_daily_reminders integer NOT NULL DEFAULT 0, + skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0, + repeat_reminders_enabled integer NOT NULL DEFAULT 0, + reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30, + max_nagging_reminders integer NOT NULL DEFAULT 5, low_stock_days integer NOT NULL DEFAULT 30, normal_stock_days integer NOT NULL DEFAULT 90, high_stock_days integer NOT NULL DEFAULT 180, diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index aae7fe8..e23d259 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -60,6 +60,10 @@ export const userSettings = sqliteTable("user_settings", { // Reminder settings reminderDaysBefore: integer("reminder_days_before").notNull().default(7), repeatDailyReminders: integer("repeat_daily_reminders", { mode: "boolean" }).notNull().default(false), + skipRemindersForTakenDoses: integer("skip_reminders_for_taken_doses", { mode: "boolean" }).notNull().default(false), + repeatRemindersEnabled: integer("repeat_reminders_enabled", { mode: "boolean" }).notNull().default(false), + reminderRepeatIntervalMinutes: integer("reminder_repeat_interval_minutes").notNull().default(30), + maxNaggingReminders: integer("max_nagging_reminders").notNull().default(5), // Stock thresholds (days) lowStockDays: integer("low_stock_days").notNull().default(30), normalStockDays: integer("normal_stock_days").notNull().default(90), diff --git a/backend/src/routes/health.ts b/backend/src/routes/health.ts index 48d1373..5ffe5fd 100644 --- a/backend/src/routes/health.ts +++ b/backend/src/routes/health.ts @@ -1,5 +1,9 @@ import { FastifyInstance } from "fastify"; export async function healthRoutes(app: FastifyInstance) { - app.get("/health", async () => ({ status: "ok" })); + app.get("/health", async () => ({ + status: "ok", + smtpConfigured: Boolean(process.env.SMTP_HOST), + shoutrrrConfigured: Boolean(process.env.SHOUTRRR_URL), + })); } diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 03f9324..5a2ebb9 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -21,6 +21,10 @@ export type UserSettings = { shoutrrrIntakeReminders: boolean; reminderDaysBefore: number; repeatDailyReminders: boolean; + skipRemindersForTakenDoses: boolean; + repeatRemindersEnabled: boolean; + reminderRepeatIntervalMinutes: number; + maxNaggingReminders: number; lowStockDays: number; normalStockDays: number; highStockDays: number; @@ -45,6 +49,10 @@ type SettingsBody = { emailIntakeReminders: boolean; shoutrrrStockReminders: boolean; shoutrrrIntakeReminders: boolean; + skipRemindersForTakenDoses: boolean; + repeatRemindersEnabled: boolean; + reminderRepeatIntervalMinutes: number; + maxNaggingReminders: number; language: string; stockCalculationMode: "automatic" | "manual"; }; @@ -57,37 +65,58 @@ type TestShoutrrrBody = { url: string; }; -// Default settings for new users -const defaultSettings = { - emailEnabled: false, - notificationEmail: null, - emailStockReminders: true, - emailIntakeReminders: true, - shoutrrrEnabled: false, - shoutrrrUrl: null, - shoutrrrStockReminders: true, - shoutrrrIntakeReminders: true, - reminderDaysBefore: 7, - repeatDailyReminders: false, - lowStockDays: 30, - normalStockDays: 90, - highStockDays: 180, - language: "en", - stockCalculationMode: "automatic" as const, - lastAutoEmailSent: null, - lastNotificationType: null, - lastNotificationChannel: null, -}; +// Helper to parse boolean env vars +function envBool(key: string, defaultVal: boolean): boolean { + const val = process.env[key]; + if (val === undefined) return defaultVal; + return val === "true" || val === "1"; +} + +// Helper to parse integer env vars +function envInt(key: string, defaultVal: number): number { + const val = process.env[key]; + if (val === undefined) return defaultVal; + const parsed = parseInt(val, 10); + return isNaN(parsed) ? defaultVal : parsed; +} + +// Default settings for new users - read from ENV with fallbacks +function getDefaultSettings() { + return { + emailEnabled: envBool("DEFAULT_EMAIL_ENABLED", false), + notificationEmail: process.env.DEFAULT_NOTIFICATION_EMAIL || null, + emailStockReminders: envBool("DEFAULT_EMAIL_STOCK_REMINDERS", true), + emailIntakeReminders: envBool("DEFAULT_EMAIL_INTAKE_REMINDERS", true), + shoutrrrEnabled: envBool("DEFAULT_SHOUTRRR_ENABLED", false), + shoutrrrUrl: process.env.DEFAULT_SHOUTRRR_URL || null, + shoutrrrStockReminders: envBool("DEFAULT_SHOUTRRR_STOCK_REMINDERS", true), + shoutrrrIntakeReminders: envBool("DEFAULT_SHOUTRRR_INTAKE_REMINDERS", true), + reminderDaysBefore: envInt("REMINDER_DAYS_BEFORE", 7), + repeatDailyReminders: envBool("DEFAULT_REPEAT_DAILY_REMINDERS", false), + skipRemindersForTakenDoses: envBool("DEFAULT_SKIP_REMINDERS_FOR_TAKEN_DOSES", false), + repeatRemindersEnabled: envBool("DEFAULT_REPEAT_REMINDERS_ENABLED", false), + reminderRepeatIntervalMinutes: envInt("DEFAULT_REMINDER_REPEAT_INTERVAL_MINUTES", 30), + maxNaggingReminders: envInt("DEFAULT_MAX_NAGGING_REMINDERS", 5), + lowStockDays: envInt("DEFAULT_LOW_STOCK_DAYS", 30), + normalStockDays: envInt("DEFAULT_NORMAL_STOCK_DAYS", 90), + highStockDays: envInt("DEFAULT_HIGH_STOCK_DAYS", 180), + language: (process.env.DEFAULT_LANGUAGE as "en" | "de") || "en", + stockCalculationMode: (process.env.DEFAULT_STOCK_CALCULATION_MODE as "automatic" | "manual") || "automatic", + lastAutoEmailSent: null, + lastNotificationType: null, + lastNotificationChannel: null, + }; +} // Helper to get or create user settings async function getOrCreateUserSettings(userId: number) { let [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId)); if (!settings) { - // Create default settings for user + // Create default settings for user (using ENV defaults) [settings] = await db.insert(userSettings).values({ userId, - ...defaultSettings, + ...getDefaultSettings(), }).returning(); } @@ -109,6 +138,10 @@ export async function loadUserSettings(userId: number): Promise { shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders, reminderDaysBefore: settings.reminderDaysBefore, repeatDailyReminders: settings.repeatDailyReminders, + skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false, + repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false, + reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30, + maxNaggingReminders: settings.maxNaggingReminders ?? 5, lowStockDays: settings.lowStockDays, normalStockDays: settings.normalStockDays, highStockDays: settings.highStockDays, @@ -135,6 +168,10 @@ export async function getAllUserSettings(): Promise { shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders, reminderDaysBefore: settings.reminderDaysBefore, repeatDailyReminders: settings.repeatDailyReminders, + skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false, + repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false, + reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30, + maxNaggingReminders: settings.maxNaggingReminders ?? 5, lowStockDays: settings.lowStockDays, normalStockDays: settings.normalStockDays, highStockDays: settings.highStockDays, @@ -187,6 +224,10 @@ export async function settingsRoutes(app: FastifyInstance) { emailIntakeReminders: settings.emailIntakeReminders, shoutrrrStockReminders: settings.shoutrrrStockReminders, shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders, + skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses, + repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false, + reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30, + maxNaggingReminders: settings.maxNaggingReminders ?? 5, language: settings.language, stockCalculationMode: settings.stockCalculationMode ?? "automatic", // SMTP settings (from .env - shared/server-configured) @@ -233,6 +274,10 @@ export async function settingsRoutes(app: FastifyInstance) { shoutrrrIntakeReminders: body.shoutrrrIntakeReminders ?? true, reminderDaysBefore: body.reminderDaysBefore, repeatDailyReminders, + skipRemindersForTakenDoses: body.skipRemindersForTakenDoses ?? false, + repeatRemindersEnabled: body.repeatRemindersEnabled ?? false, + reminderRepeatIntervalMinutes: body.reminderRepeatIntervalMinutes ?? 30, + maxNaggingReminders: body.maxNaggingReminders ?? 5, lowStockDays: body.lowStockDays ?? 30, normalStockDays: body.normalStockDays ?? 90, highStockDays: body.highStockDays ?? 180, diff --git a/backend/src/services/intake-reminder-scheduler.ts b/backend/src/services/intake-reminder-scheduler.ts index a5de06c..6f6211a 100644 --- a/backend/src/services/intake-reminder-scheduler.ts +++ b/backend/src/services/intake-reminder-scheduler.ts @@ -1,7 +1,7 @@ import nodemailer from "nodemailer"; -import { eq } from "drizzle-orm"; +import { eq, and, gte, lte } from "drizzle-orm"; import { db } from "../db/client.js"; -import { medications } from "../db/schema.js"; +import { medications, doseTracking } from "../db/schema.js"; import { readFileSync, writeFileSync, existsSync } from "fs"; import { resolve } from "path"; import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js"; @@ -14,6 +14,7 @@ import { parseBlisters, parseTakenByJson, getUpcomingIntakes, + getTodaysIntakes, parseIntakeReminderState, createDefaultIntakeReminderState, cleanOldIntakeReminders, @@ -46,7 +47,13 @@ function parseBlistersFromRow(row: { usageJson: string; everyJson: string; start return parseBlisters(row); } -async function sendIntakeReminderEmail(email: string, intakes: UpcomingIntake[], language: Language): Promise<{ success: boolean; error?: string }> { +async function sendIntakeReminderEmail( + email: string, + intakes: UpcomingIntake[], + language: Language, + isRepeat: boolean = false, + repeatIntervalMinutes?: number +): Promise<{ success: boolean; error?: string }> { const smtpHost = process.env.SMTP_HOST; const smtpUser = process.env.SMTP_USER; const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence @@ -96,11 +103,16 @@ async function sendIntakeReminderEmail(email: string, intakes: UpcomingIntake[], ? tr.intakeReminder.alertSingle : t(tr.intakeReminder.alertMultiple, { count: intakes.length }); + // Different description for repeat reminders + const description = isRepeat && repeatIntervalMinutes + ? `⚠️ Don't forget your medication! This reminder will be sent every ${repeatIntervalMinutes} minutes until you mark it as taken.` + : t(tr.intakeReminder.description, { minutes: REMINDER_MINUTES_BEFORE }); + const html = `

${tr.intakeReminder.title}

-

${t(tr.intakeReminder.description, { minutes: REMINDER_MINUTES_BEFORE })}

+

${description}

@@ -142,7 +154,7 @@ async function sendIntakeReminderEmail(email: string, intakes: UpcomingIntake[], const plainText = `${tr.intakeReminder.title} -${t(tr.intakeReminder.description, { minutes: REMINDER_MINUTES_BEFORE })} +${description} ${intakes.map((i) => { const takenByStr = i.takenBy.length > 0 ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy.join(", ") })}` : ""; @@ -152,7 +164,9 @@ ${intakes.map((i) => { --- ${tr.intakeReminder.footer}`; - const subject = t(tr.intakeReminder.subject, { medications: intakes.map(i => i.medName).join(", ") }); + const subject = isRepeat + ? `[Reminder] ${t(tr.intakeReminder.subject, { medications: intakes.map(i => i.medName).join(", ") })}` + : t(tr.intakeReminder.subject, { medications: intakes.map(i => i.medName).join(", ") }); try { const transporter = nodemailer.createTransport({ @@ -181,13 +195,18 @@ ${tr.intakeReminder.footer}`; } async function checkAndSendIntakeReminders(logger: { info: (msg: string) => void; error: (msg: string) => void }): Promise { + logger.info(`[IntakeReminder] Checking for intake reminders...`); + // Get all user settings to iterate over each user const allUserSettings = await getAllUserSettings(); if (allUserSettings.length === 0) { + logger.info(`[IntakeReminder] No users with settings found`); return; // No users with settings } + logger.info(`[IntakeReminder] Found ${allUserSettings.length} users to check`); + for (const userSettings of allUserSettings) { await checkAndSendIntakeRemindersForUser(userSettings, logger); } @@ -200,56 +219,190 @@ async function checkAndSendIntakeRemindersForUser( const language = settings.language; const tr = getTranslations(language); + logger.info(`[IntakeReminder] Checking user ${settings.userId} - repeat:${settings.repeatRemindersEnabled} skip:${settings.skipRemindersForTakenDoses}`); + // Check if any intake reminder notifications are enabled (granular check) const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders; const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders; if (!emailEnabled && !shoutrrrEnabled) { + logger.info(`[IntakeReminder] User ${settings.userId}: No intake notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`); return; // No intake reminder notifications enabled for this user } + logger.info(`[IntakeReminder] User ${settings.userId}: Notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`); + // Get all medications with intake reminders enabled for this user const rows = await db.select().from(medications).where(eq(medications.userId, settings.userId)).orderBy(medications.id); const medsWithReminders = rows.filter(row => row.intakeRemindersEnabled); if (medsWithReminders.length === 0) { + logger.info(`[IntakeReminder] User ${settings.userId}: No medications have reminders enabled`); return; // No medications have reminders enabled for this user } - const state = loadIntakeReminderState(); - const allUpcoming: UpcomingIntake[] = []; - const locale = getDateLocale(language); + logger.info(`[IntakeReminder] User ${settings.userId}: Found ${medsWithReminders.length} medications with reminders`); - // Find all upcoming intakes across all medications for this user + const state = loadIntakeReminderState(); + const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = []; + const locale = getDateLocale(language); + const tz = getTimezone(); + + // Get start and end of today in user's timezone (for filtering today's doses only) + const now = new Date(); + const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz })); + todayStart.setHours(0, 0, 0, 0); + + const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: tz })); + todayEnd.setHours(23, 59, 59, 999); + + logger.info(`[IntakeReminder] User ${settings.userId}: Today range: ${todayStart.toISOString()} to ${todayEnd.toISOString()}`); + + // Find intakes: upcoming ones in reminder window + past ones for repeat reminders for (const med of medsWithReminders) { const blisters = parseBlistersFromRow(med); const takenByArray = parseTakenByJson(med.takenByJson); - const upcoming = getUpcomingIntakes(med.name, blisters, REMINDER_MINUTES_BEFORE, takenByArray, med.pillWeightMg, locale); - allUpcoming.push(...upcoming); + + logger.info(`[IntakeReminder] User ${settings.userId}: Processing medication "${med.name}" with ${blisters.length} blisters`); + + // Process each blister separately to track blisterIndex + blisters.forEach((blister, blisterIndex) => { + logger.info(`[IntakeReminder] User ${settings.userId}: Blister ${blisterIndex} - start: ${blister.start}, every: ${blister.every} days, usage: ${blister.usage}`); + + // Always get upcoming intakes (15 min before) for first reminders + const upcomingIntakes = getUpcomingIntakes(med.name, [blister], REMINDER_MINUTES_BEFORE, takenByArray, med.pillWeightMg, locale, tz); + logger.info(`[IntakeReminder] User ${settings.userId}: Blister ${blisterIndex} found ${upcomingIntakes.length} upcoming intakes (reminder window)`); + + // Add upcoming intakes for first reminders + allUpcoming.push(...upcomingIntakes.map(intake => ({ + ...intake, + medicationId: med.id, + blisterIndex, + }))); + + // If repeat reminders enabled, also check for missed intakes (past the intake time) + if (settings.repeatRemindersEnabled) { + const allTodaysIntakes = getTodaysIntakes(med.name, [blister], takenByArray, med.pillWeightMg, locale, tz); + logger.info(`[IntakeReminder] User ${settings.userId}: Blister ${blisterIndex} - all today's intakes: ${allTodaysIntakes.length}, times: ${allTodaysIntakes.map(i => i.intakeTime.toISOString()).join(', ')}`); + const missedIntakes = allTodaysIntakes.filter(intake => intake.intakeTime.getTime() < now.getTime()); + logger.info(`[IntakeReminder] User ${settings.userId}: Blister ${blisterIndex} found ${missedIntakes.length} missed intakes (past intake time)`); + + // Add missed intakes for repeat reminders (only if not already in upcoming list) + const upcomingTimes = new Set(upcomingIntakes.map(i => i.intakeTime.getTime())); + allUpcoming.push(...missedIntakes + .filter(intake => !upcomingTimes.has(intake.intakeTime.getTime())) + .map(intake => ({ + ...intake, + medicationId: med.id, + blisterIndex, + }))); + } + }); } + logger.info(`[IntakeReminder] User ${settings.userId}: Total ${allUpcoming.length} intakes for today`); + if (allUpcoming.length === 0) { - return; // No upcoming intakes in the window + logger.info(`[IntakeReminder] User ${settings.userId}: No intakes for today`); + return; // No upcoming intakes for today } - // Filter out already-sent reminders (keyed by user) - const newReminders = allUpcoming.filter(intake => { - const key = `user_${settings.userId}:${intake.medName}:${intake.intakeTime.getTime()}`; - return !state.sentReminders.includes(key); - }); + // Determine which doses need reminders (new or repeated) + const nowMs = Date.now(); + let remindersToSend: typeof allUpcoming = []; - if (newReminders.length === 0) { - return; // All reminders already sent + for (const intake of allUpcoming) { + const key = `user_${settings.userId}:${intake.medName}:${intake.intakeTime.getTime()}`; + const existingEntry = state.reminders[key]; + const intakeTimeMs = intake.intakeTime.getTime(); + const isIntakePast = intakeTimeMs < nowMs; + + if (!existingEntry) { + // New dose - always send first reminder (upcoming or already missed) + remindersToSend.push(intake); + logger.info(`[IntakeReminder] User ${settings.userId}: First reminder for "${intake.medName}" at ${intake.intakeTimeStr} (${isIntakePast ? 'missed' : 'upcoming'})`); + } else if (settings.repeatRemindersEnabled && isIntakePast) { + // Repeat reminder - only for intakes that are already past (missed) + const intervalMs = settings.reminderRepeatIntervalMinutes * 60 * 1000; + const timeSinceLastReminder = nowMs - existingEntry.lastSentAt; + const maxReminders = settings.maxNaggingReminders ?? 5; + + if (existingEntry.sendCount >= maxReminders) { + // Max reminders reached - stop nagging + logger.info(`[IntakeReminder] User ${settings.userId}: Max reminders (${maxReminders}) reached for "${intake.medName}" at ${intake.intakeTimeStr}`); + } else if (timeSinceLastReminder >= intervalMs) { + remindersToSend.push(intake); + logger.info(`[IntakeReminder] User ${settings.userId}: Repeat reminder for missed "${intake.medName}" at ${intake.intakeTimeStr} (${existingEntry.sendCount + 1}/${maxReminders})`); + } + } + // Else: Already sent and either repeats disabled or intake not yet past - skip + } + + if (remindersToSend.length === 0) { + return; // All reminders already sent and no repeats needed } - logger.info(`[IntakeReminder] User ${settings.userId}: Sending reminder for ${newReminders.length} upcoming intakes...`); + // If skipRemindersForTakenDoses is enabled, filter out doses that were already taken today + if (settings.skipRemindersForTakenDoses) { + // Query doses marked as taken today (takenAt is timestamp, stored as seconds since epoch) + const takenToday = await db.select().from(doseTracking).where( + and( + eq(doseTracking.userId, settings.userId), + gte(doseTracking.takenAt, todayStart), + lte(doseTracking.takenAt, todayEnd) + ) + ); + + const takenDoseIds = new Set(takenToday.map(d => d.doseId)); + + // Filter out reminders for doses that were already taken + remindersToSend = remindersToSend.filter(intake => { + const timestamp = intake.intakeTime.getTime(); + + // Check both with and without person suffix + if (intake.takenBy.length > 0) { + // For multi-person medications, check if any person has taken it + const anyTaken = intake.takenBy.some(person => { + const doseId = `${intake.medicationId}-${intake.blisterIndex}-${timestamp}-${person}`; + return takenDoseIds.has(doseId); + }); + return !anyTaken; // Skip if any person has taken it + } else { + // For non-person-specific medications + const doseId = `${intake.medicationId}-${intake.blisterIndex}-${timestamp}`; + return !takenDoseIds.has(doseId); + } + }); + + if (remindersToSend.length === 0) { + logger.info(`[IntakeReminder] User ${settings.userId}: All doses taken, skipping reminders`); + return; + } + } + + logger.info(`[IntakeReminder] User ${settings.userId}: Sending reminder for ${remindersToSend.length} intakes...`); + + // Determine if this is a repeat reminder: + // - Any intake already has a state entry AND is past (repeat after first reminder) + // - OR intake is past even without state entry (missed the 15-min window) + const isRepeatReminder = remindersToSend.some(intake => { + const intakeTimeMs = intake.intakeTime.getTime(); + const isIntakePast = intakeTimeMs < nowMs; + return isIntakePast; // Use repeat message for ANY missed intake + }); let emailSuccess = false; let shoutrrrSuccess = false; // Send email if enabled for intake reminders if (emailEnabled) { - const result = await sendIntakeReminderEmail(settings.notificationEmail!, newReminders, language); + const result = await sendIntakeReminderEmail( + settings.notificationEmail!, + remindersToSend, + language, + isRepeatReminder, + settings.reminderRepeatIntervalMinutes + ); emailSuccess = result.success; if (result.success) { logger.info(`[IntakeReminder] User ${settings.userId}: Email sent successfully`); @@ -260,8 +413,15 @@ async function checkAndSendIntakeRemindersForUser( // Send Shoutrrr notification if enabled for intake reminders if (shoutrrrEnabled) { - const title = t(tr.push.intakeTitle, { minutes: REMINDER_MINUTES_BEFORE }); - const message = newReminders + const title = isRepeatReminder + ? (language === 'de' ? '⚠️ Medikamenten-Erinnerung' : '⚠️ Medication Reminder') + : t(tr.push.intakeTitle, { minutes: REMINDER_MINUTES_BEFORE }); + + const repeatNote = isRepeatReminder && settings.reminderRepeatIntervalMinutes + ? `\n\n⚠️ This reminder will be sent every ${settings.reminderRepeatIntervalMinutes} minutes until marked as taken.` + : ''; + + const message = remindersToSend .map((i) => { const takenByStr = i.takenBy.length > 0 ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy.join(", ") })}` : ""; let dosage = `${i.usage} ${i.usage === 1 ? tr.common.pill : tr.common.pills}`; @@ -271,7 +431,7 @@ async function checkAndSendIntakeRemindersForUser( } return `• ${i.medName}${takenByStr}: ${dosage} @ ${i.intakeTimeStr}`; }) - .join("\n"); + .join("\n") + repeatNote; const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message); shoutrrrSuccess = result.success; @@ -284,14 +444,32 @@ async function checkAndSendIntakeRemindersForUser( // Update state if any notification was sent successfully if (emailSuccess || shoutrrrSuccess) { - const newKeys = newReminders.map(i => `user_${settings.userId}:${i.medName}:${i.intakeTime.getTime()}`); + // Update or create entries for sent reminders + for (const intake of remindersToSend) { + const key = `user_${settings.userId}:${intake.medName}:${intake.intakeTime.getTime()}`; + const existing = state.reminders[key]; + + if (existing) { + // Update existing entry (repeat) + state.reminders[key] = { + firstSentAt: existing.firstSentAt, + lastSentAt: nowMs, + sendCount: existing.sendCount + 1, + }; + } else { + // Create new entry (first send) + state.reminders[key] = { + firstSentAt: nowMs, + lastSentAt: nowMs, + sendCount: 1, + }; + } + } - // Clean up old entries (older than 24 hours) - const cleanedReminders = cleanOldIntakeReminders(state.sentReminders); + // Clean up old entries (remove doses from past days) + state.reminders = cleanOldIntakeReminders(state.reminders, tz); - saveIntakeReminderState({ - sentReminders: [...cleanedReminders, ...newKeys], - }); + saveIntakeReminderState(state); // Update global reminder state for UI display const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push"; diff --git a/backend/src/test/e2e-routes.test.ts b/backend/src/test/e2e-routes.test.ts index d25bfc0..1e03d1e 100644 --- a/backend/src/test/e2e-routes.test.ts +++ b/backend/src/test/e2e-routes.test.ts @@ -107,6 +107,10 @@ async function createSchema(client: Client) { shoutrrr_intake_reminders integer NOT NULL DEFAULT 1, reminder_days_before integer NOT NULL DEFAULT 7, repeat_daily_reminders integer NOT NULL DEFAULT 0, + skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0, + repeat_reminders_enabled integer NOT NULL DEFAULT 0, + reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30, + max_nagging_reminders integer NOT NULL DEFAULT 5, low_stock_days integer NOT NULL DEFAULT 30, normal_stock_days integer NOT NULL DEFAULT 90, high_stock_days integer NOT NULL DEFAULT 180, @@ -556,6 +560,9 @@ describe("E2E Tests with Real Routes", () => { url: "/settings", }); + if (response.statusCode !== 200) { + console.error("GET /settings error:", response.body); + } expect(response.statusCode).toBe(200); const data = response.json(); // Check default values @@ -720,7 +727,10 @@ describe("E2E Tests with Real Routes", () => { }); expect(response.statusCode).toBe(200); - expect(response.json()).toEqual({ status: "ok" }); + const json = response.json(); + expect(json.status).toBe("ok"); + expect(typeof json.smtpConfigured).toBe("boolean"); + expect(typeof json.shoutrrrConfigured).toBe("boolean"); }); }); @@ -1138,7 +1148,10 @@ describe("E2E Tests with Real Routes", () => { }); expect(response.statusCode).toBe(200); - expect(response.json()).toEqual({ status: "ok" }); + const json = response.json(); + expect(json.status).toBe("ok"); + expect(typeof json.smtpConfigured).toBe("boolean"); + expect(typeof json.shoutrrrConfigured).toBe("boolean"); }); }); diff --git a/backend/src/test/integration.test.ts b/backend/src/test/integration.test.ts index d564f70..59877ff 100644 --- a/backend/src/test/integration.test.ts +++ b/backend/src/test/integration.test.ts @@ -104,6 +104,10 @@ async function createSchema(client: Client) { shoutrrr_intake_reminders integer NOT NULL DEFAULT 1, reminder_days_before integer NOT NULL DEFAULT 7, repeat_daily_reminders integer NOT NULL DEFAULT 0, + skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0, + repeat_reminders_enabled integer NOT NULL DEFAULT 0, + reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30, + max_nagging_reminders integer NOT NULL DEFAULT 5, low_stock_days integer NOT NULL DEFAULT 30, normal_stock_days integer NOT NULL DEFAULT 90, high_stock_days integer NOT NULL DEFAULT 180, diff --git a/backend/src/test/planner.test.ts b/backend/src/test/planner.test.ts index a07f5d8..4173f0f 100644 --- a/backend/src/test/planner.test.ts +++ b/backend/src/test/planner.test.ts @@ -94,6 +94,10 @@ async function createSchema(client: Client) { shoutrrr_intake_reminders integer NOT NULL DEFAULT 1, reminder_days_before integer NOT NULL DEFAULT 7, repeat_daily_reminders integer NOT NULL DEFAULT 0, + skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0, + repeat_reminders_enabled integer NOT NULL DEFAULT 0, + reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30, + max_nagging_reminders integer NOT NULL DEFAULT 5, low_stock_days integer NOT NULL DEFAULT 30, normal_stock_days integer NOT NULL DEFAULT 90, high_stock_days integer NOT NULL DEFAULT 180, diff --git a/backend/src/test/services.test.ts b/backend/src/test/services.test.ts index 09174e8..a51053a 100644 --- a/backend/src/test/services.test.ts +++ b/backend/src/test/services.test.ts @@ -16,6 +16,7 @@ import { calculateDailyUsage, calculateDepletionInfo, getUpcomingIntakes, + getTodaysIntakes, createDefaultReminderState, createDefaultIntakeReminderState, parseReminderState, @@ -381,6 +382,94 @@ describe("Scheduler Utils - Upcoming Intakes", () => { expect(result.length).toBeGreaterThanOrEqual(1); }); }); + + describe("getTodaysIntakes", () => { + it("should return all intakes for today", () => { + // Daily medication at 08:00 starting yesterday + const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }]; + + // Get intakes for 2025-01-02 (today's intake should be at 08:00) + const result = getTodaysIntakes("TestMed", blisters, [], null, "en-US", "UTC"); + + expect(result.length).toBeGreaterThanOrEqual(1); + const intake = result.find(i => i.intakeTime.getUTCHours() === 8); + expect(intake).toBeDefined(); + expect(intake?.medName).toBe("TestMed"); + expect(intake?.usage).toBe(1); + }); + + it("should include past intakes from today", () => { + // Medication at 00:01 today (definitely in the past) + const todayMidnight = new Date(); + todayMidnight.setUTCHours(0, 1, 0, 0); + + const blisters: Blister[] = [{ + usage: 2, + every: 1, + start: todayMidnight.toISOString() + }]; + + const result = getTodaysIntakes("PastMed", blisters, ["Bob"], 250, "en-US", "UTC"); + + expect(result).toHaveLength(1); + expect(result[0].medName).toBe("PastMed"); + expect(result[0].usage).toBe(2); + expect(result[0].takenBy).toEqual(["Bob"]); + expect(result[0].pillWeightMg).toBe(250); + }); + + it("should handle multiple intakes per day", () => { + // Two intakes today: morning and evening + const today = new Date(); + const morning = new Date(today); + morning.setUTCHours(8, 0, 0, 0); + const evening = new Date(today); + evening.setUTCHours(20, 0, 0, 0); + + const blisters: Blister[] = [ + { usage: 1, every: 1, start: morning.toISOString() }, + { usage: 1, every: 1, start: evening.toISOString() }, + ]; + + const result = getTodaysIntakes("MultiMed", blisters, [], null, "en-US", "UTC"); + + expect(result.length).toBeGreaterThanOrEqual(2); + }); + + it("should not include intakes from other days", () => { + // Weekly medication on a different day of week + const lastWeek = new Date(); + lastWeek.setDate(lastWeek.getDate() - 7); + + const blisters: Blister[] = [{ + usage: 1, + every: 7, + start: lastWeek.toISOString() + }]; + + // If today is not the same day of week, should return empty + const result = getTodaysIntakes("WeeklyMed", blisters, [], null, "en-US", "UTC"); + + // This test might return 0 or 1 depending on the day + expect(Array.isArray(result)).toBe(true); + }); + + it("should handle timezone correctly", () => { + // 23:00 in Europe/Berlin on a specific date + const blisters: Blister[] = [{ + usage: 1, + every: 1, + start: "2025-01-01T22:00:00.000Z" // 23:00 Berlin time + }]; + + const result = getTodaysIntakes("TzMed", blisters, [], null, "de-DE", "Europe/Berlin"); + + expect(Array.isArray(result)).toBe(true); + if (result.length > 0) { + expect(result[0].intakeTimeStr).toContain("23:"); + } + }); + }); }); describe("Scheduler Utils - State Management", () => { @@ -399,7 +488,7 @@ describe("Scheduler Utils - State Management", () => { describe("createDefaultIntakeReminderState", () => { it("should create default intake reminder state", () => { const state = createDefaultIntakeReminderState(); - expect(state.sentReminders).toEqual([]); + expect(state.reminders).toEqual({}); }); }); @@ -439,62 +528,91 @@ describe("Scheduler Utils - State Management", () => { }); describe("parseIntakeReminderState", () => { - it("should parse valid JSON", () => { + it("should parse valid new format JSON", () => { + const json = JSON.stringify({ + reminders: { + "med1:123": { firstSentAt: 1000, lastSentAt: 2000, sendCount: 2 }, + "med2:456": { firstSentAt: 3000, lastSentAt: 3000, sendCount: 1 } + } + }); + + const state = parseIntakeReminderState(json); + expect(Object.keys(state.reminders)).toHaveLength(2); + expect(state.reminders["med1:123"].sendCount).toBe(2); + }); + + it("should convert old array format to new format", () => { const json = JSON.stringify({ sentReminders: ["med1:123", "med2:456"] }); const state = parseIntakeReminderState(json); - expect(state.sentReminders).toEqual(["med1:123", "med2:456"]); + expect(Object.keys(state.reminders)).toHaveLength(2); + expect(state.reminders["med1:123"]).toBeDefined(); + expect(state.reminders["med1:123"].sendCount).toBe(1); }); it("should return defaults for invalid JSON", () => { const state = parseIntakeReminderState("invalid"); - expect(state.sentReminders).toEqual([]); + expect(state.reminders).toEqual({}); }); - it("should handle missing sentReminders", () => { + it("should handle missing reminders field", () => { const state = parseIntakeReminderState("{}"); - expect(state.sentReminders).toEqual([]); + expect(state.reminders).toEqual({}); }); }); describe("cleanOldIntakeReminders", () => { - it("should remove entries older than maxAgeMs", () => { - const now = Date.now(); - const oldTimestamp = now - 25 * 60 * 60 * 1000; // 25 hours ago - const recentTimestamp = now - 1 * 60 * 60 * 1000; // 1 hour ago + it("should remove entries from past days (timezone-aware)", () => { + const tz = "Europe/Berlin"; + const now = new Date(); + const today = new Date(now.toLocaleString("en-US", { timeZone: tz })); + today.setHours(12, 0, 0, 0); - const reminders = [ - `med1:${oldTimestamp}`, - `med2:${recentTimestamp}`, - ]; + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); - const cleaned = cleanOldIntakeReminders(reminders, 24 * 60 * 60 * 1000); + const reminders = { + [`med1:${yesterday.getTime()}`]: { firstSentAt: yesterday.getTime(), lastSentAt: yesterday.getTime(), sendCount: 1 }, + [`med2:${today.getTime()}`]: { firstSentAt: today.getTime(), lastSentAt: today.getTime(), sendCount: 1 }, + }; - expect(cleaned).toHaveLength(1); - expect(cleaned[0]).toContain("med2"); + const cleaned = cleanOldIntakeReminders(reminders, tz); + + expect(Object.keys(cleaned)).toHaveLength(1); + expect(cleaned[`med2:${today.getTime()}`]).toBeDefined(); }); - it("should keep all entries if none are old", () => { - const now = Date.now(); - const reminders = [ - `med1:${now - 1000}`, - `med2:${now - 2000}`, - ]; + it("should keep all entries from today", () => { + const tz = "Europe/Berlin"; + const now = new Date(); + const morning = new Date(now.toLocaleString("en-US", { timeZone: tz })); + morning.setHours(8, 0, 0, 0); - const cleaned = cleanOldIntakeReminders(reminders); - expect(cleaned).toHaveLength(2); + const evening = new Date(now.toLocaleString("en-US", { timeZone: tz })); + evening.setHours(20, 0, 0, 0); + + const reminders = { + [`med1:${morning.getTime()}`]: { firstSentAt: morning.getTime(), lastSentAt: morning.getTime(), sendCount: 1 }, + [`med2:${evening.getTime()}`]: { firstSentAt: evening.getTime(), lastSentAt: evening.getTime(), sendCount: 1 }, + }; + + const cleaned = cleanOldIntakeReminders(reminders, tz); + expect(Object.keys(cleaned)).toHaveLength(2); }); - it("should handle empty array", () => { - const cleaned = cleanOldIntakeReminders([]); - expect(cleaned).toEqual([]); + it("should handle empty reminders", () => { + const cleaned = cleanOldIntakeReminders({}, "Europe/Berlin"); + expect(cleaned).toEqual({}); }); - it("should handle malformed entries (invalid timestamp)", () => { - const reminders = ["med1:invalid", "med2:notanumber"]; - const cleaned = cleanOldIntakeReminders(reminders); - // NaN from parseInt will cause these to be filtered out (0 < cutoff) - expect(cleaned).toEqual([]); + it("should handle malformed entries (invalid timestamp in key)", () => { + const reminders = { + "med1:invalid": { firstSentAt: 1000, lastSentAt: 1000, sendCount: 1 }, + "med2:notanumber": { firstSentAt: 2000, lastSentAt: 2000, sendCount: 1 } + }; + const cleaned = cleanOldIntakeReminders(reminders, "Europe/Berlin"); + // NaN from parseInt will cause these to be filtered out (invalid < todayStart) + expect(Object.keys(cleaned)).toHaveLength(0); }); }); }); diff --git a/backend/src/test/settings.test.ts b/backend/src/test/settings.test.ts index 17fcb7b..d38d790 100644 --- a/backend/src/test/settings.test.ts +++ b/backend/src/test/settings.test.ts @@ -41,6 +41,10 @@ async function registerSettingsRoutes(ctx: TestContext) { shoutrrrIntakeReminders: true, reminderDaysBefore: 7, repeatDailyReminders: false, + skipRemindersForTakenDoses: false, + repeatRemindersEnabled: false, + reminderRepeatIntervalMinutes: 30, + maxNaggingReminders: 5, lowStockDays: 30, normalStockDays: 90, highStockDays: 180, @@ -62,6 +66,10 @@ async function registerSettingsRoutes(ctx: TestContext) { shoutrrrIntakeReminders: Boolean(s.shoutrrr_intake_reminders), reminderDaysBefore: s.reminder_days_before, repeatDailyReminders: Boolean(s.repeat_daily_reminders), + skipRemindersForTakenDoses: Boolean(s.skip_reminders_for_taken_doses ?? false), + repeatRemindersEnabled: Boolean(s.repeat_reminders_enabled ?? false), + reminderRepeatIntervalMinutes: s.reminder_repeat_interval_minutes ?? 30, + maxNaggingReminders: s.max_nagging_reminders ?? 5, lowStockDays: s.low_stock_days, normalStockDays: s.normal_stock_days, highStockDays: s.high_stock_days, @@ -84,6 +92,10 @@ async function registerSettingsRoutes(ctx: TestContext) { shoutrrrIntakeReminders?: boolean; reminderDaysBefore?: number; repeatDailyReminders?: boolean; + skipRemindersForTakenDoses?: boolean; + repeatRemindersEnabled?: boolean; + reminderRepeatIntervalMinutes?: number; + maxNaggingReminders?: number; lowStockDays?: number; normalStockDays?: number; highStockDays?: number; @@ -111,6 +123,12 @@ async function registerSettingsRoutes(ctx: TestContext) { if (body.stockCalculationMode && !["automatic", "manual"].includes(body.stockCalculationMode)) { return reply.status(400).send({ error: "stockCalculationMode must be 'automatic' or 'manual'" }); } + if (body.reminderRepeatIntervalMinutes !== undefined && (body.reminderRepeatIntervalMinutes < 5 || body.reminderRepeatIntervalMinutes > 480)) { + return reply.status(400).send({ error: "reminderRepeatIntervalMinutes must be between 5 and 480" }); + } + if (body.maxNaggingReminders !== undefined && (body.maxNaggingReminders < 1 || body.maxNaggingReminders > 20)) { + return reply.status(400).send({ error: "maxNaggingReminders must be between 1 and 20" }); + } // Check if settings exist const existing = await client.execute({ @@ -126,10 +144,11 @@ async function registerSettingsRoutes(ctx: TestContext) { email_stock_reminders, email_intake_reminders, shoutrrr_enabled, shoutrrr_url, shoutrrr_stock_reminders, shoutrrr_intake_reminders, - reminder_days_before, repeat_daily_reminders, + reminder_days_before, repeat_daily_reminders, skip_reminders_for_taken_doses, + repeat_reminders_enabled, reminder_repeat_interval_minutes, max_nagging_reminders, low_stock_days, normal_stock_days, high_stock_days, expiry_warning_days, language, stock_calculation_mode - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, args: [ userId, body.emailEnabled ? 1 : 0, @@ -142,6 +161,10 @@ async function registerSettingsRoutes(ctx: TestContext) { body.shoutrrrIntakeReminders !== false ? 1 : 0, body.reminderDaysBefore ?? 7, body.repeatDailyReminders ? 1 : 0, + body.skipRemindersForTakenDoses ? 1 : 0, + body.repeatRemindersEnabled ? 1 : 0, + body.reminderRepeatIntervalMinutes ?? 30, + body.maxNaggingReminders ?? 5, body.lowStockDays ?? 30, body.normalStockDays ?? 90, body.highStockDays ?? 180, @@ -164,6 +187,10 @@ async function registerSettingsRoutes(ctx: TestContext) { shoutrrr_intake_reminders = ?, reminder_days_before = ?, repeat_daily_reminders = ?, + skip_reminders_for_taken_doses = ?, + repeat_reminders_enabled = ?, + reminder_repeat_interval_minutes = ?, + max_nagging_reminders = ?, low_stock_days = ?, normal_stock_days = ?, high_stock_days = ?, @@ -183,6 +210,10 @@ async function registerSettingsRoutes(ctx: TestContext) { body.shoutrrrIntakeReminders !== false ? 1 : 0, body.reminderDaysBefore ?? 7, body.repeatDailyReminders ? 1 : 0, + body.skipRemindersForTakenDoses ? 1 : 0, + body.repeatRemindersEnabled ? 1 : 0, + body.reminderRepeatIntervalMinutes ?? 30, + body.maxNaggingReminders ?? 5, body.lowStockDays ?? 30, body.normalStockDays ?? 90, body.highStockDays ?? 180, @@ -507,4 +538,137 @@ describe("Settings API", () => { expect(getResponse.json().stockCalculationMode).toBe("automatic"); }); }); + + // --------------------------------------------------------------------------- + // Repeat Reminders & Skip Reminders Settings + // --------------------------------------------------------------------------- + + describe("Repeat Reminders Settings", () => { + it("should enable repeat reminders with interval", async () => { + const response = await ctx.app.inject({ + method: "PUT", + url: "/settings", + payload: { + repeatRemindersEnabled: true, + reminderRepeatIntervalMinutes: 10, + }, + }); + + expect(response.statusCode).toBe(200); + + const getResponse = await ctx.app.inject({ + method: "GET", + url: "/settings", + }); + + const settings = getResponse.json(); + expect(settings.repeatRemindersEnabled).toBe(true); + expect(settings.reminderRepeatIntervalMinutes).toBe(10); + }); + + it("should validate repeat interval range", async () => { + let response = await ctx.app.inject({ + method: "PUT", + url: "/settings", + payload: { + repeatRemindersEnabled: true, + reminderRepeatIntervalMinutes: 2, + }, + }); + expect(response.statusCode).toBe(400); + + response = await ctx.app.inject({ + method: "PUT", + url: "/settings", + payload: { + repeatRemindersEnabled: true, + reminderRepeatIntervalMinutes: 500, + }, + }); + expect(response.statusCode).toBe(400); + }); + + it("should validate max nagging reminders range", async () => { + let response = await ctx.app.inject({ + method: "PUT", + url: "/settings", + payload: { + maxNaggingReminders: 0, + }, + }); + expect(response.statusCode).toBe(400); + + response = await ctx.app.inject({ + method: "PUT", + url: "/settings", + payload: { + maxNaggingReminders: 25, + }, + }); + expect(response.statusCode).toBe(400); + + // Valid values should work + response = await ctx.app.inject({ + method: "PUT", + url: "/settings", + payload: { + maxNaggingReminders: 10, + }, + }); + expect(response.statusCode).toBe(200); + + const getResponse = await ctx.app.inject({ + method: "GET", + url: "/settings", + }); + + const settings = getResponse.json(); + expect(settings.maxNaggingReminders).toBe(10); + }); + }); + + describe("Skip Reminders for Taken Doses", () => { + it("should enable and disable skip reminders setting", async () => { + let response = await ctx.app.inject({ + method: "PUT", + url: "/settings", + payload: { + skipRemindersForTakenDoses: true, + }, + }); + expect(response.statusCode).toBe(200); + + response = await ctx.app.inject({ + method: "PUT", + url: "/settings", + payload: { + skipRemindersForTakenDoses: false, + }, + }); + expect(response.statusCode).toBe(200); + }); + + it("should work with repeat reminders enabled", async () => { + const response = await ctx.app.inject({ + method: "PUT", + url: "/settings", + payload: { + repeatRemindersEnabled: true, + reminderRepeatIntervalMinutes: 5, + skipRemindersForTakenDoses: true, + }, + }); + expect(response.statusCode).toBe(200); + + const getResponse = await ctx.app.inject({ + method: "GET", + url: "/settings", + }); + + const settings = getResponse.json(); + expect(settings.repeatRemindersEnabled).toBe(true); + expect(settings.reminderRepeatIntervalMinutes).toBe(5); + expect(settings.skipRemindersForTakenDoses).toBe(true); + }); + }); }); diff --git a/backend/src/utils/scheduler-utils.ts b/backend/src/utils/scheduler-utils.ts index f2bb0c8..b1c9ca9 100644 --- a/backend/src/utils/scheduler-utils.ts +++ b/backend/src/utils/scheduler-utils.ts @@ -188,6 +188,70 @@ export type UpcomingIntake = { pillWeightMg: number | null; }; +/** + * Get all intakes for today (past and future) - used for repeat reminders. + * Returns all intakes scheduled for today in user's timezone. + */ +export function getTodaysIntakes( + medName: string, + blisters: Blister[], + takenBy: string[], + pillWeightMg: number | null, + locale: string, + tz?: string +): UpcomingIntake[] { + const timezone = tz ?? getTimezone(); + const now = new Date(); + + // Get start and end of today in user's timezone + const todayStart = new Date(now.toLocaleString("en-US", { timeZone: timezone })); + todayStart.setHours(0, 0, 0, 0); + + const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: timezone })); + todayEnd.setHours(23, 59, 59, 999); + + const intakes: UpcomingIntake[] = []; + + for (const blister of blisters) { + const startTime = new Date(blister.start).getTime(); + const intervalMs = blister.every * 24 * 60 * 60 * 1000; + + if (intervalMs <= 0) continue; + + // Find all occurrences that fall within today + let currentTime = startTime; + + // If start is in the past, calculate the first occurrence on or after todayStart + if (currentTime < todayStart.getTime()) { + const elapsed = todayStart.getTime() - startTime; + const intervals = Math.floor(elapsed / intervalMs); + currentTime = startTime + intervals * intervalMs; + } + + // Collect all intakes for today + while (currentTime <= todayEnd.getTime()) { + if (currentTime >= todayStart.getTime()) { + const intakeDate = new Date(currentTime); + intakes.push({ + medName, + usage: blister.usage, + intakeTime: intakeDate, + intakeTimeStr: intakeDate.toLocaleTimeString(locale, { + hour: "2-digit", + minute: "2-digit", + timeZone: timezone + }), + takenBy, + pillWeightMg, + }); + } + currentTime += intervalMs; + } + } + + return intakes; +} + /** * Get upcoming intakes that fall within the reminder window. * Returns intakes that should be notified about right now. @@ -277,8 +341,14 @@ export type ReminderState = { lastNotificationChannel: "email" | "push" | "both" | null; }; +export type IntakeReminderEntry = { + firstSentAt: number; // Timestamp when first reminder was sent + lastSentAt: number; // Timestamp when last reminder was sent + sendCount: number; // How many times reminder was sent +}; + export type IntakeReminderState = { - sentReminders: string[]; + reminders: Record; // key -> entry }; /** Create default reminder state */ @@ -295,7 +365,7 @@ export function createDefaultReminderState(): ReminderState { /** Create default intake reminder state */ export function createDefaultIntakeReminderState(): IntakeReminderState { - return { sentReminders: [] }; + return { reminders: {} }; } /** Parse reminder state from JSON string */ @@ -315,12 +385,28 @@ export function parseReminderState(json: string): ReminderState { } } -/** Parse intake reminder state from JSON string */ +/** Parse intake reminder state from JSON string (backward compatible) */ export function parseIntakeReminderState(json: string): IntakeReminderState { try { const saved = JSON.parse(json); + + // Backward compatibility: convert old array format to new map format + if (Array.isArray(saved.sentReminders)) { + const reminders: Record = {}; + const now = Date.now(); + for (const key of saved.sentReminders) { + reminders[key] = { + firstSentAt: now, + lastSentAt: now, + sendCount: 1, + }; + } + return { reminders }; + } + + // New format return { - sentReminders: saved.sentReminders ?? [], + reminders: saved.reminders ?? {}, }; } catch { return createDefaultIntakeReminderState(); @@ -328,10 +414,21 @@ export function parseIntakeReminderState(json: string): IntakeReminderState { } /** Clean up old intake reminder entries (older than given milliseconds) */ -export function cleanOldIntakeReminders(sentReminders: string[], maxAgeMs: number = 24 * 60 * 60 * 1000): string[] { - const cutoff = Date.now() - maxAgeMs; - return sentReminders.filter(key => { +/** Clean up old intake reminder entries (using timezone-aware day check) */ +export function cleanOldIntakeReminders(reminders: Record, tz: string): Record { + // Get start of today in user's timezone + const now = new Date(); + const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz })); + todayStart.setHours(0, 0, 0, 0); + const todayStartMs = todayStart.getTime(); + + // Keep only reminders from today onwards (based on dose timestamp in key) + const cleaned: Record = {}; + for (const [key, entry] of Object.entries(reminders)) { const timestamp = parseInt(key.split(":").pop() || "0", 10); - return timestamp > cutoff; - }); + if (timestamp >= todayStartMs) { + cleaned[key] = entry; + } + } + return cleaned; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 73be405..b91a101 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -289,6 +289,10 @@ function AppContent() { notificationEmail: "", reminderDaysBefore: 7, repeatDailyReminders: false, + skipRemindersForTakenDoses: false, + repeatRemindersEnabled: false, + reminderRepeatIntervalMinutes: 30, + maxNaggingReminders: 5, lowStockDays: 30, normalStockDays: 90, highStockDays: 180, @@ -627,6 +631,10 @@ function AppContent() { notificationEmail: settings.notificationEmail, reminderDaysBefore: settings.reminderDaysBefore, repeatDailyReminders: settings.repeatDailyReminders, + skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses, + repeatRemindersEnabled: settings.repeatRemindersEnabled, + reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes, + maxNaggingReminders: settings.maxNaggingReminders ?? 5, lowStockDays: settings.lowStockDays, normalStockDays: settings.normalStockDays, highStockDays: settings.highStockDays, @@ -1927,7 +1935,7 @@ function AppContent() {

{t('settings.notifications.enableHint')}

)} + + {/* Skip reminders for taken doses */} +
+ + +
+ + {/* Repeat reminders for missed doses */} +
+ + +
+ + {/* Reminder interval (only shown when repeat is enabled) */} + {settings.repeatRemindersEnabled && ( + <> +
+ + setSettings({ ...settings, reminderRepeatIntervalMinutes: parseInt(e.target.value) || 30 })} + style={{width: "80px", textAlign: "center"}} + /> +
+
+ + setSettings({ ...settings, maxNaggingReminders: parseInt(e.target.value) || 5 })} + style={{width: "80px", textAlign: "center"}} + /> +
+ + )}

{t('settings.notifications.email')}

- diff --git a/frontend/src/components/Auth.tsx b/frontend/src/components/Auth.tsx index 789e650..acaeb01 100644 --- a/frontend/src/components/Auth.tsx +++ b/frontend/src/components/Auth.tsx @@ -421,7 +421,32 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () => {t("auth.register", "Create Account")} -
+ {/* SSO Login Button - also show on registration */} + {authState?.oidcEnabled && ( +
+ + {authState?.localAuthEnabled && ( +
+ {t("auth.or", "or")} +
+ )} +
+ )} + + {/* Local Registration Form - only show if local auth is enabled */} + {authState?.localAuthEnabled && ( + {error &&
{error}
}
@@ -471,6 +496,7 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () => {loading ? t("common.loading", "Loading...") : t("auth.register", "Create Account")} + )} {onSwitchToLogin && (
diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index aaeb9ac..9063f91 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -157,7 +157,15 @@ "push": "Push", "stockReminders": "Bestands-Erinnerungen", "intakeReminders": "Einnahme-Erinnerungen", - "enableHint": "Aktivieren Sie mindestens einen Kanal, um Benachrichtigungen zu erhalten." + "enableHint": "Aktivieren Sie mindestens einen Kanal, um Benachrichtigungen zu erhalten.", + "skipTakenDoses": "Keine Erinnerungen für genommene Dosen", + "skipTakenDosesTooltip": "Sende keine Einnahme-Erinnerungen für Dosen, die heute bereits als genommen markiert wurden", + "repeatReminders": "Wiederholte Erinnerungen für verpasste Dosen", + "repeatRemindersTooltip": "Sende automatisch wiederholte Erinnerungen für Dosen, die noch nicht als genommen markiert wurden", + "reminderInterval": "Erinnerungsintervall (Minuten)", + "reminderIntervalTooltip": "Wie oft wiederholte Erinnerungen für verpasste Dosen gesendet werden sollen", + "maxNaggingReminders": "Max. Erinnerungen pro Dosis", + "maxNaggingRemindersTooltip": "Wiederholungserinnerungen nach dieser Anzahl Versuchen stoppen (1-20)" }, "email": { "recipient": "Empfänger", diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 5f193b7..40daa43 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -159,7 +159,15 @@ "push": "Push", "stockReminders": "Stock Reminders", "intakeReminders": "Intake Reminders", - "enableHint": "Enable at least one channel below to receive notifications." + "enableHint": "Enable at least one channel below to receive notifications.", + "skipTakenDoses": "Skip reminders for taken doses", + "skipTakenDosesTooltip": "Don't send intake reminders for doses that have already been marked as taken today", + "repeatReminders": "Repeat reminders for missed doses", + "repeatRemindersTooltip": "Automatically send repeated reminders for doses that haven't been marked as taken", + "reminderInterval": "Reminder interval (minutes)", + "reminderIntervalTooltip": "How often to send repeated reminders for missed doses", + "maxNaggingReminders": "Max reminders per dose", + "maxNaggingRemindersTooltip": "Stop sending repeat reminders after this many attempts (1-20)" }, "email": { "recipient": "Recipient",