Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2905bb8c7 | |||
| d0a40bde88 | |||
| e754729e08 |
+41
-1
@@ -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
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.0.2",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Generated
+10
-10
@@ -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"
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user