0

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)
tukusejssirs
  • 564
  • 1
  • 7
  • 29
  • https://github.com/mde/timezone-js Should be able to create any time and zone you want isn’t it? – Thallius Oct 29 '22 at 08:43
  • (1) That repo is archived and the package is deprecated, so I am hesitant to you it. (2) Te issue is not about _creating a date in any timezone_. It is about change the time bit of a UTC date object, while the time is is a non-UTC timezone, e.g. I want to change the time to `10` hours and `15` minutes in UTC+2, which should auto-convert the time to `8` hours and `15` minutes UTC and set those values instead. – tukusejssirs Oct 29 '22 at 09:21

3 Answers3

1

The following follows your method of getting the date and offset in the target timezone then using it with the time parts to generate a timestamp that is parsed by the built–in parser.

It doesn't fix the issue of the initial date and the result crossing an offset (likely DST), it's just a bit less code. You really should use a suitable library.

E.g.

function setLocalTime(
  date = new Date(),
  time = {h: 0, m: 0, s: 0, ms: 0},
  tz = 'Europe/Bratislava') {
  
  let {h, m, s, ms} = time;
  let z = (n,len) => ('00'+n).slice(-len);

  let [datePart, offset] = date.toLocaleDateString('sv', {
    timeZone:tz, timeZoneName:'longOffset'
  }).split(' GMT');

  let timestamp = `${datePart}T` +
    `${z(h||0,2)}:${z(m||0,2)}:${z(s||0,2)}.${z(ms||0,3)}` +
    `${offset.replace(String.fromCharCode(8722),'-')}`;

  return new Date(timestamp);
}

let tz, d;
tz = 'Europe/Bratislava';
d = setLocalTime(new Date(), {h: 15, m: 30, s: 0, ms: 0}, tz);
console.log('UTC: ' + d.toISOString());
console.log(tz + ': ' + d.toLocaleString('en-gb',{timeZone: tz, timeZoneName: 'long'}));

tz = 'America/New_York';
d = setLocalTime(new Date(), {h: 15, m: 30, s: 0, ms: 0}, tz);
console.log('UTC: '+ d.toISOString());
console.log(tz + ': ' + d.toLocaleString('en-gb',{timeZone: tz, timeZoneName: 'long'}));

In Safari, "−" character (charCode 8722) in a negative offset isn't parsed correctly so it's replaced with a hyphen. Also, timeZoneName:'longOffset' may not be that widely supported yet.

