Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions config/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,7 @@
"minute": 0
},
"intervalHours": 0,
"timezone": "",
"roles": []
}
},
Expand Down Expand Up @@ -767,6 +768,8 @@
"hour": 14,
"minute": 0
},
"intervalHours": 0,
"timezone": "",
"roles": []
}
},
Expand Down
2 changes: 2 additions & 0 deletions packages/types/lib/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export type Config<Client extends boolean = false> = DeepMerge<
trialPeriod: {
start: TrialPeriodDate
end: TrialPeriodDate
intervalHours: number
timezone: string
roles: string[]
}
allowedGuilds: string[]
Expand Down
133 changes: 126 additions & 7 deletions server/src/services/Timer.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,27 @@ 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

/** @type {NodeJS.Timeout | null} */
this._timer = null
/** @type {NodeJS.Timeout | null} */
this._interval = null
/** @type {boolean} */
this._stopped = false
}

get ms() {
Expand Down Expand Up @@ -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<any>} 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)
}
}
}

Expand All @@ -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)
Expand All @@ -108,6 +226,7 @@ class Timer extends Logger {
}

async clear() {
this._stopped = true
if (this._timer) {
this.log.info('clearing timer')
clearTimeout(this._timer)
Expand Down
37 changes: 31 additions & 6 deletions server/src/services/Trial.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand All @@ -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)
}
Expand All @@ -60,6 +71,7 @@ class Trial extends Logger {
this._startTimer = new Timer(
startDate,
this._trial.intervalHours,
this._timezone,
this._type,
this._name,
'trial',
Expand All @@ -68,6 +80,7 @@ class Trial extends Logger {
this._endTimer = new Timer(
endDate,
this._trial.intervalHours,
this._timezone,
this._type,
this._name,
'trial',
Expand All @@ -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)
Expand All @@ -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,
Expand Down
Loading