Monday 20 November 2023

Adding Durations to dates in a manner stable across timezones

I'm currently working with software that does computations with date-fns Durations both on the server- and client-side.

This software gathers data for a time window that is specified using Durations from a URL. The intention then is to gather data and perform computations on both sides for the same time window.

Now because of DST there are cases where these windows do not align when adding the Durations to a current date on either end.

For example when computing add(new Date('2023-11-13T10:59:13.371Z'), { days: -16 }) in UTC the computation arrives at 2023-10-28T10:59:13.371Z, but a browser in CET will arrive at 2023-10-28T09:59:13.371Z instead.

Attempted solution

I've been trying to conjure up a special addDuration function to add durations the way UTC does it in the hope of obtaining a reproducible way to apply durations independent of Browsers. However (because time is hard) this appears quite hard to get right and I'm not sure it is even entirely possible with what we've got. (I wish temporal was ready to aid me in this.)

So I came up with this function:

const addDuration = (date, delta) => {
  const { years = 0, months = 0, weeks = 0, days = 0, hours = 0, minutes = 0, seconds = 0 } = delta

  const dateWithCalendarDelta = add(date, { months, years, days, weeks })
  const tzDelta = date.getTimezoneOffset() - dateWithCalendarDelta.getTimezoneOffset()

  return add(dateWithCalendarDelta, { hours, minutes: minutes + tzDelta, seconds })
}

I then went on to test it with several examples and print outputs like this:

console.table(
  examples.map(({ start, delta, utc }) => {
    const add1 = add(new Date(start), delta)
    const ok1 = add1.toISOString() === utc ? '✅' : '❌'
    const add2 = addDuration(new Date(start), delta)
    const ok2 = add2.toISOString() === utc ? '✅' : '❌'

    return { start: new Date(start), delta, utc: new Date(utc), add1, ok1, add2, ok2 }
  }),
)

With this I went ahead and executed the code with different TZ environment variables:

Output of TZ=UTC node example.js: Screenshot of TZ=UTC node example.js

Output of TZ=CET node example.js: Screenshot of TZ=CET node example.js

Here in the add2 column we see how addDuration behaves and a ✅ is displayed in the ok2 column when it matches the UTC output. Similarly add1 is the behaviour of the typical date-fns/add function.

Open ends

I'd like to specifically learn more about these aspects:

  • Is it generally possible to apply Durations to a Date in Browsers without shipping a whole dump of different timezone data?
    • Is there a simple way to correct the broken case for addDuration in TZ=CET?
  • Is there an easy/simple way to achieve the desired outcome using date-fns? Maybe I've just overlooked something?
  • Is what I'm trying here a bad idea for some reason and I just struggle to understand that?

I think I want this:

A pure function to apply Durations (deltas) to a Date independent of the local timezone. Ideally it should work the same as UTC, but that feels secondary to working the same across different browsers.

I'm under the impression that this is hindered to some extent by how Date in JavaScript behaves dependent on the local TZ.

Existence of such a function would - I think - imply that a statement such as 'yesterday' or '1 year ago' could be interpreted in a way that makes sense independent of the local TZ and independent of DST.

I know that it would be possible to gloss over the facts of how many days the current year or month have exactly and 'just' compute a number of hours for this, to then accept the same delta for all - but I'd like things like { months: -1 } to work in a way that makes 'sense' for humans if possible.

Related notes

Complete example

Here's the complete example.js source:

// const add = require('date-fns/add')