RobG
  • 142,382
  • 31
  • 172
  • 209
  • Thanks, @RobG, for your time and help! It is actually an improvement, though, I still want to fix some issues (see the latest update in the question) before I consider this question resolved. – tukusejssirs Oct 31 '22 at 16:46
  • Also a note: I want to use this on the backend in a Node.js app, thus browser compatibility is not an issue to me, however, it might be helpful to others. :) – tukusejssirs Oct 31 '22 at 16:47
  • Anyway, your function (as it is) fails for the following tests from `tests` array (identified by their index; counting only uncommented tests) from the question: `[2, 5, 6, 9, 13, 14]` (see [JSFiddle](https://jsfiddle.net/tukusejssirs/5qk71ang/1/)). – tukusejssirs Oct 31 '22 at 17:15
  • In order to fix your function, we need to add a _check if timezones of `dto.date` and `newDate` match; if not, use the new timezone to re-create `newDate`_; see my function (I’ve fixed your function at [JSFiddle](https://jsfiddle.net/tukusejssirs/5qk71ang/3/), albeit I simply copy-pasted the required parts from my function). – tukusejssirs Oct 31 '22 at 17:33
0

In JavaScript the Date objects usually expect time definitions in the local (browser) time and store it in UTC time. You can explicitly set a time in the UTC timezone with the .setUTC<time part>() methods. You can set the time hours for the timezone GMT+2 by using the .setUTCHours() function with an argument of hours-2.

const d=new Date(),hours=10,minutes=40;
d.setUTCHours(hours-2);d.setUTCMinutes(minutes);
console.log("GMT/UTC:",d); // UTC time ("Z")
console.log("New York:",d.toLocaleString("en-US",{timeZone:"America/New_York"})); // local time US
console.log("Berlin:",d.toLocaleString("de-DE",{timeZone:"Europe/Berlin"})); // local time Germany (GMT+2)

The method .setUTCMinutes() and setMinutes() will in most cases achieve the same result. One notable exception is the time zone "Asia/Kolkata" which will apply a 30 minutes offset.

Carsten Massmann
  • 26,510
  • 2
  • 22
  • 43
  • The issue is about _updating_ a Date object, not about getting a date string. And I don’t want to hard-code an timezone offset (`2` for `UTC+02:00` in your example). I want to make it work for any timezone, as I want a user of my app set their timezone and set any time-related stuff in their timezone, however, when I work with the dates in the backend, I want to use UTC. – tukusejssirs Oct 29 '22 at 12:31
  • According to [this answer](https://stackoverflow.com/a/9370143/2610061) you can enquire about the current time offset of your local timezone relative to UTC but you cannot set it in JavaScript. – Carsten Massmann Oct 29 '22 at 12:48
  • @Carsten_Massmann, did you see my updated question? Yes, you can set an offset in pure JS: `new Date('2022-10-29T12:50:00.000+02:00')` will contain `2022-10-29T10:50:00.000Z`. You just have to always specify the timezone offset in `/^[+-][0-2]\d:[0-5]\d$/` format. – tukusejssirs Oct 29 '22 at 12:53
  • Well, yes, you did well in defining your own `setLocalTime()` function which hopefully gets you what you want , but you have not changed the actual timezone settings for JavaScript date objects in your browser. – Carsten Massmann Oct 29 '22 at 13:09
  • _you have not changed the actual timezone settings for JavaScript date objects in your browser_: That was not my intention. :) … Anyway, do you see any issue in `setLocalTime()`? Could improve it? ;) – tukusejssirs Oct 29 '22 at 13:28
  • Okay, I’ve updated my function (based on your function, however, I kept some stuff in comparison with yours) in the question and also fixed two additional test cases (still four to go). – tukusejssirs Oct 31 '22 at 18:53
0

I created the following function that sets the time in a local timezone, for example, if you have Date object and you want to change its time (but not date in a particular timezone, regardless of the UTC date of that time), you provide this function with that Date object, the required time, timezone and optionally whether you prefer using the new timezone (see below).

I had to add preferNewTimezone parameter because off so-called double hours (hours in a non-UTC timezone that occur twice because of setting the clock back by an hour), as those failed to output the date (including timezone offset) I expected.

I don’t say it is fast nor perfect, however, it works. :)

I have created 52 tests of this function in Europe/Bratislava timezone. They all pass. I presume it’ll work for any timezone. If not and you find an issue or have an improvement/optimisation, I want to hear about it.

/**
 * Set time provided in a timezone
 *
 * @param      {Date}     [dto.date = new Date()]          Date object to work with
 * @param      {boolean}  [dto.preferNewTimezone = false]  Whether to prefer new timezone for double hours
 * @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(),
  preferNewTimezone: false,
  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.includes(v))
  ) {
    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)

  /**
   * Whether a date falls in double hour in a particular timezone
   *
   * @param      {Date}    dto.date      Date
   * @param      {string}  dto.timezone  IANA timezone
   * @return     {boolean}  `true` if the specified Date falls in double hour in a particular timezone, `false` otherwise
   */
  const isInDoubleHour = (dto = {date: new Date(), timezone: 'Europe/Bratislava'}) => {
    // Get hour in `dto.timezone` of timezones an hour before and after `dto.date`
    const hourBeforeHour = +new Intl.DateTimeFormat('en-GB', {hour: 'numeric', timeZone: dto.timezone}).formatToParts(new Date(new Date(dto.date).setUTCHours(dto.date.getUTCHours() - 1)))?.[0].value
    const hourDateHour = +new Intl.DateTimeFormat('en-GB', {hour: 'numeric', timeZone: dto.timezone}).formatToParts(dto.date)?.[0].value
    const hourAfterHour = +new Intl.DateTimeFormat('en-GB', {hour: 'numeric', timeZone: dto.timezone}).formatToParts(new Date(new Date(dto.date).setUTCHours(dto.date.getUTCHours() + 1)))?.[0].value

    return hourBeforeHour === hourDateHour || hourAfterHour === hourDateHour
  }

  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
  }

  /**
   * Pad a number with zeros from left to a required length
   *
   * @param      {number}  n    Number
   * @param      {number}  len  Length
   * @return     {string}  Padded number
   */
  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}`)

  // Invalidate the date in `newDate` when the hour defined by user is not the same as the hour of `newDate` formatted in the user-defined timezone
  if (dto.time.h !== +new Intl.DateTimeFormat('en-GB', {hour: 'numeric', timeZone: dto.timezone}).formatToParts(newDate)?.[0].value) {
    newDate = new Date('')
  }

  // Check if the user prefers using the new timezone when `newDate` is in double hour
  newDate = dto.preferNewTimezone && !isNaN(newDate) && isInDoubleHour({date: newDate, timezone: dto.timezone})
    ? new Date(`${newDateWithoutOffset}${getTimezoneOffsetHours({date: new Date(new Date(newDate).setUTCHours(dto.date.getUTCHours() + 1)), timezone: dto.timezone})}`)
    : newDate

  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'),
    preferNewTimezone: false,
    testName: '[DST to ST] `now` in DST before double  hour, `newDate` in DST before double  hour [don\'t prefer new timezone]',
    time: {h: 1, m: 55}
  },
  {
    expString: '30/10/2022, 02:55:00 GMT+02:00',
    now: new Date('2022-10-29T23:56:12.006Z'),
    preferNewTimezone: false,
    testName: '[DST to ST] `now` in DST before double  hour, `newDate` in DST during double  hour [don\'t prefer new timezone]',
    time: {h: 2, m: 55}
  },
  {
    expString: '30/10/2022, 02:55:00 GMT+02:00',
    now: new Date('2022-10-29T23:56:12.006Z'),
    preferNewTimezone: false,
    testName: '[DST to ST] `now` in DST before double  hour, `newDate` in DST during double  hour [don\'t prefer new timezone]',
    time: {h: 2, m: 55}
  },
  {
    expString: '30/10/2022, 03:55:00 GMT+01:00',
    now: new Date('2022-10-29T23:56:12.006Z'),
    preferNewTimezone: false,
    testName: '[DST to ST] `now` in DST before double  hour, `newDate` in  ST after  double  hour [don\'t prefer new timezone]',
    time: {h: 3, m: 55}
  },
  {
    expString: '30/10/2022, 01:55:00 GMT+02:00',
    now: new Date('2022-10-30T00:56:12.006Z'),
    preferNewTimezone: false,
    testName: '[DST to ST] `now` in DST during double  hour, `newDate` in DST before double  hour [don\'t prefer new timezone]',
    time: {h: 1, m: 55}
  },
  {
    expString: '30/10/2022, 02:55:00 GMT+02:00',
    now: new Date('2022-10-30T00:56:12.006Z'),
    preferNewTimezone: false,
    testName: '[DST to ST] `now` in DST during double  hour, `newDate` in DST during double  hour [don\'t prefer new timezone]',
    time: {h: 2, m: 55}
  },
  {
    expString: '30/10/2022, 02:55:00 GMT+02:00',
    now: new Date('2022-10-30T00:56:12.006Z'),
    preferNewTimezone: false,
    testName: '[DST to ST] `now` in DST during double  hour, `newDate` in DST during double  hour [don\'t prefer new timezone]',
    time: {h: 2, m: 55}
  },
  {
    expString: '30/10/2022, 03:55:00 GMT+01:00',
    now: new Date('2022-10-30T00:56:12.006Z'),
    preferNewTimezone: false,
    testName: '[DST to ST] `now` in DST during double  hour, `newDate` in  ST after  double  hour [don\'t prefer new timezone]',
    time: {h: 3, m: 55}
  },
  {
    expString: '30/10/2022, 01:55:00 GMT+02:00',
    now: new Date('2022-10-30T01:56:12.006Z'),
    preferNewTimezone: false,
    testName: '[DST to ST] `now` in  ST during double  hour, `newDate` in DST before double  hour [don\'t prefer new timezone]',
    time: {h: 1, m: 55}
  },
  {
    expString: '30/10/2022, 02:55:00 GMT+01:00',
    now: new Date('2022-10-30T01:56:12.006Z'),
    preferNewTimezone: false,
    testName: '[DST to ST] `now` in  ST during double  hour, `newDate` in  ST during double  hour [don\'t prefer new timezone]',
    time: {h: 2, m: 55}
  },
  {
    expString: '30/10/2022, 02:55:00 GMT+01:00',
    now: new Date('2022-10-30T01:56:12.006Z'),
    preferNewTimezone: false,
    testName: '[DST to ST] `now` in  ST during double  hour, `newDate` in  ST during double  hour [don\'t prefer new timezone]',
    time: {h: 2, m: 55}
  },
  {
    expString: '30/10/2022, 03:55:00 GMT+01:00',
    now: new Date('2022-10-30T01:56:12.006Z'),
    preferNewTimezone: false,
    testName: '[DST to ST] `now` in  ST during double  hour, `newDate` in  ST after  double  hour [don\'t prefer new timezone]',
    time: {h: 3, m: 55}
  },
  {
    expString: '30/10/2022, 01:55:00 GMT+02:00',
    now: new Date('2022-10-30T02:56:12.006Z'),
    preferNewTimezone: false,
    testName: '[DST to ST] `now` in  ST after  double  hour, `newDate` in DST before double  hour [don\'t prefer new timezone]',
    time: {h: 1, m: 55}
  },
  {
    expString: '30/10/2022, 02:55:00 GMT+01:00',
    now: new Date('2022-10-30T02:56:12.006Z'),
    preferNewTimezone: false,
    testName: '[DST to ST] `now` in  ST after  double  hour, `newDate` in  ST during double  hour [don\'t prefer new timezone]',
    time: {h: 2, m: 55}
  },
  {
    expString: '30/10/2022, 02:55:00 GMT+01:00',
    now: new Date('2022-10-30T02:56:12.006Z'),
    preferNewTimezone: false,
    testName: '[DST to ST] `now` in  ST after  double  hour, `newDate` in  ST during double  hour [don\'t prefer new timezone]',
    time: {h: 2, m: 55}
  },
  {
    expString: '30/10/2022, 03:55:00 GMT+01:00',
    now: new Date('2022-10-30T02:56:12.006Z'),
    preferNewTimezone: false,
    testName: '[DST to ST] `now` in  ST after  double  hour, `newDate` in  ST after  double  hour [don\'t prefer new timezone]',
    time: {h: 3, m: 55}
  },
  {
    expString: '26/03/2023, 01:55:00 GMT+01:00',
    now: new Date('2023-03-26T00:56:12.006Z'),
    preferNewTimezone: false,
    testName: '[ST to DST] `now` in  ST before skipped hour, `newDate` in  ST before skipped hour [don\'t prefer new timezone]',
    time: {h: 1, m: 55}
  },
  {
    expString: 'Invalid Date',
    now: new Date('2023-03-26T00:56:12.006Z'),
    preferNewTimezone: false,
    testName: '[ST to DST] `now` in  ST before skipped hour, `newDate` in  ST in skipped hour     [don\'t prefer new timezone]',
    time: {h: 2, m: 55}
  },
  {
    expString: '26/03/2023, 03:55:00 GMT+02:00',
    now: new Date('2023-03-26T00:56:12.006Z'),
    preferNewTimezone: false,
    testName: '[ST to DST] `now` in  ST before skipped hour, `newDate` in DST after  skipped hour [don\'t prefer new timezone]',
    time: {h: 3, m: 55}
  },
  {
    expString: '26/03/2023, 01:55:00 GMT+01:00',
    now: new Date('2023-03-26T01:56:12.006Z'),
    preferNewTimezone: false,
    testName: '[ST to DST] `now` in DST after  skipped hour, `newDate` in  ST before skipped hour [don\'t prefer new timezone]',
    time: {h: 1, m: 55}
  },
  {
    expString: 'Invalid Date',
    now: new Date('2023-03-26T01:56:12.006Z'),
    preferNewTimezone: false,
    testName: '[ST to DST] `now` in DST after  skipped hour, `newDate` in  ST in skipped hour     [don\'t prefer new timezone]',
    time: {h: 2, m: 55}
  },
  {
    expString: '26/03/2023, 03:55:00 GMT+02:00',
    now: new Date('2023-03-26T01:56:12.006Z'),
    preferNewTimezone: false,
    testName: '[ST to DST] `now` in DST after  skipped hour, `newDate` in DST after  skipped hour [don\'t prefer new timezone]',
    time: {h: 3, m: 55}
  },
  {
    expString: '30/10/2022, 01:55:00 GMT+02:00',
    now: new Date('2022-10-29T23:56:12.006Z'),
    preferNewTimezone: true,
    testName: '[DST to ST] `now` in DST before double  hour, `newDate` in DST before double  hour [prefer new timezone]      ',
    time: {h: 1, m: 55}
  },
  {
    expString: '30/10/2022, 02:55:00 GMT+01:00',
    now: new Date('2022-10-29T23:56:12.006Z'),
    preferNewTimezone: true,
    testName: '[DST to ST] `now` in DST before double  hour, `newDate` in  ST during double  hour [prefer new timezone]      ',
    time: {h: 2, m: 55}
  },
  {
    expString: '30/10/2022, 02:55:00 GMT+01:00',
    now: new Date('2022-10-29T23:56:12.006Z'),
    preferNewTimezone: true,
    testName: '[DST to ST] `now` in DST before double  hour, `newDate` in  ST during double  hour [prefer new timezone]      ',
    time: {h: 2, m: 55}
  },
  {
    expString: '30/10/2022, 03:55:00 GMT+01:00',
    now: new Date('2022-10-29T23:56:12.006Z'),
    preferNewTimezone: true,
    testName: '[DST to ST] `now` in DST before double  hour, `newDate` in  ST after  double  hour [prefer new timezone]      ',
    time: {h: 3, m: 55}
  },
  {
    expString: '30/10/2022, 01:55:00 GMT+02:00',
    now: new Date('2022-10-30T00:56:12.006Z'),
    preferNewTimezone: true,
    testName: '[DST to ST] `now` in DST during double  hour, `newDate` in DST before double  hour [prefer new timezone]      ',
    time: {h: 1, m: 55}
  },
  {
    expString: '30/10/2022, 02:55:00 GMT+01:00',
    now: new Date('2022-10-30T00:56:12.006Z'),
    preferNewTimezone: true,
    testName: '[DST to ST] `now` in DST during double  hour, `newDate` in  ST during double  hour [prefer new timezone]      ',
    time: {h: 2, m: 55}
  },
  {
    expString: '30/10/2022, 02:55:00 GMT+01:00',
    now: new Date('2022-10-30T00:56:12.006Z'),
    preferNewTimezone: true,
    testName: '[DST to ST] `now` in DST during double  hour, `newDate` in  ST during double  hour [prefer new timezone]      ',
    time: {h: 2, m: 55}
  },
  {
    expString: '30/10/2022, 03:55:00 GMT+01:00',
    now: new Date('2022-10-30T00:56:12.006Z'),
    preferNewTimezone: true,
    testName: '[DST to ST] `now` in DST during double  hour, `newDate` in  ST after  double  hour [prefer new timezone]      ',
    time: {h: 3, m: 55}
  },
  {
    expString: '30/10/2022, 01:55:00 GMT+02:00',
    now: new Date('2022-10-30T01:56:12.006Z'),
    preferNewTimezone: true,
    testName: '[DST to ST] `now` in  ST during double  hour, `newDate` in DST before double  hour [prefer new timezone]      ',
    time: {h: 1, m: 55}
  },
  {
    expString: '30/10/2022, 02:55:00 GMT+01:00',
    now: new Date('2022-10-30T01:56:12.006Z'),
    preferNewTimezone: true,
    testName: '[DST to ST] `now` in  ST during double  hour, `newDate` in  ST during double  hour [prefer new timezone]      ',
    time: {h: 2, m: 55}
  },
  {
    expString: '30/10/2022, 02:55:00 GMT+01:00',
    now: new Date('2022-10-30T01:56:12.006Z'),
    preferNewTimezone: true,
    testName: '[DST to ST] `now` in  ST during double  hour, `newDate` in  ST during double  hour [prefer new timezone]      ',
    time: {h: 2, m: 55}
  },
  {
    expString: '30/10/2022, 03:55:00 GMT+01:00',
    now: new Date('2022-10-30T01:56:12.006Z'),
    preferNewTimezone: true,
    testName: '[DST to ST] `now` in  ST during double  hour, `newDate` in  ST after  double  hour [prefer new timezone]      ',
    time: {h: 3, m: 55}
  },
  {
    expString: '30/10/2022, 01:55:00 GMT+02:00',
    now: new Date('2022-10-30T02:56:12.006Z'),
    preferNewTimezone: true,
    testName: '[DST to ST] `now` in  ST after  double  hour, `newDate` in DST before double  hour [prefer new timezone]      ',
    time: {h: 1, m: 55}
  },
  {
    expString: '30/10/2022, 02:55:00 GMT+01:00',
    now: new Date('2022-10-30T02:56:12.006Z'),
    preferNewTimezone: true,
    testName: '[DST to ST] `now` in  ST after  double  hour, `newDate` in  ST during double  hour [prefer new timezone]      ',
    time: {h: 2, m: 55}
  },
  {
    expString: '30/10/2022, 02:55:00 GMT+01:00',
    now: new Date('2022-10-30T02:56:12.006Z'),
    preferNewTimezone: true,
    testName: '[DST to ST] `now` in  ST after  double  hour, `newDate` in  ST during double  hour [prefer new timezone]      ',
    time: {h: 2, m: 55}
  },
  {
    expString: '30/10/2022, 03:55:00 GMT+01:00',
    now: new Date('2022-10-30T02:56:12.006Z'),
    preferNewTimezone: true,
    testName: '[DST to ST] `now` in  ST after  double  hour, `newDate` in  ST after  double  hour [prefer new timezone]      ',
    time: {h: 3, m: 55}
  },
  {
    expString: '26/03/2023, 01:55:00 GMT+01:00',
    now: new Date('2023-03-26T00:56:12.006Z'),
    preferNewTimezone: true,
    testName: '[ST to DST] `now` in  ST before skipped hour, `newDate` in  ST before skipped hour [prefer new timezone]      ',
    time: {h: 1, m: 55}
  },
  {
    expString: 'Invalid Date',
    now: new Date('2023-03-26T00:56:12.006Z'),
    preferNewTimezone: true,
    testName: '[ST to DST] `now` in  ST before skipped hour, `newDate` in  ST in skipped hour     [prefer new timezone]      ',
    time: {h: 2, m: 55}
  },
  {
    expString: '26/03/2023, 03:55:00 GMT+02:00',
    now: new Date('2023-03-26T00:56:12.006Z'),
    preferNewTimezone: true,
    testName: '[ST to DST] `now` in  ST before skipped hour, `newDate` in DST after  skipped hour [prefer new timezone]      ',
    time: {h: 3, m: 55}
  },
  {
    expString: '26/03/2023, 01:55:00 GMT+01:00',
    now: new Date('2023-03-26T01:56:12.006Z'),
    preferNewTimezone: true,
    testName: '[ST to DST] `now` in DST after  skipped hour, `newDate` in  ST before skipped hour [prefer new timezone]      ',
    time: {h: 1, m: 55}
  },
  {
    expString: 'Invalid Date',
    now: new Date('2023-03-26T01:56:12.006Z'),
    preferNewTimezone: true,
    testName: '[ST to DST] `now` in DST after  skipped hour, `newDate` in  ST in skipped hour     [prefer new timezone]      ',
    time: {h: 2, m: 55}
  },
  {
    expString: '26/03/2023, 03:55:00 GMT+02:00',
    now: new Date('2023-03-26T01:56:12.006Z'),
    preferNewTimezone: true,
    testName: '[ST to DST] `now` in DST after  skipped hour, `newDate` in DST after  skipped hour [prefer new timezone]      ',
    time: {h: 3, m: 55}
  },
  {
    expString: '01/01/2023, 03:55:00 GMT+01:00',
    now: new Date('2023-01-01T10:00:00.000Z'),
    preferNewTimezone: true,
    testName: '[ST] `now` in  ST, `newDate` in  ST before `now` [prefer new timezone]      ',
    time: {h: 3, m: 55}
  },
  {
    expString: '01/01/2023, 12:00:00 GMT+01:00',
    now: new Date('2023-01-01T10:00:00.000Z'),
    preferNewTimezone: true,
    testName: '[ST] `now` in  ST, `newDate` in  ST after  `now` [prefer new timezone]      ',
    time: {h: 12}
  },
  {
    expString: '01/07/2023, 03:55:00 GMT+02:00',
    now: new Date('2023-07-01T10:00:00.000Z'),
    preferNewTimezone: true,
    testName: '[ST] `now` in DST, `newDate` in DST before `now` [prefer new timezone]      ',
    time: {h: 3, m: 55}
  },
  {
    expString: '01/07/2023, 12:00:00 GMT+02:00',
    now: new Date('2023-07-01T10:00:00.000Z'),
    preferNewTimezone: true,
    testName: '[ST] `now` in DST, `newDate` in DST after  `now` [prefer new timezone]      ',
    time: {h: 12}
  },
  {
    expString: '01/01/2023, 03:55:00 GMT+01:00',
    now: new Date('2023-01-01T10:00:00.000Z'),
    preferNewTimezone: false,
    testName: '[ST] `now` in  ST, `newDate` in  ST before `now` [don\'t prefer new timezone]',
    time: {h: 3, m: 55}
  },
  {
    expString: '01/01/2023, 12:00:00 GMT+01:00',
    now: new Date('2023-01-01T10:00:00.000Z'),
    preferNewTimezone: false,
    testName: '[ST] `now` in  ST, `newDate` in  ST after  `now` [don\'t prefer new timezone]',
    time: {h: 12}
  },
  {
    expString: '01/07/2023, 03:55:00 GMT+02:00',
    now: new Date('2023-07-01T10:00:00.000Z'),
    preferNewTimezone: false,
    testName: '[ST] `now` in DST, `newDate` in DST before `now` [don\'t prefer new timezone]',
    time: {h: 3, m: 55}
  },
  {
    expString: '01/07/2023, 12:00:00 GMT+02:00',
    now: new Date('2023-07-01T10:00:00.000Z'),
    preferNewTimezone: false,
    testName: '[ST] `now` in DST, `newDate` in DST after  `now` [don\'t prefer new timezone]',
    time: {h: 12}
  }
]

const results = tests.map(t => {
  const newDate = setLocalTime({date: t.now, preferNewTimezone: t.preferNewTimezone, 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)
tukusejssirs
  • 564
  • 1
  • 7
  • 29
  • 1
    Instead of generating timestamps then parsing them, consider [*formatToParts*](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/formatToParts). Pretty sure this will fail around DST changeovers since it uses local methods to apply changes to *Dates* that are attempting to represent dates and times at other locations. – RobG Oct 29 '22 at 23:53
  • [A] `formatToParts()`: Well, yeah I could use it, however, unfortunately the output array does not contain timezone (TZ) offset information. Moreover, I am not really interested in _formatting_ a `Date` object to a string, however, to _set time in a non-UTC TZ into a `Date` object (which is stored in UTC) regardless of the local TZ_. Do you still think `formatToParts()` could improve my function? I might misunderstand you suggestion. – tukusejssirs Oct 30 '22 at 11:16
  • [B] Yes, there are some DST issues: (1) when one a single day, there are two timestamps in different TZ offsets (one with DST, the other without), e.g. on 30 Oct 2022 in Slovakia, we changed from CEST (DST) to CET (non-DST). – tukusejssirs Oct 30 '22 at 11:16
  • (2) I have no idea how to deal with double hours when we fall back from a DST to ST. I have found this discussion (see the next link) at the QT forum that asks this very same question. A user [suggested](https://forum.qt.io/post/397516) to check if a `date` is in DST and if `dateMinusOneHour` is also in DST, consider both dates a double date. I believe there need to be other conditions added [e.g. are both dates on the same day in a particular TZ? is `date` and the both previous/next DST change within one hour range?], but also it might not be easy to check in JS if a date in a TZ is in DST. – tukusejssirs Oct 30 '22 at 11:16
  • (3) We should also take care of ST changing to DST: an hour is skipped (e.g. 1am to 2am or 2am to 3am), thus when a user requests e.g. 1.30am (2.30am) on the day of the ST-to-DST change, we should either `throw` an error or autoatically resolve it to +1 hour. – tukusejssirs Oct 30 '22 at 12:11
  • 1
    *formatToParts* will contain offset information if you include appropriate options in the options object (exactly as you're doing already). You are using *Intl* to generate a timestamp, that you then *split* into parts, reformat and generate a new *Date*. *formatToParts* can replace *split*. – RobG Oct 30 '22 at 20:42
  • You’re right, @RobG, it contains the timezone offset too (I misread the docs somehow), however, it seems to me that the command to get the offset only is much longer than when using `format()`: (1) `formatToParts()`: `new Intl.DateTimeFormat('en-GB', {timeZone: 'Europe/Bratislava', timeZoneName: 'longOffset'}).formatToParts(new Date()).find(i => i.type === 'timeZoneName').value.match(/[\d+:-]+$/)?.[0]`; (2) `format()`: `new Intl.DateTimeFormat('en-GB', {timeZone: 'Europe/Bratislava', timeZoneName: 'longOffset'}).format(new Date).match(/[\d+:-]+$/)?.[0]`. You might find a better/simpler way. – tukusejssirs Oct 30 '22 at 21:15
  • @RobG, maybe you can help me debug my latest version of the `setLocalTime()` function, which is in the lated update of the question. For some reason, the function now returns a string instead of assumed `Date` object. – tukusejssirs Oct 30 '22 at 22:37
  • 1
    *formatToParts* returns an array that I usually turn into an object like `new Int.DateTimeFormat(...).formatToParts(date).reduce((acc, part) => {acc[part.type] = part.value; return acc;}, Object.create(null)` so that you have an object with named values (the separator is trashed but that's not really an issue). But you still have the issue of adding time in the context of a timezone other than the host or UTC. For that, a suitable library is the go. If *day.js* doesn't do it for you, pick another. (maybe Luxon) or just shim the Temporal object. – RobG Oct 31 '22 at 04:04
  • "*For some reason, the function now returns a string…* no it doesn't. *setLocalTime* returns a *Date* object. – RobG Oct 31 '22 at 04:56