Compare commits

..

3 Commits

Author SHA1 Message Date
Daniel Volz a2905bb8c7 chore: release v1.1.0 2026-01-10 21:08:37 +01:00
Daniel Volz d0a40bde88 feat: Nagging reminders with max limit + ENV defaults for settings (#18)
* ci: prevent duplicate test runs - tests only on PRs, inline tests for builds

* docs: add testing and CI/CD documentation

* security: fix CodeQL vulnerabilities (SSRF, XSS, rate limiting)

- Add URL validation to prevent SSRF attacks on notification endpoints
  - Block private IPs (10.x, 172.16-31.x, 192.168.x, 169.254.x)
  - Block localhost and internal hostnames
  - Only allow HTTP/HTTPS protocols
- Add HTML escaping for medication names in email templates (XSS)
- Add stricter rate limiting for auth routes (5 req/15min for login/register)
- Add SSRF protection tests (405 tests total)

* security: add rate limiting to remaining auth routes

* chore: add CodeQL config to suppress rate-limit false positives

Rate limiting IS implemented via @fastify/rate-limit plugin:
- Global: 100 req/min (index.ts)
- Auth routes: 5-10 req/min via config.rateLimit option

CodeQL doesn't recognize Fastify's plugin-based rate limiting pattern.

* ci: switch to CodeQL Advanced Setup

- Add custom codeql.yml workflow
- Configure to use codeql-config.yml
- Exclude js/missing-rate-limiting rule (false positive)
  Rate limiting is implemented via @fastify/rate-limit plugin

* ci: add explicit permissions to workflows

Fixes CodeQL 'Workflow does not contain permissions' warnings.
Sets minimal 'contents: read' at top level.

* ci: add manual trigger to CodeQL workflow

* ci: add explicit permissions to all workflow jobs

* build(deps): bump esbuild, @vitest/coverage-v8 and vitest in /backend

Bumps [esbuild](https://github.com/evanw/esbuild) to 0.27.2 and updates ancestor dependencies [esbuild](https://github.com/evanw/esbuild), [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest). These dependencies need to be updated together.


Updates `esbuild` from 0.21.5 to 0.27.2
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2024.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.21.5...v0.27.2)

Updates `@vitest/coverage-v8` from 2.1.9 to 4.0.16
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.0.16/packages/coverage-v8)

Updates `vitest` from 2.1.9 to 4.0.16
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.0.16/packages/vitest)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version: 0.27.2
  dependency-type: indirect
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.0.16
  dependency-type: direct:development
- dependency-name: vitest
  dependency-version: 4.0.16
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

* docs: add GitHub issue templates

- Bug report template with deployment type, browser info, logs
- Feature request template with affected area, priority
- Config with link to discussions and README
- Optimize test.yml to skip tests for non-code changes

* Initial plan

* Remove database schema duplication by creating shared schema-sql.ts module

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Refactor frontend date formatting to eliminate duplication

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* docs: Add branch protection warning and PR workflow to instructions

* ci: remove paths filter from test workflow to fix branch protection

