I want to use UTC dates in my Node.js backend app, however, I need to be able to set time (hours and minutes) in a local/user-specified timezone.
I am looking for a solution in either pure JS or using dayjs
. I am not looking for a solution in moment
.
It seemed like using dayjs
I could solve this problem quite easily, however, I could not find a way to accomplish this.
I can use UTC timezone by using dayjs.utc()
or using dayjs.tz(someDate, 'Etc/UTC')
.
When using dayjs.utc()
, I cannot use/specify other timezones for anything, therefore I could not find a way to tell dayjs
I want to set hours/minutes in a particular (non-UTC) timezone.
When using dayjs.tz()
, I still cannot define a timezone of time I want to set to a particular date.
Example in plain JS
My locale timezone is Europe/Slovakia
(CEST = UTC+02 with DST, CET = UTC+1 without DST), however, I want this to work with any timezone.
// Expected outcome
// old: 2022-10-29T10:00:00.000Z
// new time: 10h 15m CEST
// new: 2022-10-29T08:15:00.000Z
// Plain JS
const now = new Date('2022-10-29T10:00:00.000Z')
const hours = 10
const minutes = 15
now.setHours(10)
now.setMinutes(15)
// As my default timezone is `Europe/Bratislava`, it seems to work as expected
console.log(now)
// Output: 2022-10-29T08:15:00.000Z
// However, it won't work with timezones other than my local timezone
(Nearly) a solution
Update: I posted a working function in this answer.
The following functions seems to work for most test cases, however, it fails for 6 4 cases known to me (any help is greatly appreciated):
- [DST to ST]
now
in DST before double hour,newDate
in ST during double hour; - [DST to ST]
now
in DST during double hour,newDate
in ST during double hour; - [DST to ST]
now
in ST during double hour,newDate
in DST during double hour; - [DST to ST]
now
in ST after double hour,newDate
in DST during double hour; [ST to DST];now
in ST before skipped hour,newDate
in ST in skipped hour[ST to DST].now
in DST after skipped hour,newDate
in ST in skipped hour
I think the only missing piece is to find a way to check if a particular date in a non-UTC timezone falls into double hour. By double hour I mean a situation caused by changint DST to ST, i.e. setting our clock back an hour (e.g. at 3am to 2am → double hour is between 02:00:00.000
and 02:59:59.999
, which occur both in DST and ST).
/**
* Set time provided in a timezone
*
* @param {Date} [dto.date = new Date()] Date object to work with
* @param {number} [dto.time.h = 0] Hour to set
* @param {number} [dto.time.m = 0] Minute to set
* @param {number} [dto.time.s = 0] Second to set
* @param {number} [dto.time.ms = 0] Millisecond to set
* @param {string} [dto.timezone = 'Europe/Bratislava'] Timezone of `dto.time`
*
* @return {Date} Date object
*/
function setLocalTime(dto = {
date: new Date(),
// TODO: Rename the property to `{h, m, s, ms}`.
time: {h: 0, m: 0, ms: 0, s: 0},
timezone: 'Europe/Bratislava'
}) {
const defaultTime = {h: 0, m: 0, ms: 0, s: 0}
const defaultTimeKeys = Object.keys(defaultTime)
// src: https://stackoverflow.com/a/44118363/3408342
if (!Intl || !Intl.DateTimeFormat().resolvedOptions().timeZone) {
throw new Error('`Intl` API is not available or it does not contain a list of timezone identifiers in this environment')
}
if (!(dto.date instanceof Date)) {
throw Error('`date` must be a `Date` object.')
}
try {
Intl.DateTimeFormat(undefined, {timeZone: dto.timezone})
} catch (e) {
throw Error('`timezone` must be a valid IANA timezone.')
}
if (
typeof dto.time !== 'undefined'
&& typeof dto.time !== 'object'
&& dto.time instanceof Object
&& Object.keys(dto.time).every(v => defaultTimeKeys.indexOf(v) !== -1)
) {
throw Error('`time` must be an object of `{h: number, m: number, s: number, ms: number}` format, where numbers should be valid time values.')
}
dto.time = Object.assign({}, defaultTime, dto.time)
const getTimezoneOffsetHours = ({date, localisedDate, returnNumber, timezone}) => {
let offsetString
if (localisedDate) {
offsetString = localisedDate.find(i => i.type === 'timeZoneName').value.match(/[\d+:-]+$/)?.[0]
} else {
offsetString = new Intl
.DateTimeFormat('en-GB', {timeZone: timezone, timeZoneName: 'longOffset'})
.formatToParts(date)
.find(i => i.type === 'timeZoneName').value.match(/[\d+:-]+$/)?.[0]
}
return returnNumber ? offsetString.split(':').reduce((a, c) => /^[+-]/.test(c) ? +c * 60 : a + +c, 0) : offsetString
}
const pad = (n, len) => `00${n}`.slice(-len)
let [datePart, offset] = dto.date.toLocaleDateString('sv', {
timeZone: dto.timezone,
timeZoneName: 'longOffset'
}).split(/ GMT|\//)
offset = offset.replace(String.fromCharCode(8722), '-')
const newDateWithoutOffset = `${datePart}T${pad(dto.time.h || 0, 2)}:${pad(dto.time.m || 0, 2)}:${pad(dto.time.s || 0, 2)}.${pad(dto.time.ms || 0, 3)}`
let newDate = new Date(`${newDateWithoutOffset}${offset}`)
const newDateTimezoneOffsetHours = getTimezoneOffsetHours({date: newDate, timezone: dto.timezone})
// Check if timezones of `dto.date` and `newDate` match; if not, use the new timezone to re-create `newDate`
newDate = newDateTimezoneOffsetHours === offset
? newDate
: new Date(`${newDateWithoutOffset}${newDateTimezoneOffsetHours}`)
if (dto.time.h !== +new Intl.DateTimeFormat('en-GB', {hour: 'numeric', timeZone: dto.timezone}).formatToParts(newDate)?.[0].value) {
newDate = new Date('')
}
return newDate
}
const timezoneIana = 'Europe/Bratislava'
const tests = [
{
expString: '30/10/2022, 01:55:00 GMT+02:00',
now: new Date('2022-10-29T23:56:12.006Z'),
testName: '[DST to ST] `now` in DST before double hour, `newDate` in DST before double hour',
time: {h: 1, m: 55}
},
{
expString: '30/10/2022, 02:55:00 GMT+02:00',
now: new Date('2022-10-29T23:56:12.006Z'),
testName: '[DST to ST] `now` in DST before double hour, `newDate` in DST during double hour',
time: {h: 2, m: 55}
},
// FIXME
{
expString: '30/10/2022, 02:55:00 GMT+01:00',
now: new Date('2022-10-29T23:56:12.006Z'),
testName: '[DST to ST] `now` in DST before double hour, `newDate` in ST during double hour',
time: {h: 2, m: 55}
},
{
expString: '30/10/2022, 03:55:00 GMT+01:00',
now: new Date('2022-10-29T23:56:12.006Z'),
testName: '[DST to ST] `now` in DST before double hour, `newDate` in ST after double hour',
time: {h: 3, m: 55}
},
{
expString: '30/10/2022, 01:55:00 GMT+02:00',
now: new Date('2022-10-30T00:56:12.006Z'),
testName: '[DST to ST] `now` in DST during double hour, `newDate` in DST before double hour',
time: {h: 1, m: 55}
},
{
expString: '30/10/2022, 02:55:00 GMT+02:00',
now: new Date('2022-10-30T00:56:12.006Z'),
testName: '[DST to ST] `now` in DST during double hour, `newDate` in DST during double hour',
time: {h: 2, m: 55}
},
// FIXME
{
expString: '30/10/2022, 02:55:00 GMT+01:00',
now: new Date('2022-10-30T00:56:12.006Z'),
testName: '[DST to ST] `now` in DST during double hour, `newDate` in ST during double hour',
time: {h: 2, m: 55}
},
{
expString: '30/10/2022, 03:55:00 GMT+01:00',
now: new Date('2022-10-30T00:56:12.006Z'),
testName: '[DST to ST] `now` in DST during double hour, `newDate` in ST after double hour',
time: {h: 3, m: 55}
},
{
expString: '30/10/2022, 01:55:00 GMT+02:00',
now: new Date('2022-10-30T01:56:12.006Z'),
testName: '[DST to ST] `now` in ST during double hour, `newDate` in DST before double hour',
time: {h: 1, m: 55}
},
// FIXME
{
expString: '30/10/2022, 02:55:00 GMT+02:00',
now: new Date('2022-10-30T01:56:12.006Z'),
testName: '[DST to ST] `now` in ST during double hour, `newDate` in DST during double hour',
time: {h: 2, m: 55}
},
{
expString: '30/10/2022, 02:55:00 GMT+01:00',
now: new Date('2022-10-30T01:56:12.006Z'),
testName: '[DST to ST] `now` in ST during double hour, `newDate` in ST during double hour',
time: {h: 2, m: 55}
},
{
expString: '30/10/2022, 03:55:00 GMT+01:00',
now: new Date('2022-10-30T01:56:12.006Z'),
testName: '[DST to ST] `now` in ST during double hour, `newDate` in ST after double hour',
time: {h: 3, m: 55}
},
{
expString: '30/10/2022, 01:55:00 GMT+02:00',
now: new Date('2022-10-30T02:56:12.006Z'),
testName: '[DST to ST] `now` in ST after double hour, `newDate` in DST before double hour',
time: {h: 1, m: 55}
},
// FIXME
{
expString: '30/10/2022, 02:55:00 GMT+02:00',
now: new Date('2022-10-30T02:56:12.006Z'),
testName: '[DST to ST] `now` in ST after double hour, `newDate` in DST during double hour',
time: {h: 2, m: 55}
},
{
expString: '30/10/2022, 02:55:00 GMT+01:00',
now: new Date('2022-10-30T02:56:12.006Z'),
testName: '[DST to ST] `now` in ST after double hour, `newDate` in ST during double hour',
time: {h: 2, m: 55}
},
{
expString: '30/10/2022, 03:55:00 GMT+01:00',
now: new Date('2022-10-30T02:56:12.006Z'),
testName: '[DST to ST] `now` in ST after double hour, `newDate` in ST after double hour',
time: {h: 3, m: 55}
},
{
expString: '26/03/2023, 01:55:00 GMT+01:00',
now: new Date('2023-03-26T00:56:12.006Z'),
testName: '[ST to DST] `now` in ST before skipped hour, `newDate` in ST before skipped hour',
time: {h: 1, m: 55}
},
// FIXME
{
expString: 'Invalid Date',
now: new Date('2023-03-26T00:56:12.006Z'),
testName: '[ST to DST] `now` in ST before skipped hour, `newDate` in ST in skipped hour',
time: {h: 2, m: 55}
},
{
expString: '26/03/2023, 03:55:00 GMT+02:00',
now: new Date('2023-03-26T00:56:12.006Z'),
testName: '[ST to DST] `now` in ST before skipped hour, `newDate` in DST after skipped hour',
time: {h: 3, m: 55}
},
{
expString: '26/03/2023, 01:55:00 GMT+01:00',
now: new Date('2023-03-26T01:56:12.006Z'),
testName: '[ST to DST] `now` in DST after skipped hour, `newDate` in ST before skipped hour',
time: {h: 1, m: 55}
},
// FIXME
{
expString: 'Invalid Date',
now: new Date('2023-03-26T01:56:12.006Z'),
testName: '[ST to DST] `now` in DST after skipped hour, `newDate` in ST in skipped hour',
time: {h: 2, m: 55}
},
{
expString: '26/03/2023, 03:55:00 GMT+02:00',
now: new Date('2023-03-26T01:56:12.006Z'),
testName: '[ST to DST] `now` in DST after skipped hour, `newDate` in DST after skipped hour',
time: {h: 3, m: 55}
}
// TODO: Add a test of a date in DST and ST on a day on which there is no timezone change (two tests in total, one for DST and another for ST).
]
const results = tests.map(t => {
const newDate = setLocalTime({date: t.now, time: t.time, timezone: timezoneIana})
const newDateString = newDate.toLocaleString('en-GB', {timeZone: timezoneIana, timeZoneName: 'longOffset'})
const testResult = newDateString === t.expString
if (testResult) {
console.log(testResult, `: ${t.testName} : ${newDateString}`)
} else {
console.log(testResult, `: ${t.testName} : ${newDateString} :`, {newDate, newDateString, test: t})
}
return testResult
}).reduce((a, c, i) => {
if (c) {
a.passed++
} else {
a.failed++
a.failedTestIds.push(i)
}
return a
}, {failed: 0, failedTestIds: [], passed: 0})
console.log(results)