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..0adb652f6 100644 --- a/server/src/services/Timer.js +++ b/server/src/services/Timer.js @@ -17,13 +17,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 @@ -31,6 +36,8 @@ class Timer extends Logger { this._timer = null /** @type {NodeJS.Timeout | null} */ this._interval = null + /** @type {boolean} */ + this._stopped = false } get ms() { @@ -69,20 +76,130 @@ 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()) } + /** + * 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. + * + * @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', + hourCycle: 'h23', + }).formatToParts(utcDate) + const get = (type) => parseInt(parts.find((p) => p.type === type).value, 10) + + return Timer.localToUtc( + get('year'), + get('month') - 1, + get('day'), + get('hour') + hours, + get('minute'), + get('second'), + 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 = () => { + 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) + } + scheduleNext() + } else { + this._interval = setInterval(async () => { + await cb() + this.setNextDate() + }, this._intervalMs) + } } } @@ -92,6 +209,7 @@ class Timer extends Logger { activate(cb) { const now = Date.now() this.clear() + this._stopped = false if (now >= this._date.getTime()) { this.setInterval(cb) @@ -108,6 +226,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 1ef701bbe..4b7467fb7 100644 --- a/server/src/services/Trial.js +++ b/server/src/services/Trial.js @@ -18,15 +18,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 +46,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 +71,7 @@ class Trial extends Logger { this._startTimer = new Timer( startDate, this._trial.intervalHours, + this._timezone, this._type, this._name, 'trial', @@ -68,6 +80,7 @@ class Trial extends Logger { this._endTimer = new Timer( endDate, this._trial.intervalHours, + this._timezone, this._type, this._name, 'trial', @@ -87,9 +100,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,6 +112,17 @@ class Trial extends Logger { log.debug('date object is missing required fields') return new Date(0) } + 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,