* fix: add .js extension to schema-sql imports for ESM compatibility (#15)

* feat: add setting to skip reminders for taken doses

- Add skipRemindersForTakenDoses setting to database schema
- Extend settings API to save and load new setting
- Update intake reminder scheduler to filter taken doses
- Add frontend toggle in settings with i18n (EN/DE)
- Only check doses from today (timezone-aware)
- Update all test schemas with new field
- All 405 tests passing

* feat: add repeat reminders for missed doses

- Add repeatRemindersEnabled and reminderRepeatIntervalMinutes settings
- Refactor intake reminder state from array to object with sendCount tracking
- Update scheduler to send repeated reminders at configurable intervals
- Only remind for today's doses (timezone-aware filtering)
- Add frontend toggle and interval input (5-480 minutes) in settings
- Maintain backward compatibility for old state file format
- Update all test schemas and assertions
- All 406 tests passing

* feat: add nagging reminders with max limit and ENV defaults

- Add maxNaggingReminders setting to limit repeat reminders (1-20)
- Add ENV defaults for all user settings (DEFAULT_*)
- Add ALTER TABLE migrations for backward compatibility
- Add smtpConfigured/shoutrrrConfigured to health endpoint
- Fix Push toggle to allow enabling without existing URL
- Disable skip/repeat toggles when no notifications enabled
- Add Pocket ID button to registration page
- Add getTodaysIntakes() for repeat reminder logic
- Update translations (en/de) for new settings
- Add comprehensive tests for new features

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>
2026-01-10 21:05:44 +01:00
dependabot[bot] e754729e08 build(deps): bump react-router and react-router-dom in /frontend (#17)
Bumps [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) to 7.12.0 and updates ancestor dependency [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom). These dependencies need to be updated together.


Updates `react-router` from 7.11.0 to 7.12.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.12.0/packages/react-router)

Updates `react-router-dom` from 7.11.0 to 7.12.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.12.0/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router
  dependency-version: 7.12.0
  dependency-type: indirect
- dependency-name: react-router-dom
  dependency-version: 7.12.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-09 07:58:44 +01:00
21 changed files with 1031 additions and 136 deletions
+41 -1
View File
@@ -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
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
+74 -10
View File
@@ -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 <wert>` 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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "medassist-ng-backend",
"version": "1.0.2",
"version": "1.1.0",
"private": true,
"type": "module",
"scripts": {
+21
View File
@@ -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 };
}
+4
View File
@@ -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,
+4
View File
@@ -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),
+5 -1
View File
@@ -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),
}));
}
+68 -23
View File
@@ -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<UserSettings> {
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<UserSettings[]> {
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,
+209 -31
View File
@@ -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 = `
<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;">${tr.intakeReminder.title}</h2>
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">${t(tr.intakeReminder.description, { minutes: REMINDER_MINUTES_BEFORE })}</p>
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">${description}</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;">
@@ -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<void> {
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";
+15 -2
View File
@@ -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");
});
});
+4
View File
@@ -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,
+4
View File
@@ -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,
+151 -33
View File
@@ -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);
});
});
});
+166 -2
View File
@@ -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);
});
});
});
+106 -9
View File
@@ -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<string, IntakeReminderEntry>; // 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<string, IntakeReminderEntry> = {};
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<string, IntakeReminderEntry>, tz: string): Record<string, IntakeReminderEntry> {
// 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<string, IntakeReminderEntry> = {};
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;
}
+10 -10
View File
@@ -1,19 +1,19 @@
{
"name": "medassist-ng-frontend",
"version": "1.0.1",
"version": "1.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "medassist-ng-frontend",
"version": "1.0.1",
"version": "1.0.2",
"dependencies": {
"i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^15.4.1",
"react-router-dom": "^7.11.0",
"react-router-dom": "^7.12.0",
"zod": "^3.23.8"
},
"devDependencies": {
@@ -1713,9 +1713,9 @@
}
},
"node_modules/react-router": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz",
"integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==",
"version": "7.12.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
"integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -1735,12 +1735,12 @@
}
},
"node_modules/react-router-dom": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz",
"integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==",
"version": "7.12.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz",
"integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==",
"license": "MIT",
"dependencies": {
"react-router": "7.11.0"
"react-router": "7.12.0"
},
"engines": {
"node": ">=20.0.0"
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "medassist-ng-frontend",
"private": true,
"version": "1.0.2",
"version": "1.1.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -15,7 +15,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^15.4.1",
"react-router-dom": "^7.11.0",
"react-router-dom": "^7.12.0",
"zod": "^3.23.8"
},
"devDependencies": {
+101 -8
View File
@@ -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() {
<label className={`toggle-switch small${!settings.emailEnabled ? ' disabled' : ''}`}>
<input
type="checkbox"
checked={settings.emailStockReminders}
checked={settings.smtpHost && settings.emailEnabled ? settings.emailStockReminders : false}
onChange={(e) => setSettings({ ...settings, emailStockReminders: e.target.checked })}
disabled={!settings.emailEnabled}
/>
@@ -1938,7 +1946,7 @@ function AppContent() {
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? ' disabled' : ''}`}>
<input
type="checkbox"
checked={settings.shoutrrrStockReminders}
checked={settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrStockReminders : false}
onChange={(e) => setSettings({ ...settings, shoutrrrStockReminders: e.target.checked })}
disabled={!settings.shoutrrrEnabled}
/>
@@ -1952,7 +1960,7 @@ function AppContent() {
<label className={`toggle-switch small${!settings.emailEnabled ? ' disabled' : ''}`}>
<input
type="checkbox"
checked={settings.emailIntakeReminders}
checked={settings.smtpHost && settings.emailEnabled ? settings.emailIntakeReminders : false}
onChange={(e) => setSettings({ ...settings, emailIntakeReminders: e.target.checked })}
disabled={!settings.emailEnabled}
/>
@@ -1963,7 +1971,7 @@ function AppContent() {
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? ' disabled' : ''}`}>
<input
type="checkbox"
checked={settings.shoutrrrIntakeReminders}
checked={settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrIntakeReminders : false}
onChange={(e) => setSettings({ ...settings, shoutrrrIntakeReminders: e.target.checked })}
disabled={!settings.shoutrrrEnabled}
/>
@@ -1975,16 +1983,94 @@ function AppContent() {
{!settings.emailEnabled && !settings.shoutrrrEnabled && (
<p className="hint-text">{t('settings.notifications.enableHint')}</p>
)}
{/* Skip reminders for taken doses */}
<div className="setting-row compact" style={{marginTop: "16px", paddingTop: "16px", borderTop: "1px solid var(--border-color)"}}>
<label className="setting-label">
{t('settings.notifications.skipTakenDoses')}
<span className="info-tooltip small" data-tooltip={t('settings.notifications.skipTakenDosesTooltip')}></span>
</label>
<label className={`toggle-switch small${!settings.emailEnabled && !settings.shoutrrrEnabled ? ' disabled' : ''}`}>
<input
type="checkbox"
checked={settings.skipRemindersForTakenDoses}
onChange={(e) => setSettings({ ...settings, skipRemindersForTakenDoses: e.target.checked })}
disabled={!settings.emailEnabled && !settings.shoutrrrEnabled}
/>
<span className="toggle-slider"></span>
</label>
</div>
{/* Repeat reminders for missed doses */}
<div className="setting-row compact" style={{marginTop: "12px"}}>
<label className="setting-label">
{t('settings.notifications.repeatReminders')}
<span className="info-tooltip small" data-tooltip={t('settings.notifications.repeatRemindersTooltip')}></span>
</label>
<label className={`toggle-switch small${!settings.emailEnabled && !settings.shoutrrrEnabled ? ' disabled' : ''}`}>
<input
type="checkbox"
checked={settings.repeatRemindersEnabled}
onChange={(e) => setSettings({ ...settings, repeatRemindersEnabled: e.target.checked })}
disabled={!settings.emailEnabled && !settings.shoutrrrEnabled}
/>
<span className="toggle-slider"></span>
</label>
</div>
{/* Reminder interval (only shown when repeat is enabled) */}
{settings.repeatRemindersEnabled && (
<>
<div className="setting-row compact" style={{marginTop: "12px", marginLeft: "24px"}}>
<label className="setting-label">
{t('settings.notifications.reminderInterval')}
<span className="info-tooltip small" data-tooltip={t('settings.notifications.reminderIntervalTooltip')}></span>
</label>
<input
type="number"
min="5"
max="480"
step="5"
value={settings.reminderRepeatIntervalMinutes}
onChange={(e) => setSettings({ ...settings, reminderRepeatIntervalMinutes: parseInt(e.target.value) || 30 })}
style={{width: "80px", textAlign: "center"}}
/>
</div>
<div className="setting-row compact" style={{marginTop: "8px", marginLeft: "24px"}}>
<label className="setting-label">
{t('settings.notifications.maxNaggingReminders')}
<span className="info-tooltip small" data-tooltip={t('settings.notifications.maxNaggingRemindersTooltip')}></span>
</label>
<input
type="number"
min="1"
max="20"
step="1"
value={settings.maxNaggingReminders ?? 5}
onChange={(e) => setSettings({ ...settings, maxNaggingReminders: parseInt(e.target.value) || 5 })}
style={{width: "80px", textAlign: "center"}}
/>
</div>
</>
)}
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t('settings.notifications.email')}</h3>
<label className="toggle-switch small">
<label className={`toggle-switch small${!settings.smtpHost ? ' disabled' : ''}`}>
<input
type="checkbox"
checked={settings.emailEnabled}
onChange={(e) => setSettings({ ...settings, emailEnabled: e.target.checked })}
checked={settings.smtpHost ? settings.emailEnabled : false}
onChange={(e) => {
const newVal = e.target.checked;
if (!newVal && !settings.shoutrrrEnabled) {
setSettings({ ...settings, emailEnabled: false, emailStockReminders: false, emailIntakeReminders: false, skipRemindersForTakenDoses: false, repeatRemindersEnabled: false });
} else {
setSettings({ ...settings, emailEnabled: newVal });
}
}}
disabled={!settings.smtpHost}
/>
<span className="toggle-slider"></span>
</label>
@@ -2028,7 +2114,14 @@ function AppContent() {
<input
type="checkbox"
checked={settings.shoutrrrEnabled}
onChange={(e) => setSettings({ ...settings, shoutrrrEnabled: e.target.checked })}
onChange={(e) => {
const newVal = e.target.checked;
if (!newVal && !settings.emailEnabled) {
setSettings({ ...settings, shoutrrrEnabled: false, shoutrrrStockReminders: false, shoutrrrIntakeReminders: false, skipRemindersForTakenDoses: false, repeatRemindersEnabled: false });
} else {
setSettings({ ...settings, shoutrrrEnabled: newVal });
}
}}
/>
<span className="toggle-slider"></span>
</label>
+27 -1
View File
@@ -421,7 +421,32 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
{t("auth.register", "Create Account")}
</h2>
<form onSubmit={handleSubmit} className="auth-form">
{/* SSO Login Button - also show on registration */}
{authState?.oidcEnabled && (
<div className="auth-sso">
<button
type="button"
className="btn btn-secondary auth-submit sso-btn"
onClick={() => window.location.href = "/api/auth/oidc/login"}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="sso-icon">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>
<polyline points="10 17 15 12 10 7"/>
<line x1="15" y1="12" x2="3" y2="12"/>
</svg>
{t("auth.loginWithSSO", "Login with {{provider}}", { provider: authState.oidcProviderName || "SSO" })}
</button>
{authState?.localAuthEnabled && (
<div className="auth-divider">
<span>{t("auth.or", "or")}</span>
</div>
)}
</div>
)}
{/* Local Registration Form - only show if local auth is enabled */}
{authState?.localAuthEnabled && (
<form onSubmit={handleSubmit} className="auth-form">
{error && <div className="auth-error">{error}</div>}
<div className="form-group">
@@ -471,6 +496,7 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
{loading ? t("common.loading", "Loading...") : t("auth.register", "Create Account")}
</button>
</form>
)}
{onSwitchToLogin && (
<div className="auth-links">
+9 -1
View File
@@ -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",
+9 -1
View File
@@ -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",