javascriptnode.jsdstdate-fnsdate-fns-tz

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:

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>


Solution

  • My understanding is that an addDuration function is wanted that computes in UTC only, and I think that is possible using Date.UTC and the Date.getUTC* functions like this:

    const addDuration = (date, delta) => {
      const {
        years = 0,
        months = 0,
        weeks = 0,
        days = 0,
        hours = 0,
        minutes = 0,
        seconds = 0,
      } = delta;
    
      const utcYears = date.getUTCFullYear();
      const utcMonths = date.getUTCMonth();
      const utcDays = date.getUTCDate();
      const utcHours = date.getUTCHours();
      const utcMinutes = date.getUTCMinutes();
      const utcSeconds = date.getUTCSeconds();
      const utcMilliseconds = date.getUTCMilliseconds();
    
      return new Date(
        Date.UTC(
          utcYears + years,
          utcMonths + months,
          utcDays + weeks * 7 + days,
          utcHours + hours,
          utcMinutes + minutes,
          utcSeconds + seconds,
          utcMilliseconds
        )
      );
    };
    

    I think it would satisfy the needs of calculating the same date for the same deltas and start date in different clients independent of TZ.

    I understand that there are cases in general when doing these computations where different results are possible. For example when asking which date it was a month before March 31st. For these cases it is only important for me that all clients behave the same and preferentially the way JS 'does it anyway'. My understanding is that exactly this happens when asking JS to create a date for such a case:

    new Date(Date.UTC(2023,01,31,0,0,0,0))
    // 2023-03-03T00:00:00.000Z
    

    I've found that the implementation would also fit all the test cases from the example:

    Screenshot of executing the given example with the new suggestion of addDuration in TZ=CET

    Full code then looks like this:

    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",
      },
    ];
    
    /**
     *
     * @param {Date} date
     * @param {*} delta
     * @returns Date
     */
    const addDuration = (date, delta) => {
      const {
        years = 0,
        months = 0,
        weeks = 0,
        days = 0,
        hours = 0,
        minutes = 0,
        seconds = 0,
      } = delta;
    
      const utcYears = date.getUTCFullYear();
      const utcMonths = date.getUTCMonth();
      const utcDays = date.getUTCDate();
      const utcHours = date.getUTCHours();
      const utcMinutes = date.getUTCMinutes();
      const utcSeconds = date.getUTCSeconds();
      const utcMilliseconds = date.getUTCMilliseconds();
    
      return new Date(
        Date.UTC(
          utcYears + years,
          utcMonths + months,
          utcDays + weeks * 7 + days,
          utcHours + hours,
          utcMinutes + minutes,
          utcSeconds + seconds,
          utcMilliseconds
        )
      );
    };
    
    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,
        };
      })
    );