From eded2386cd75c9094ed932733014f69c6b869555 Mon Sep 17 00:00:00 2001 From: lenisko <10072920+lenisko@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:50:50 +0200 Subject: [PATCH 1/3] feat: add IANA timezone support to trialPeriod config When a timezone (e.g. "Europe/Warsaw") is set in trialPeriod, start/end times are interpreted as local time in that timezone. Interval advancement preserves local time across DST transitions by decomposing UTC dates into local components via Intl.DateTimeFormat and re-resolving the offset each cycle, so e.g. 17:00 Warsaw stays 17:00 Warsaw regardless of CET/CEST shifts. Timer uses chained setTimeout instead of fixed setInterval when timezone is set, so each delay adapts to the actual wall-clock gap (167h or 169h around DST boundaries for a 168h interval). Without timezone set, behavior is unchanged (server-local time, fixed ms intervals). --- config/default.json | 3 ++ packages/types/lib/config.d.ts | 2 + server/src/services/Timer.js | 74 ++++++++++++++++++++++++++++++---- server/src/services/Trial.js | 33 +++++++++++---- 4 files changed, 98 insertions(+), 14 deletions(-) diff --git a/config/default.json b/config/default.json index 67aa516f5..d20d646d4 100644 --- a/config/default.json +++ b/config/default.json @@ -739,6 +739,7 @@ "minute": 0 }, "intervalHours": 0, + "timezone": "", "roles": [] } }, @@ -767,6 +768,8 @@ "hour": 14, "minute": 0 }, + "intervalHours": 0, + "timezone": "", "roles": [] } }, diff --git a/packages/types/lib/config.d.ts b/packages/types/lib/config.d.ts index 4c098138e..84d7600d6 100644 --- a/packages/types/lib/config.d.ts +++ b/packages/types/lib/config.d.ts @@ -66,6 +66,8 @@ export type Config = DeepMerge< trialPeriod: { start: TrialPeriodDate end: TrialPeriodDate + intervalHours: number + timezone: string roles: string[] } allowedGuilds: string[] diff --git a/server/src/services/Timer.js b/server/src/services/Timer.js index 4c7c02855..9be612b44 100644 --- a/server/src/services/Timer.js +++ b/server/src/services/Timer.js @@ -1,4 +1,5 @@ // @ts-check +const { zonedTimeToUtc } = require('date-fns-tz') const { Logger } = require('@rm/logger') const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) @@ -17,13 +18,18 @@ class Timer extends Logger { /** * @param {Date} date * @param {number} intervalHours + * @param {string} [timezone] * @param {...string} [tags] */ - constructor(date, intervalHours, ...tags) { + constructor(date, intervalHours, timezone, ...tags) { super(...(tags.length ? tags : ['timer'])) /** @type {number} */ - this._intervalMs = (intervalHours || 0) * 60 * 60 * 1000 + this._intervalHours = intervalHours || 0 + /** @type {number} */ + this._intervalMs = this._intervalHours * 60 * 60 * 1000 + /** @type {string | undefined} */ + this._timezone = timezone || undefined /** @type {Date} */ this._date = date @@ -69,20 +75,74 @@ class Timer extends Logger { } setNextDate() { - this._date = new Date(this._date.getTime() + this._intervalMs) + if (this._timezone) { + this._date = Timer.advanceInTimezone( + this._date, + this._intervalHours, + this._timezone, + ) + } else { + this._date = new Date(this._date.getTime() + this._intervalMs) + } this.log.info('next', this.relative()) } + /** + * Advance a UTC date by the given hours while preserving local time + * in the target timezone across DST transitions. + * + * @param {Date} utcDate + * @param {number} hours + * @param {string} timezone IANA timezone identifier + * @returns {Date} + */ + static advanceInTimezone(utcDate, hours, timezone) { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + hour12: false, + }).formatToParts(utcDate) + const get = (type) => + parseInt(parts.find((p) => p.type === type).value, 10) + + const advanced = new Date( + get('year'), + get('month') - 1, + get('day'), + get('hour') + hours, + get('minute'), + get('second'), + ) + return zonedTimeToUtc(advanced, timezone) + } + /** * @param {() => any | Promise} cb */ setInterval(cb) { if (this._intervalMs > 0) { this.setNextDate() - this._interval = setInterval(async () => { - await cb() - this.setNextDate() - }, this._intervalMs) + if (this._timezone) { + const scheduleNext = () => { + const delay = Math.max(0, this._date.getTime() - Date.now()) + this._interval = setTimeout(async () => { + await cb() + this.setNextDate() + scheduleNext() + }, delay) + } + scheduleNext() + } else { + this._interval = setInterval(async () => { + await cb() + this.setNextDate() + }, this._intervalMs) + } } } diff --git a/server/src/services/Trial.js b/server/src/services/Trial.js index 1ef701bbe..7384a2c88 100644 --- a/server/src/services/Trial.js +++ b/server/src/services/Trial.js @@ -1,4 +1,5 @@ // @ts-check +const { zonedTimeToUtc } = require('date-fns-tz') const { Logger, log } = require('@rm/logger') const config = require('@rm/config') @@ -18,15 +19,18 @@ class Trial extends Logger { start: null, end: null, intervalHours: 0, + timezone: '', roles: [], }, strategy.trialPeriod, ) + /** @type {string | undefined} */ + this._timezone = this._trial.timezone || undefined this._forceActive = false - let startDate = Trial.getJsDate(this._trial.start) - let endDate = Trial.getJsDate(this._trial.end) + let startDate = Trial.getJsDate(this._trial.start, this._timezone) + let endDate = Trial.getJsDate(this._trial.end, this._timezone) if (this._trial.intervalHours > 0) { if (startDate.getTime() < Date.now() && endDate.getTime() < Date.now()) { @@ -43,9 +47,17 @@ class Trial extends Logger { startDate.getTime() < Date.now() && endDate.getTime() < Date.now() ) { - startDate = new Date( - startDate.getTime() + this._trial.intervalHours * 60 * 60 * 1000, - ) + if (this._timezone) { + startDate = Timer.advanceInTimezone( + startDate, + this._trial.intervalHours, + this._timezone, + ) + } else { + startDate = new Date( + startDate.getTime() + this._trial.intervalHours * 60 * 60 * 1000, + ) + } endDate = new Date(startDate.getTime() + diff) this.log.debug('next start:', startDate, 'next end:', endDate) } @@ -60,6 +72,7 @@ class Trial extends Logger { this._startTimer = new Timer( startDate, this._trial.intervalHours, + this._timezone, this._type, this._name, 'trial', @@ -68,6 +81,7 @@ class Trial extends Logger { this._endTimer = new Timer( endDate, this._trial.intervalHours, + this._timezone, this._type, this._name, 'trial', @@ -87,9 +101,10 @@ class Trial extends Logger { * Get a JavaScript Date object from a @see TrialPeriodDate object * * @param {import("@rm/types").TrialPeriodDate} dateObj + * @param {string} [timezone] IANA timezone (e.g. 'Europe/Warsaw') * @returns {Date} */ - static getJsDate(dateObj) { + static getJsDate(dateObj, timezone) { if (!dateObj) { log.debug('date object is null') return new Date(0) @@ -98,7 +113,7 @@ class Trial extends Logger { log.debug('date object is missing required fields') return new Date(0) } - return new Date( + const localDate = new Date( dateObj.year, dateObj.month - 1, dateObj.day, @@ -107,6 +122,10 @@ class Trial extends Logger { dateObj.second || 0, dateObj.millisecond || 0, ) + if (timezone) { + return zonedTimeToUtc(localDate, timezone) + } + return localDate } #getClearFn(start = false) { From 742f39467523f516168a36510add50a0cd7b1f22 Mon Sep 17 00:00:00 2001 From: lenisko <10072920+lenisko@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:04:51 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20stop?= =?UTF-8?q?=20reschedule=20after=20clear,=20remove=20host-tz=20dependency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: Add _stopped flag to Timer so the recursive setTimeout chain does not re-arm after clear() is called mid-callback. Reset on activate() to allow re-use. P2: Replace new Date(y,m,d,h) + zonedTimeToUtc with Date.UTC + Intl.DateTimeFormat offset resolution (localToUtc). This avoids the host timezone normalising DST-gap hours before zonedTimeToUtc can read them. A second-pass offset check handles cases where the initial guess straddles a DST boundary. Removes date-fns-tz dependency from both Timer and Trial. --- server/src/services/Timer.js | 68 +++++++++++++++++++++++++++++++++--- server/src/services/Trial.js | 18 ++++++---- 2 files changed, 76 insertions(+), 10 deletions(-) diff --git a/server/src/services/Timer.js b/server/src/services/Timer.js index 9be612b44..8b342e661 100644 --- a/server/src/services/Timer.js +++ b/server/src/services/Timer.js @@ -1,5 +1,4 @@ // @ts-check -const { zonedTimeToUtc } = require('date-fns-tz') const { Logger } = require('@rm/logger') const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) @@ -37,6 +36,8 @@ class Timer extends Logger { this._timer = null /** @type {NodeJS.Timeout | null} */ this._interval = null + /** @type {boolean} */ + this._stopped = false } get ms() { @@ -87,6 +88,61 @@ class Timer extends Logger { this.log.info('next', this.relative()) } + /** + * Convert local date/time components in a timezone to a UTC Date. + * Uses only Date.UTC and Intl.DateTimeFormat to avoid host-timezone + * interference (no `new Date(y,m,d,h,…)` which normalises through + * the server's local DST rules). + * + * @param {number} year + * @param {number} month 0-indexed + * @param {number} day + * @param {number} hour may overflow/underflow (Date.UTC normalises) + * @param {number} minute + * @param {number} second + * @param {string} timezone IANA timezone identifier + * @returns {Date} + */ + static localToUtc(year, month, day, hour, minute, second, timezone) { + const fmt = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + hourCycle: 'h23', + }) + const getOffset = (ms) => { + const parts = fmt.formatToParts(new Date(ms)) + const get = (type) => + parseInt(parts.find((p) => p.type === type).value, 10) + return ( + Date.UTC( + get('year'), + get('month') - 1, + get('day'), + get('hour'), + get('minute'), + get('second'), + ) - ms + ) + } + + const utcMs = Date.UTC(year, month, day, hour, minute, second) + const offset = getOffset(utcMs) + const result = utcMs - offset + + // Re-check: if utcMs and result straddle a DST boundary the offset + // at the result may differ from the initial guess. + const offset2 = getOffset(result) + if (offset !== offset2) { + return new Date(utcMs - offset2) + } + return new Date(result) + } + /** * Advance a UTC date by the given hours while preserving local time * in the target timezone across DST transitions. @@ -105,20 +161,20 @@ class Timer extends Logger { hour: 'numeric', minute: 'numeric', second: 'numeric', - hour12: false, + hourCycle: 'h23', }).formatToParts(utcDate) const get = (type) => parseInt(parts.find((p) => p.type === type).value, 10) - const advanced = new Date( + return Timer.localToUtc( get('year'), get('month') - 1, get('day'), get('hour') + hours, get('minute'), get('second'), + timezone, ) - return zonedTimeToUtc(advanced, timezone) } /** @@ -129,9 +185,11 @@ class Timer extends Logger { this.setNextDate() if (this._timezone) { const scheduleNext = () => { + if (this._stopped) return const delay = Math.max(0, this._date.getTime() - Date.now()) this._interval = setTimeout(async () => { await cb() + if (this._stopped) return this.setNextDate() scheduleNext() }, delay) @@ -152,6 +210,7 @@ class Timer extends Logger { activate(cb) { const now = Date.now() this.clear() + this._stopped = false if (now >= this._date.getTime()) { this.setInterval(cb) @@ -168,6 +227,7 @@ class Timer extends Logger { } async clear() { + this._stopped = true if (this._timer) { this.log.info('clearing timer') clearTimeout(this._timer) diff --git a/server/src/services/Trial.js b/server/src/services/Trial.js index 7384a2c88..4b7467fb7 100644 --- a/server/src/services/Trial.js +++ b/server/src/services/Trial.js @@ -1,5 +1,4 @@ // @ts-check -const { zonedTimeToUtc } = require('date-fns-tz') const { Logger, log } = require('@rm/logger') const config = require('@rm/config') @@ -113,7 +112,18 @@ class Trial extends Logger { log.debug('date object is missing required fields') return new Date(0) } - const localDate = new Date( + if (timezone) { + return Timer.localToUtc( + dateObj.year, + dateObj.month - 1, + dateObj.day, + dateObj.hour || 0, + dateObj.minute || 0, + dateObj.second || 0, + timezone, + ) + } + return new Date( dateObj.year, dateObj.month - 1, dateObj.day, @@ -122,10 +132,6 @@ class Trial extends Logger { dateObj.second || 0, dateObj.millisecond || 0, ) - if (timezone) { - return zonedTimeToUtc(localDate, timezone) - } - return localDate } #getClearFn(start = false) { From 329ee5d21167d5b94693414199d1cc78e3fdee8e Mon Sep 17 00:00:00 2001 From: lenisko <10072920+lenisko@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:09:24 +0200 Subject: [PATCH 3/3] style: fix prettier formatting in Timer.js --- server/src/services/Timer.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/server/src/services/Timer.js b/server/src/services/Timer.js index 8b342e661..0adb652f6 100644 --- a/server/src/services/Timer.js +++ b/server/src/services/Timer.js @@ -163,8 +163,7 @@ class Timer extends Logger { second: 'numeric', hourCycle: 'h23', }).formatToParts(utcDate) - const get = (type) => - parseInt(parts.find((p) => p.type === type).value, 10) + const get = (type) => parseInt(parts.find((p) => p.type === type).value, 10) return Timer.localToUtc( get('year'),