From 401228699f0aaf6552bd6ae247fa0fc8220a5793 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Fri, 10 Apr 2026 21:08:16 +0200 Subject: [PATCH] Add searchable timezone settings override for reminder scheduling --- .env.example | 3 +- README.md | 4 +- .../0014_add_user_settings_timezone.sql | 1 + backend/drizzle/meta/_journal.json | 7 ++ backend/src/db/migration-utils.ts | 1 + backend/src/db/schema-sql.ts | 1 + backend/src/db/schema.ts | 1 + backend/src/routes/settings.ts | 9 +++ .../src/services/intake-reminder-scheduler.ts | 3 +- backend/src/services/reminder-scheduler.ts | 4 +- backend/src/services/settings-service.ts | 31 +++++++++ backend/src/utils/scheduler-utils.ts | 66 ++++++++++++++++--- frontend/src/hooks/useSettings.ts | 7 ++ frontend/src/i18n/de.json | 6 ++ frontend/src/i18n/en.json | 6 ++ frontend/src/pages/SettingsPage.tsx | 46 +++++++++++++ 16 files changed, 183 insertions(+), 13 deletions(-) create mode 100644 backend/drizzle/0014_add_user_settings_timezone.sql diff --git a/.env.example b/.env.example index ab19a01..68c8b4d 100644 --- a/.env.example +++ b/.env.example @@ -37,7 +37,8 @@ LOG_LEVEL=warn # production: leave unset, or set OPENAPI_DOCS_ENABLED=false # OPENAPI_DOCS_ENABLED=true -# Timezone for scheduled reminders (e.g., Europe/Berlin, America/New_York) +# Server default timezone for scheduled reminders (e.g., Europe/Berlin, America/New_York). +# Users can override this per account in Settings -> Timezone. TZ=Europe/Berlin # ============================================================================= diff --git a/README.md b/README.md index 1bfaa86..cfce8c7 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ All configuration is done via environment variables in `.env`. Copy `.env.exampl | `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS | | `LOG_LEVEL` | `info` | Log verbosity (`debug`, `info`, `warn`, `error`, `silent`). At `info` (default), high-frequency polling endpoints are suppressed. Set `debug` to see all requests. | | `OPENAPI_DOCS_ENABLED` | `auto` | Enables API docs in non-production by default. Set explicitly to `true`/`false` to override. | -| `TZ` | `Europe/Berlin` | Timezone for scheduled reminders | +| `TZ` | `Europe/Berlin` | Server default timezone for scheduled reminders (can be overridden per user in Settings) | Recommended values for API docs by environment: @@ -305,6 +305,8 @@ API reference: | `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder | | `EXPIRY_WARNING_DAYS` | `30` | Days before expiry to show warning | +Intake reminder timing uses IANA timezones. The server uses `TZ` as default, and each user can set an override in Settings. If no user timezone is set, reminders continue using the server default. + ### Push Notifications (Shoutrrr) MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notifications, supporting many services with a single URL format. diff --git a/backend/drizzle/0014_add_user_settings_timezone.sql b/backend/drizzle/0014_add_user_settings_timezone.sql new file mode 100644 index 0000000..aa94984 --- /dev/null +++ b/backend/drizzle/0014_add_user_settings_timezone.sql @@ -0,0 +1 @@ +ALTER TABLE `user_settings` ADD `timezone` text DEFAULT '' NOT NULL; \ No newline at end of file diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index 9b2cefe..58499b8 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1773348659979, "tag": "0013_add_share_medication_overview", "breakpoints": true + }, + { + "idx": 14, + "version": "6", + "when": 1775849300000, + "tag": "0014_add_user_settings_timezone", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/src/db/migration-utils.ts b/backend/src/db/migration-utils.ts index 93988a0..601ed8f 100644 --- a/backend/src/db/migration-utils.ts +++ b/backend/src/db/migration-utils.ts @@ -33,6 +33,7 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo `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`, + `ALTER TABLE user_settings ADD COLUMN timezone text NOT NULL DEFAULT ''`, `ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`, `ALTER TABLE dose_tracking ADD COLUMN taken_source text NOT NULL DEFAULT 'manual'`, `ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`, diff --git a/backend/src/db/schema-sql.ts b/backend/src/db/schema-sql.ts index 963927b..a0fdcfe 100644 --- a/backend/src/db/schema-sql.ts +++ b/backend/src/db/schema-sql.ts @@ -64,6 +64,7 @@ export function getTableCreationSQL(): string[] { high_stock_days integer NOT NULL DEFAULT 180, expiry_warning_days integer NOT NULL DEFAULT 90, language text NOT NULL DEFAULT 'en', + timezone text NOT NULL DEFAULT '', stock_calculation_mode text NOT NULL DEFAULT 'automatic', share_stock_status integer NOT NULL DEFAULT 1, upcoming_today_only integer NOT NULL DEFAULT 0, diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index f4310aa..99dba58 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -105,6 +105,7 @@ export const userSettings = sqliteTable("user_settings", { expiryWarningDays: integer("expiry_warning_days").notNull().default(90), // UI preferences language: text("language", { length: 10 }).notNull().default("en"), + timezone: text("timezone", { length: 64 }).notNull().default(""), // Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses) stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"), // Whether shared schedule links show stock status (Critical/Low/Normal) to intake users diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 4fe2cac..167b523 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -8,9 +8,11 @@ import { getSmtpConfig, sendEmailNotification } from "../services/notifications/ import { classifyTestEmailFailure, getAllUserSettingsFromDb, + getAvailableTimezones, getDefaultSettings, getNotificationProvider, loadUserSettingsFromDb, + normalizeSettingsTimezone, sanitizeNotificationUrl, type UserSettings, validateNotificationHostname, @@ -20,6 +22,7 @@ import type { AuthUser } from "../types/fastify.js"; export type { UserSettings } from "../services/settings-service.js"; type SettingsBody = { + timezone: string; emailEnabled: boolean; notificationEmail: string; reminderDaysBefore: number; @@ -174,6 +177,9 @@ export async function settingsRoutes(app: FastifyInstance) { const reminderMinutesBefore = envInt("REMINDER_MINUTES_BEFORE", 15); return reply.send({ + timezone: settings.timezone ?? "", + availableTimezones: getAvailableTimezones(), + serverTimezone: process.env.TZ || "UTC", // User notification settings (from DB) emailEnabled: settings.emailEnabled, notificationEmail: settings.notificationEmail ?? "", @@ -241,6 +247,7 @@ export async function settingsRoutes(app: FastifyInstance) { type: "object", required: ["emailEnabled", "notificationEmail", "reminderDaysBefore", "language"], properties: { + timezone: { type: "string" }, emailEnabled: { type: "boolean" }, notificationEmail: { type: "string" }, reminderDaysBefore: { type: "number" }, @@ -293,6 +300,7 @@ export async function settingsRoutes(app: FastifyInstance) { upcomingTodayOnly: false, shareScheduleTodayOnly: false, swapDashboardMainSections: false, + timezone: "", }, }, response: { @@ -318,6 +326,7 @@ export async function settingsRoutes(app: FastifyInstance) { const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId)); const settingsData = { + timezone: normalizeSettingsTimezone(body.timezone), emailEnabled: body.emailEnabled, notificationEmail: body.notificationEmail || null, emailStockReminders: body.emailStockReminders ?? true, diff --git a/backend/src/services/intake-reminder-scheduler.ts b/backend/src/services/intake-reminder-scheduler.ts index df64032..5490f46 100644 --- a/backend/src/services/intake-reminder-scheduler.ts +++ b/backend/src/services/intake-reminder-scheduler.ts @@ -18,6 +18,7 @@ import type { ServiceLogger } from "../utils/logger.js"; import { cleanOldIntakeReminders, createDefaultIntakeReminderState, + getEffectiveTimezone, getTimezone, getTodaysIntakes, getUpcomingIntakes, @@ -425,7 +426,7 @@ export async function checkAndSendIntakeRemindersForUser( const activeRows = rows.filter((med) => med.isObsolete !== true).sort((left, right) => left.id - right.id); const locale = getDateLocale(language); - const tz = getTimezone(); + const tz = getEffectiveTimezone(settings.timezone ?? null); const autoMarkedCount = await autoMarkDueIntakesAsTaken(settings, activeRows, locale, tz, logger); if (autoMarkedCount > 0) { diff --git a/backend/src/services/reminder-scheduler.ts b/backend/src/services/reminder-scheduler.ts index c240331..0e8dbed 100644 --- a/backend/src/services/reminder-scheduler.ts +++ b/backend/src/services/reminder-scheduler.ts @@ -21,6 +21,7 @@ import { formatInTimezone, getCurrentHourInTimezone, getDateOnlyTimestamp, + getEffectiveTimezone, getMsUntilNextCheck, getNextScheduledOccurrenceTime, getNextScheduledTime, @@ -534,7 +535,8 @@ async function checkAndSendReminderForUser( } const state = loadReminderState(); - const today = getTodayInTimezone(); // YYYY-MM-DD in configured timezone + const userTimezone = getEffectiveTimezone(settings.timezone ?? null); + const today = getTodayInTimezone(userTimezone); // YYYY-MM-DD in effective user timezone const userStateKey = `user_${settings.userId}`; const userStockNotifiedKey = `${userStateKey}_${today}_stock`; const userPrescriptionNotifiedKey = `${userStateKey}_${today}_prescription`; diff --git a/backend/src/services/settings-service.ts b/backend/src/services/settings-service.ts index 4b01a95..ed040e6 100644 --- a/backend/src/services/settings-service.ts +++ b/backend/src/services/settings-service.ts @@ -5,6 +5,7 @@ import type { Language } from "../i18n/translations.js"; export type UserSettings = { userId: number; + timezone?: string | null; emailEnabled: boolean; notificationEmail: string | null; emailStockReminders: boolean; @@ -105,6 +106,7 @@ function envInt(key: string, defaultVal: number): number { export function getDefaultSettings() { return { + timezone: "", emailEnabled: envBool("DEFAULT_EMAIL_ENABLED", false), notificationEmail: process.env.DEFAULT_NOTIFICATION_EMAIL || null, emailStockReminders: envBool("DEFAULT_EMAIL_STOCK_REMINDERS", true), @@ -144,6 +146,33 @@ export function getDefaultSettings() { }; } +type IntlWithSupportedValuesOf = typeof Intl & { + supportedValuesOf?: (key: string) => string[]; +}; + +let cachedTimezones: Set | null = null; + +function getTimezoneSet(): Set { + if (cachedTimezones) return cachedTimezones; + const intlWithSupportedValues = Intl as IntlWithSupportedValuesOf; + if (typeof intlWithSupportedValues.supportedValuesOf === "function") { + cachedTimezones = new Set(intlWithSupportedValues.supportedValuesOf("timeZone")); + return cachedTimezones; + } + cachedTimezones = new Set([process.env.TZ || "UTC", "UTC"]); + return cachedTimezones; +} + +export function getAvailableTimezones(): string[] { + return [...getTimezoneSet()].sort((left, right) => left.localeCompare(right)); +} + +export function normalizeSettingsTimezone(value: string | null | undefined): string { + const trimmed = value?.trim() ?? ""; + if (!trimmed) return ""; + return getTimezoneSet().has(trimmed) ? trimmed : ""; +} + export function validateNotificationHostname(hostnameRaw: string): string | null { const hostname = hostnameRaw.toLowerCase(); @@ -245,6 +274,7 @@ export async function loadUserSettingsFromDb(userId: number): Promise { const allSettings = await db.select().from(userSettings); return allSettings.map((settings) => ({ userId: settings.userId, + timezone: settings.timezone?.trim() ? settings.timezone : null, emailEnabled: settings.emailEnabled, notificationEmail: settings.notificationEmail, emailStockReminders: settings.emailStockReminders, diff --git a/backend/src/utils/scheduler-utils.ts b/backend/src/utils/scheduler-utils.ts index b5e3328..e0f1b9c 100644 --- a/backend/src/utils/scheduler-utils.ts +++ b/backend/src/utils/scheduler-utils.ts @@ -64,6 +64,16 @@ function toDateOnly(date: Date): Date { return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0); } +function getLocalDateOrdinal(date: Date): number { + return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000); +} + +function addLocalCalendarDays(date: Date, days: number): Date { + const next = new Date(date); + next.setDate(next.getDate() + days); + return next; +} + export function getDateOnlyTimestamp(date: Date): number { return toDateOnly(date).getTime(); } @@ -175,13 +185,23 @@ export function getNextScheduledOccurrenceTime( const lowerBound = inclusive ? fromMs : fromMs + 1; if (schedule.scheduleMode !== "weekdays") { - const period = Math.max(1, schedule.every) * 86_400_000; + const intervalDays = Math.max(1, schedule.every); if (startTime >= lowerBound) { return startTime; } - const intervals = Math.ceil((lowerBound - startTime) / period); - return startTime + intervals * period; + const lowerBoundDate = new Date(lowerBound); + const startOrdinal = getLocalDateOrdinal(startDate); + const lowerBoundOrdinal = getLocalDateOrdinal(lowerBoundDate); + const daysBetween = Math.max(0, lowerBoundOrdinal - startOrdinal); + const wholeIntervals = Math.floor(daysBetween / intervalDays); + + let candidate = addLocalCalendarDays(startDate, wholeIntervals * intervalDays); + while (candidate.getTime() < lowerBound) { + candidate = addLocalCalendarDays(candidate, intervalDays); + } + + return candidate.getTime(); } const candidateStart = Math.max(lowerBound, startTime); @@ -224,17 +244,28 @@ export function forEachScheduledOccurrenceInRange( } if (schedule.scheduleMode !== "weekdays") { - const period = Math.max(1, schedule.every) * 86_400_000; - let occurrenceMs = startTime; - if (occurrenceMs < rangeStartMs) { - const intervals = Math.ceil((rangeStartMs - occurrenceMs) / period); - occurrenceMs += intervals * period; + const intervalDays = Math.max(1, schedule.every); + let occurrence = new Date(startDate); + if (occurrence.getTime() < rangeStartMs) { + const rangeStartDate = new Date(rangeStartMs); + const startOrdinal = getLocalDateOrdinal(startDate); + const rangeStartOrdinal = getLocalDateOrdinal(rangeStartDate); + const daysBetween = Math.max(0, rangeStartOrdinal - startOrdinal); + const wholeIntervals = Math.floor(daysBetween / intervalDays); + occurrence = addLocalCalendarDays(startDate, wholeIntervals * intervalDays); + + while (occurrence.getTime() < rangeStartMs) { + occurrence = addLocalCalendarDays(occurrence, intervalDays); + } } - for (; occurrenceMs <= rangeEndMs; occurrenceMs += period) { + for (let occurrenceMs = occurrence.getTime(); occurrenceMs <= rangeEndMs; ) { if (occurrenceMs >= rangeStartMs) { callback(occurrenceMs); } + + occurrence = addLocalCalendarDays(occurrence, intervalDays); + occurrenceMs = occurrence.getTime(); } return; } @@ -348,6 +379,23 @@ export function getTimezone(): string { return process.env.TZ || "UTC"; } +export function isValidTimezone(value: string): boolean { + try { + new Intl.DateTimeFormat("en-US", { timeZone: value }); + return true; + } catch { + return false; + } +} + +export function getEffectiveTimezone(override?: string | null): string { + const normalized = override?.trim() ?? ""; + if (normalized && isValidTimezone(normalized)) { + return normalized; + } + return getTimezone(); +} + /** Format a date in the configured timezone */ export function formatInTimezone(date: Date, tz?: string): string { return date.toLocaleString("de-DE", { diff --git a/frontend/src/hooks/useSettings.ts b/frontend/src/hooks/useSettings.ts index 09e35b8..25ec53a 100644 --- a/frontend/src/hooks/useSettings.ts +++ b/frontend/src/hooks/useSettings.ts @@ -7,6 +7,9 @@ import { useTranslation } from "react-i18next"; import { log } from "../utils/logger"; export interface Settings { + timezone: string; + availableTimezones: string[]; + serverTimezone: string; emailEnabled: boolean; notificationEmail: string; reminderDaysBefore: number; @@ -58,6 +61,9 @@ export interface Settings { export type SettingsLoadError = "auth" | "forbidden" | "request" | null; const defaultSettings: Settings = { + timezone: "", + availableTimezones: [], + serverTimezone: "UTC", emailEnabled: false, notificationEmail: "", reminderDaysBefore: 7, @@ -243,6 +249,7 @@ export function useSettings(): UseSettingsReturn { const repeatDailyReminders = hasAnyStockReminder ? settingsToSave.repeatDailyReminders : false; return { + timezone: settingsToSave.timezone, emailEnabled: effectiveEmailEnabled, notificationEmail: settingsToSave.notificationEmail, reminderDaysBefore: settingsToSave.reminderDaysBefore, diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index 2f74d98..bf76a4a 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -389,6 +389,12 @@ "title": "Sprache", "select": "Sprache auswählen" }, + "timezone": { + "select": "Zeitzone", + "hint": "IANA-Zeitzone wählen. Wenn gesetzt, überschreibt sie die Server-TZ für deine Reminder-Zeitpunkte.", + "useServerDefault": "Server-Standard nutzen", + "currentServerTz": "Server-Standardzeitzone: {{timezone}}" + }, "apiKey": { "title": "API-Zugriff", "generateTitle": "API-Key erzeugen", diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 0eca437..3011082 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -389,6 +389,12 @@ "title": "Language", "select": "Select language" }, + "timezone": { + "select": "Timezone", + "hint": "Select an IANA timezone. When set, this overrides server TZ for your reminder timing.", + "useServerDefault": "Use server default", + "currentServerTz": "Server default timezone: {{timezone}}" + }, "apiKey": { "title": "API Access", "generateTitle": "Generate API key", diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 006c30d..7992857 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -116,6 +116,23 @@ export function SettingsPage() { const automaticStockCalculationId = "settings-stock-calculation-automatic"; const manualStockCalculationId = "settings-stock-calculation-manual"; + const timezoneSuggestions = + settings.availableTimezones.length > 0 + ? settings.availableTimezones + : (() => { + try { + type IntlWithSupportedValuesOf = typeof Intl & { + supportedValuesOf?: (key: string) => string[]; + }; + const intlWithSupportedValues = Intl as IntlWithSupportedValuesOf; + if (typeof intlWithSupportedValues.supportedValuesOf === "function") { + return intlWithSupportedValues.supportedValuesOf("timeZone"); + } + } catch { + // fall through + } + return [settings.serverTimezone || "UTC", "UTC"]; + })(); return (
@@ -160,6 +177,35 @@ export function SettingsPage() { +
+
+ {t("settings.timezone.select")} + + ⓘ + +
+
+ setSettings({ ...settings, timezone: e.target.value })} + list="settings-timezone-suggestions" + placeholder={settings.serverTimezone || "UTC"} + /> + + {timezoneSuggestions.map((zone) => ( + + +
+
+

+ {t("settings.timezone.currentServerTz", { timezone: settings.serverTimezone || "UTC" })} +