const examples = [{
    start: '2023-10-29T03:00:00.000Z',
    delta: {
      hours: 0
    },
    utc: '2023-10-29T03:00:00.000Z',
  },
  {
    start: '2023-10-29T03:00:00.000Z',
    delta: {
      hours: -1
    },
    utc: '2023-10-29T02:00:00.000Z',
  },
  {
    start: '2023-10-29T03:00:00.000Z',
    delta: {
      hours: -2
    },
    utc: '2023-10-29T01:00:00.000Z',
  },
  {
    start: '2023-10-29T03:00:00.000Z',
    delta: {
      hours: -3
    },
    utc: '2023-10-29T00:00:00.000Z',
  },
  {
    start: '2023-10-29T03:00:00.000Z',
    delta: {
      hours: -4
    },
    utc: '2023-10-28T23:00:00.000Z',
  },
  {
    start: '2023-11-13T10:59:13.371Z',
    delta: {
      days: -15,
      hours: -4
    },
    utc: '2023-10-29T06:59:13.371Z',
  },
  {
    start: '2023-11-13T10:59:13.371Z',
    delta: {
      days: -16
    },
    utc: '2023-10-28T10:59:13.371Z',
  },
  {
    start: '2023-11-13T10:59:13.371Z',
    delta: {
      days: -16,
      hours: -4
    },
    utc: '2023-10-28T06:59:13.371Z',
  },
  {
    start: '2023-11-13T10:59:13.371Z',
    delta: {
      hours: -(16 * 24 + 4)
    },
    utc: '2023-10-28T06:59:13.371Z',
  },
  {
    start: '2023-10-30T00:00:00.000Z',
    delta: {
      days: -1
    },
    utc: '2023-10-29T00:00:00.000Z',
  },
  {
    start: '2023-10-30T00:00:00.000Z',
    delta: {
      days: -2
    },
    utc: '2023-10-28T00:00:00.000Z',
  },
  {
    start: '2023-03-26T04:00:00.000Z',
    delta: {
      hours: 0
    },
    utc: '2023-03-26T04:00:00.000Z',
  },
  {
    start: '2023-03-26T04:00:00.000Z',
    delta: {
      hours: -1
    },
    utc: '2023-03-26T03:00:00.000Z',
  },
  {
    start: '2023-03-26T04:00:00.000Z',
    delta: {
      hours: -2
    },
    utc: '2023-03-26T02:00:00.000Z',
  },
  {
    start: '2023-03-26T04:00:00.000Z',
    delta: {
      hours: -3
    },
    utc: '2023-03-26T01:00:00.000Z',
  },
  {
    start: '2023-03-26T04:00:00.000Z',
    delta: {
      days: -1
    },
    utc: '2023-03-25T04:00:00.000Z',
  },
  {
    start: '2023-03-26T04:00:00.000Z',
    delta: {
      days: -1,
      hours: 1
    },
    utc: '2023-03-25T05:00:00.000Z',
  },
  {
    start: '2023-10-30T00:00:00.000Z',
    delta: {
      months: 1,
      days: -1
    },
    utc: '2023-11-29T00:00:00.000Z',
  },
  {
    start: '2023-10-30T00:00:00.000Z',
    delta: {
      months: -1,
      days: 1
    },
    utc: '2023-10-01T00:00:00.000Z',
  },
  {
    start: '2023-10-30T00:00:00.000Z',
    delta: {
      years: 1,
      days: -1
    },
    utc: '2024-10-29T00:00:00.000Z',
  },
  {
    start: '2023-10-30T00:00:00.000Z',
    delta: {
      years: -1,
      days: 1
    },
    utc: '2022-10-31T00:00:00.000Z',
  },
  {
    start: '2023-10-29T00:00:00.000Z',
    delta: {
      months: 1,
      days: -1
    },
    utc: '2023-11-28T00:00:00.000Z',
  },
  {
    start: '2023-10-29T00:00:00.000Z',
    delta: {
      months: -1,
      days: 1
    },
    utc: '2023-09-30T00:00:00.000Z',
  },
  {
    start: '2023-10-29T00:00:00.000Z',
    delta: {
      years: 1,
      days: -1
    },
    utc: '2024-10-28T00:00:00.000Z',
  },
  {
    start: '2023-10-29T00:00:00.000Z',
    delta: {
      years: -1,
      days: 1
    },
    utc: '2022-10-30T00:00:00.000Z',
  },
  {
    start: '2023-10-28T00:00:00.000Z',
    delta: {
      months: 1,
      days: -1
    },
    utc: '2023-11-27T00:00:00.000Z',
  },
  {
    start: '2023-10-28T00:00:00.000Z',
    delta: {
      months: -1,
      days: 1
    },
    utc: '2023-09-29T00:00:00.000Z',
  },
  {
    start: '2023-10-28T00:00:00.000Z',
    delta: {
      years: 1,
      days: -1
    },
    utc: '2024-10-27T00:00:00.000Z',
  },
  {
    start: '2023-10-28T00:00:00.000Z',
    delta: {
      years: -1,
      days: 1
    },
    utc: '2022-10-29T00:00:00.000Z',
  },
  {
    start: '2023-03-27T00:00:00.000Z',
    delta: {
      months: 1,
      days: -1
    },
    utc: '2023-04-26T00:00:00.000Z',
  },
  {
    start: '2023-03-27T00:00:00.000Z',
    delta: {
      months: -1,
      days: 1
    },
    utc: '2023-02-28T00:00:00.000Z',
  },
  {
    start: '2023-03-27T00:00:00.000Z',
    delta: {
      years: 1,
      days: -1
    },
    utc: '2024-03-26T00:00:00.000Z',
  },
  {
    start: '2023-03-27T00:00:00.000Z',
    delta: {
      years: -1,
      days: 1
    },
    utc: '2022-03-28T00:00:00.000Z',
  },
  {
    start: '2023-03-26T00:00:00.000Z',
    delta: {
      months: 1,
      days: -1
    },
    utc: '2023-04-25T00:00:00.000Z',
  },
  {
    start: '2023-03-26T00:00:00.000Z',
    delta: {
      months: -1,
      days: 1
    },
    utc: '2023-02-27T00:00:00.000Z',
  },
  {
    start: '2023-03-26T00:00:00.000Z',
    delta: {
      years: 1,
      days: -1
    },
    utc: '2024-03-25T00:00:00.000Z',
  },
  {
    start: '2023-03-26T00:00:00.000Z',
    delta: {
      years: -1,
      days: 1
    },
    utc: '2022-03-27T00:00:00.000Z',
  },
  {
    start: '2023-03-25T00:00:00.000Z',
    delta: {
      months: 1,
      days: -1
    },
    utc: '2023-04-24T00:00:00.000Z',
  },
  {
    start: '2023-03-25T00:00:00.000Z',
    delta: {
      months: -1,
      days: 1
    },
    utc: '2023-02-26T00:00:00.000Z',
  },
  {
    start: '2023-03-25T00:00:00.000Z',
    delta: {
      years: 1,
      days: -1
    },
    utc: '2024-03-24T00:00:00.000Z',
  },
  {
    start: '2023-03-25T00:00:00.000Z',
    delta: {
      years: -1,
      days: 1
    },
    utc: '2022-03-26T00:00:00.000Z',
  },
]

const addDuration = (date, delta) => {
  const {
    years = 0, months = 0, weeks = 0, days = 0, hours = 0, minutes = 0, seconds = 0
  } = delta

  const dateWithCalendarDelta = add(date, {
    months,
    years,
    days,
    weeks
  })
  const tzDelta = date.getTimezoneOffset() - dateWithCalendarDelta.getTimezoneOffset()

  return add(dateWithCalendarDelta, {
    hours,
    minutes: minutes + tzDelta,
    seconds
  })
}

const main = () => {
  console.table(
    examples.map(({
      start,
      delta,
      utc
    }) => {
      const add1 = add(new Date(start), delta)
      const ok1 = add1.toISOString() === utc ? '✅' : '❌'
      const add2 = addDuration(new Date(start), delta)
      const ok2 = add2.toISOString() === utc ? '✅' : '❌'

      return {
        start: new Date(start),
        delta,
        utc: new Date(utc),
        add1,
        ok1,
        add2,
        ok2
      }
      document.querySelector('tbody')
    }),
  )
}

setTimeout(main, 500)
<script type="module">
  import { add } from 'https://esm.run/date-fns';
  window.add = add;
</script>


from Adding Durations to dates in a manner stable across timezones

No comments:

Post a Comment