Add searchable timezone settings override for reminder scheduling
This commit is contained in:
+2
-1
@@ -37,7 +37,8 @@ LOG_LEVEL=warn
|
|||||||
# production: leave unset, or set OPENAPI_DOCS_ENABLED=false
|
# production: leave unset, or set OPENAPI_DOCS_ENABLED=false
|
||||||
# OPENAPI_DOCS_ENABLED=true
|
# 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
|
TZ=Europe/Berlin
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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 |
|
| `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. |
|
| `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. |
|
| `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:
|
Recommended values for API docs by environment:
|
||||||
|
|
||||||
@@ -305,6 +305,8 @@ API reference:
|
|||||||
| `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder |
|
| `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder |
|
||||||
| `EXPIRY_WARNING_DAYS` | `30` | Days before expiry to show warning |
|
| `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)
|
### Push Notifications (Shoutrrr)
|
||||||
|
|
||||||
MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notifications, supporting many services with a single URL format.
|
MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notifications, supporting many services with a single URL format.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `user_settings` ADD `timezone` text DEFAULT '' NOT NULL;
|
||||||
@@ -99,6 +99,13 @@
|
|||||||
"when": 1773348659979,
|
"when": 1773348659979,
|
||||||
"tag": "0013_add_share_medication_overview",
|
"tag": "0013_add_share_medication_overview",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1775849300000,
|
||||||
|
"tag": "0014_add_user_settings_timezone",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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 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 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 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 dismissed integer NOT NULL DEFAULT 0`,
|
||||||
`ALTER TABLE dose_tracking ADD COLUMN taken_source text NOT NULL DEFAULT 'manual'`,
|
`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'`,
|
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export function getTableCreationSQL(): string[] {
|
|||||||
high_stock_days integer NOT NULL DEFAULT 180,
|
high_stock_days integer NOT NULL DEFAULT 180,
|
||||||
expiry_warning_days integer NOT NULL DEFAULT 90,
|
expiry_warning_days integer NOT NULL DEFAULT 90,
|
||||||
language text NOT NULL DEFAULT 'en',
|
language text NOT NULL DEFAULT 'en',
|
||||||
|
timezone text NOT NULL DEFAULT '',
|
||||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||||
share_stock_status integer NOT NULL DEFAULT 1,
|
share_stock_status integer NOT NULL DEFAULT 1,
|
||||||
upcoming_today_only integer NOT NULL DEFAULT 0,
|
upcoming_today_only integer NOT NULL DEFAULT 0,
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export const userSettings = sqliteTable("user_settings", {
|
|||||||
expiryWarningDays: integer("expiry_warning_days").notNull().default(90),
|
expiryWarningDays: integer("expiry_warning_days").notNull().default(90),
|
||||||
// UI preferences
|
// UI preferences
|
||||||
language: text("language", { length: 10 }).notNull().default("en"),
|
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)
|
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
|
||||||
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
|
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
|
||||||
// Whether shared schedule links show stock status (Critical/Low/Normal) to intake users
|
// Whether shared schedule links show stock status (Critical/Low/Normal) to intake users
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import { getSmtpConfig, sendEmailNotification } from "../services/notifications/
|
|||||||
import {
|
import {
|
||||||
classifyTestEmailFailure,
|
classifyTestEmailFailure,
|
||||||
getAllUserSettingsFromDb,
|
getAllUserSettingsFromDb,
|
||||||
|
getAvailableTimezones,
|
||||||
getDefaultSettings,
|
getDefaultSettings,
|
||||||
getNotificationProvider,
|
getNotificationProvider,
|
||||||
loadUserSettingsFromDb,
|
loadUserSettingsFromDb,
|
||||||
|
normalizeSettingsTimezone,
|
||||||
sanitizeNotificationUrl,
|
sanitizeNotificationUrl,
|
||||||
type UserSettings,
|
type UserSettings,
|
||||||
validateNotificationHostname,
|
validateNotificationHostname,
|
||||||
@@ -20,6 +22,7 @@ import type { AuthUser } from "../types/fastify.js";
|
|||||||
export type { UserSettings } from "../services/settings-service.js";
|
export type { UserSettings } from "../services/settings-service.js";
|
||||||
|
|
||||||
type SettingsBody = {
|
type SettingsBody = {
|
||||||
|
timezone: string;
|
||||||
emailEnabled: boolean;
|
emailEnabled: boolean;
|
||||||
notificationEmail: string;
|
notificationEmail: string;
|
||||||
reminderDaysBefore: number;
|
reminderDaysBefore: number;
|
||||||
@@ -174,6 +177,9 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
const reminderMinutesBefore = envInt("REMINDER_MINUTES_BEFORE", 15);
|
const reminderMinutesBefore = envInt("REMINDER_MINUTES_BEFORE", 15);
|
||||||
|
|
||||||
return reply.send({
|
return reply.send({
|
||||||
|
timezone: settings.timezone ?? "",
|
||||||
|
availableTimezones: getAvailableTimezones(),
|
||||||
|
serverTimezone: process.env.TZ || "UTC",
|
||||||
// User notification settings (from DB)
|
// User notification settings (from DB)
|
||||||
emailEnabled: settings.emailEnabled,
|
emailEnabled: settings.emailEnabled,
|
||||||
notificationEmail: settings.notificationEmail ?? "",
|
notificationEmail: settings.notificationEmail ?? "",
|
||||||
@@ -241,6 +247,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
type: "object",
|
type: "object",
|
||||||
required: ["emailEnabled", "notificationEmail", "reminderDaysBefore", "language"],
|
required: ["emailEnabled", "notificationEmail", "reminderDaysBefore", "language"],
|
||||||
properties: {
|
properties: {
|
||||||
|
timezone: { type: "string" },
|
||||||
emailEnabled: { type: "boolean" },
|
emailEnabled: { type: "boolean" },
|
||||||
notificationEmail: { type: "string" },
|
notificationEmail: { type: "string" },
|
||||||
reminderDaysBefore: { type: "number" },
|
reminderDaysBefore: { type: "number" },
|
||||||
@@ -293,6 +300,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
upcomingTodayOnly: false,
|
upcomingTodayOnly: false,
|
||||||
shareScheduleTodayOnly: false,
|
shareScheduleTodayOnly: false,
|
||||||
swapDashboardMainSections: false,
|
swapDashboardMainSections: false,
|
||||||
|
timezone: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
response: {
|
response: {
|
||||||
@@ -318,6 +326,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
||||||
|
|
||||||
const settingsData = {
|
const settingsData = {
|
||||||
|
timezone: normalizeSettingsTimezone(body.timezone),
|
||||||
emailEnabled: body.emailEnabled,
|
emailEnabled: body.emailEnabled,
|
||||||
notificationEmail: body.notificationEmail || null,
|
notificationEmail: body.notificationEmail || null,
|
||||||
emailStockReminders: body.emailStockReminders ?? true,
|
emailStockReminders: body.emailStockReminders ?? true,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type { ServiceLogger } from "../utils/logger.js";
|
|||||||
import {
|
import {
|
||||||
cleanOldIntakeReminders,
|
cleanOldIntakeReminders,
|
||||||
createDefaultIntakeReminderState,
|
createDefaultIntakeReminderState,
|
||||||
|
getEffectiveTimezone,
|
||||||
getTimezone,
|
getTimezone,
|
||||||
getTodaysIntakes,
|
getTodaysIntakes,
|
||||||
getUpcomingIntakes,
|
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 activeRows = rows.filter((med) => med.isObsolete !== true).sort((left, right) => left.id - right.id);
|
||||||
|
|
||||||
const locale = getDateLocale(language);
|
const locale = getDateLocale(language);
|
||||||
const tz = getTimezone();
|
const tz = getEffectiveTimezone(settings.timezone ?? null);
|
||||||
|
|
||||||
const autoMarkedCount = await autoMarkDueIntakesAsTaken(settings, activeRows, locale, tz, logger);
|
const autoMarkedCount = await autoMarkDueIntakesAsTaken(settings, activeRows, locale, tz, logger);
|
||||||
if (autoMarkedCount > 0) {
|
if (autoMarkedCount > 0) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
formatInTimezone,
|
formatInTimezone,
|
||||||
getCurrentHourInTimezone,
|
getCurrentHourInTimezone,
|
||||||
getDateOnlyTimestamp,
|
getDateOnlyTimestamp,
|
||||||
|
getEffectiveTimezone,
|
||||||
getMsUntilNextCheck,
|
getMsUntilNextCheck,
|
||||||
getNextScheduledOccurrenceTime,
|
getNextScheduledOccurrenceTime,
|
||||||
getNextScheduledTime,
|
getNextScheduledTime,
|
||||||
@@ -534,7 +535,8 @@ async function checkAndSendReminderForUser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const state = loadReminderState();
|
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 userStateKey = `user_${settings.userId}`;
|
||||||
const userStockNotifiedKey = `${userStateKey}_${today}_stock`;
|
const userStockNotifiedKey = `${userStateKey}_${today}_stock`;
|
||||||
const userPrescriptionNotifiedKey = `${userStateKey}_${today}_prescription`;
|
const userPrescriptionNotifiedKey = `${userStateKey}_${today}_prescription`;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { Language } from "../i18n/translations.js";
|
|||||||
|
|
||||||
export type UserSettings = {
|
export type UserSettings = {
|
||||||
userId: number;
|
userId: number;
|
||||||
|
timezone?: string | null;
|
||||||
emailEnabled: boolean;
|
emailEnabled: boolean;
|
||||||
notificationEmail: string | null;
|
notificationEmail: string | null;
|
||||||
emailStockReminders: boolean;
|
emailStockReminders: boolean;
|
||||||
@@ -105,6 +106,7 @@ function envInt(key: string, defaultVal: number): number {
|
|||||||
|
|
||||||
export function getDefaultSettings() {
|
export function getDefaultSettings() {
|
||||||
return {
|
return {
|
||||||
|
timezone: "",
|
||||||
emailEnabled: envBool("DEFAULT_EMAIL_ENABLED", false),
|
emailEnabled: envBool("DEFAULT_EMAIL_ENABLED", false),
|
||||||
notificationEmail: process.env.DEFAULT_NOTIFICATION_EMAIL || null,
|
notificationEmail: process.env.DEFAULT_NOTIFICATION_EMAIL || null,
|
||||||
emailStockReminders: envBool("DEFAULT_EMAIL_STOCK_REMINDERS", true),
|
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<string> | null = null;
|
||||||
|
|
||||||
|
function getTimezoneSet(): Set<string> {
|
||||||
|
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 {
|
export function validateNotificationHostname(hostnameRaw: string): string | null {
|
||||||
const hostname = hostnameRaw.toLowerCase();
|
const hostname = hostnameRaw.toLowerCase();
|
||||||
|
|
||||||
@@ -245,6 +274,7 @@ export async function loadUserSettingsFromDb(userId: number): Promise<UserSettin
|
|||||||
const settings = await getOrCreateUserSettings(userId);
|
const settings = await getOrCreateUserSettings(userId);
|
||||||
return {
|
return {
|
||||||
userId: settings.userId,
|
userId: settings.userId,
|
||||||
|
timezone: settings.timezone?.trim() ? settings.timezone : null,
|
||||||
emailEnabled: settings.emailEnabled,
|
emailEnabled: settings.emailEnabled,
|
||||||
notificationEmail: settings.notificationEmail,
|
notificationEmail: settings.notificationEmail,
|
||||||
emailStockReminders: settings.emailStockReminders,
|
emailStockReminders: settings.emailStockReminders,
|
||||||
@@ -288,6 +318,7 @@ export async function getAllUserSettingsFromDb(): Promise<UserSettings[]> {
|
|||||||
const allSettings = await db.select().from(userSettings);
|
const allSettings = await db.select().from(userSettings);
|
||||||
return allSettings.map((settings) => ({
|
return allSettings.map((settings) => ({
|
||||||
userId: settings.userId,
|
userId: settings.userId,
|
||||||
|
timezone: settings.timezone?.trim() ? settings.timezone : null,
|
||||||
emailEnabled: settings.emailEnabled,
|
emailEnabled: settings.emailEnabled,
|
||||||
notificationEmail: settings.notificationEmail,
|
notificationEmail: settings.notificationEmail,
|
||||||
emailStockReminders: settings.emailStockReminders,
|
emailStockReminders: settings.emailStockReminders,
|
||||||
|
|||||||
@@ -64,6 +64,16 @@ function toDateOnly(date: Date): Date {
|
|||||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
|
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 {
|
export function getDateOnlyTimestamp(date: Date): number {
|
||||||
return toDateOnly(date).getTime();
|
return toDateOnly(date).getTime();
|
||||||
}
|
}
|
||||||
@@ -175,13 +185,23 @@ export function getNextScheduledOccurrenceTime(
|
|||||||
|
|
||||||
const lowerBound = inclusive ? fromMs : fromMs + 1;
|
const lowerBound = inclusive ? fromMs : fromMs + 1;
|
||||||
if (schedule.scheduleMode !== "weekdays") {
|
if (schedule.scheduleMode !== "weekdays") {
|
||||||
const period = Math.max(1, schedule.every) * 86_400_000;
|
const intervalDays = Math.max(1, schedule.every);
|
||||||
if (startTime >= lowerBound) {
|
if (startTime >= lowerBound) {
|
||||||
return startTime;
|
return startTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
const intervals = Math.ceil((lowerBound - startTime) / period);
|
const lowerBoundDate = new Date(lowerBound);
|
||||||
return startTime + intervals * period;
|
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);
|
const candidateStart = Math.max(lowerBound, startTime);
|
||||||
@@ -224,17 +244,28 @@ export function forEachScheduledOccurrenceInRange(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (schedule.scheduleMode !== "weekdays") {
|
if (schedule.scheduleMode !== "weekdays") {
|
||||||
const period = Math.max(1, schedule.every) * 86_400_000;
|
const intervalDays = Math.max(1, schedule.every);
|
||||||
let occurrenceMs = startTime;
|
let occurrence = new Date(startDate);
|
||||||
if (occurrenceMs < rangeStartMs) {
|
if (occurrence.getTime() < rangeStartMs) {
|
||||||
const intervals = Math.ceil((rangeStartMs - occurrenceMs) / period);
|
const rangeStartDate = new Date(rangeStartMs);
|
||||||
occurrenceMs += intervals * period;
|
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) {
|
if (occurrenceMs >= rangeStartMs) {
|
||||||
callback(occurrenceMs);
|
callback(occurrenceMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
occurrence = addLocalCalendarDays(occurrence, intervalDays);
|
||||||
|
occurrenceMs = occurrence.getTime();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -348,6 +379,23 @@ export function getTimezone(): string {
|
|||||||
return process.env.TZ || "UTC";
|
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 */
|
/** Format a date in the configured timezone */
|
||||||
export function formatInTimezone(date: Date, tz?: string): string {
|
export function formatInTimezone(date: Date, tz?: string): string {
|
||||||
return date.toLocaleString("de-DE", {
|
return date.toLocaleString("de-DE", {
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { log } from "../utils/logger";
|
import { log } from "../utils/logger";
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
|
timezone: string;
|
||||||
|
availableTimezones: string[];
|
||||||
|
serverTimezone: string;
|
||||||
emailEnabled: boolean;
|
emailEnabled: boolean;
|
||||||
notificationEmail: string;
|
notificationEmail: string;
|
||||||
reminderDaysBefore: number;
|
reminderDaysBefore: number;
|
||||||
@@ -58,6 +61,9 @@ export interface Settings {
|
|||||||
export type SettingsLoadError = "auth" | "forbidden" | "request" | null;
|
export type SettingsLoadError = "auth" | "forbidden" | "request" | null;
|
||||||
|
|
||||||
const defaultSettings: Settings = {
|
const defaultSettings: Settings = {
|
||||||
|
timezone: "",
|
||||||
|
availableTimezones: [],
|
||||||
|
serverTimezone: "UTC",
|
||||||
emailEnabled: false,
|
emailEnabled: false,
|
||||||
notificationEmail: "",
|
notificationEmail: "",
|
||||||
reminderDaysBefore: 7,
|
reminderDaysBefore: 7,
|
||||||
@@ -243,6 +249,7 @@ export function useSettings(): UseSettingsReturn {
|
|||||||
const repeatDailyReminders = hasAnyStockReminder ? settingsToSave.repeatDailyReminders : false;
|
const repeatDailyReminders = hasAnyStockReminder ? settingsToSave.repeatDailyReminders : false;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
timezone: settingsToSave.timezone,
|
||||||
emailEnabled: effectiveEmailEnabled,
|
emailEnabled: effectiveEmailEnabled,
|
||||||
notificationEmail: settingsToSave.notificationEmail,
|
notificationEmail: settingsToSave.notificationEmail,
|
||||||
reminderDaysBefore: settingsToSave.reminderDaysBefore,
|
reminderDaysBefore: settingsToSave.reminderDaysBefore,
|
||||||
|
|||||||
@@ -389,6 +389,12 @@
|
|||||||
"title": "Sprache",
|
"title": "Sprache",
|
||||||
"select": "Sprache auswählen"
|
"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": {
|
"apiKey": {
|
||||||
"title": "API-Zugriff",
|
"title": "API-Zugriff",
|
||||||
"generateTitle": "API-Key erzeugen",
|
"generateTitle": "API-Key erzeugen",
|
||||||
|
|||||||
@@ -389,6 +389,12 @@
|
|||||||
"title": "Language",
|
"title": "Language",
|
||||||
"select": "Select 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": {
|
"apiKey": {
|
||||||
"title": "API Access",
|
"title": "API Access",
|
||||||
"generateTitle": "Generate API key",
|
"generateTitle": "Generate API key",
|
||||||
|
|||||||
@@ -116,6 +116,23 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
const automaticStockCalculationId = "settings-stock-calculation-automatic";
|
const automaticStockCalculationId = "settings-stock-calculation-automatic";
|
||||||
const manualStockCalculationId = "settings-stock-calculation-manual";
|
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 (
|
return (
|
||||||
<section className="grid">
|
<section className="grid">
|
||||||
@@ -160,6 +177,35 @@ export function SettingsPage() {
|
|||||||
<option value="de">🇩🇪 Deutsch</option>
|
<option value="de">🇩🇪 Deutsch</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<div className="setting-row compact" style={{ marginTop: "12px" }}>
|
||||||
|
<div className="setting-label">
|
||||||
|
<span>{t("settings.timezone.select")}</span>
|
||||||
|
<span className="info-tooltip small" data-tooltip={t("settings.timezone.hint")}>
|
||||||
|
ⓘ
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="setting-actions" style={{ margin: 0, flexWrap: "nowrap", gap: "8px" }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="select-field language-select"
|
||||||
|
value={settings.timezone}
|
||||||
|
onChange={(e) => setSettings({ ...settings, timezone: e.target.value })}
|
||||||
|
list="settings-timezone-suggestions"
|
||||||
|
placeholder={settings.serverTimezone || "UTC"}
|
||||||
|
/>
|
||||||
|
<datalist id="settings-timezone-suggestions">
|
||||||
|
{timezoneSuggestions.map((zone) => (
|
||||||
|
<option key={zone} value={zone} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
<button type="button" className="ghost" onClick={() => setSettings({ ...settings, timezone: "" })}>
|
||||||
|
{t("settings.timezone.useServerDefault")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="hint-text" style={{ marginTop: "8px" }}>
|
||||||
|
{t("settings.timezone.currentServerTz", { timezone: settings.serverTimezone || "UTC" })}
|
||||||
|
</p>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article className="card" data-testid="settings-notification-card">
|
<article className="card" data-testid="settings-notification-card">
|
||||||
|
|||||||
Reference in New Issue
Block a user