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.
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
:
Output 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.
I'd like to specifically learn more about these aspects:
addDuration
in TZ=CET
?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.
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>
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:
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,
};
})
);