I was working with Day.js in Observable and I found a bug. Then I noticed others had found the bug too, so I decided to come up with a workaround.

What’s Day.js?

Day.js is a library for handling dates in JavaScript.

Dates in Javascript are … messy and annoying. There are a few libraries out there built to work around them by adding functionality. Day.js is one of them – a minimalist successor to a classic library, Moment.js, that is now no longer being developed.

The bug I found was to do with the way Day.js calculates the differences between specific calendar dates, and how this feeds into a specific type of Duration object it returns, designed to measure an amount of time, rather than specific dates.

Background: dates and lengths of time

I was playing around with the interconnected fields function I created on Observable the other day, specifically with dates. I wanted a user to be able to set a time period for the term of a contract.

The start date was easy enough as I can just give the user a date field to pick.

But for determining when the contract would end I wanted the user to be able to either to set the length of the term (how long from the start date), or the end date of the term (another specific date). And I wanted those two input fields to update each other.

Updating the term length would set the end date based on the number of years entered from the start date, and setting or changing the end date would recalculate the end of term.

The length of term is a straightforward number field and the end date, like the start date, is a date field.

I figured I could use my reusable connect function to get them to work together. But I also needed functions within that connect function which would translate from one type of field or value to the other.

Using Day.js as a converter

Why write special code to convert Date objects to numbers and back again? After starting to do this, I soon realised I’d be reinventing the wheel.

So I plugged Day.js functions into my connect function.

What I wrote originally looked like this – bearing in mind that fn describes how to convert value stored in the central view, in this case test3Way to the relevant field, and ifn describes how to convert the value from the field back to the central field (the inverse function):

connect([
  { target: viewof termLength,
    fn: x => x.years(),
    ifn: x => dayjs.duration(x, "years")
  },
  { target: viewof endDate,
    fn: x => dayjs(startDate).add(x, "y").toDate(),
    ifn: x => dayjs.duration(dayjs(x).diff(startDate))
  }
], viewof test3Way)

I wanted Day.js to act as a converter between these different values, so they could update each other.

Unfortunately, my inverse function, ifn, for endDate didn’t work. After much experimentation and a little self-doubt, I found out this is because of a bug in Day.js.

I then confirmed this by seeing that others had also encountered the bug. There was even an issue in the Day.js about the duration between dates being incorrect. I left my own comment on this detailing my findings about how Day.js’s diff and duration methods didn’t work together properly.

Here’s a quick example of how the bug manifests itself:

startDate = dayjs("2021-09-21")
// set a start date, any start date
endDate = dayjs("2050-09-21")
// set a date exactly 29 years from the start date

dayjs(startDate)
  .add(29, "y")
  .toDate()
// returns "2050-09-21"
// same as endDate as expected
// 29 years' difference

dayjs.duration(endDate.diff(startDate))
// returns an object containing 29 years
// ...and 7 days instead!

This happens because diff returns the difference between two dates in milliseconds, – unless you tell it to return as a specific unit of time, but more on that shortly.

If you then use this milliseconds-based result with the duration method, there’s no context as to when these milliseconds are happening – no information about different lengths of month, or leap years. The duration method just takes these things as generic, which isn’t very helpful if you want an accurate length of time between two specific dates.

Workaround

I thought I’d try coming up with a workaround as a proof-of-concept fix for the bug.

Here were the two facts that led me to my workaround:

  1. Duration objects can be used to detail the amount of time between two dates, using several units – years, months, days, hours, minutes, seconds, even milliseconds. You can also have weeks in there.

  2. The diff method doesn’t return milliseconds. We can return the difference between two dates in several units – years, months, days…

I figured if I could calculate the difference between a start date and an end date first as a number of years, then I could subtract that difference from the end date to get a new end date. I could then calculate the difference between the start date and the new end date as a number of months – then I could subtract that from the new end date to get an even newer end date, and do the same for the days, and so on.

Then all of these results could be used with the duration method to create a comprehensive and accurate Duration object, properly showing the difference between two dates in years, months, days, and so on.

What the code looks like

You can find the details of the workaround in an Observable notebook with an explanation and examples.

The reason I’m writing about it here is because I’m fairly pleased with the speed at which I did this and with the recursive little function I created to deal with the problem.

function diffDates (startDate, endDate, includeWeeks) {
  const units = ["y", "M"]
    .concat((includeWeeks ? ["w"] : [])
    .concat(["d", "h", "m", "s"]))
  const whittleDate = (givenDate, givenUnits, duration) => {
      let diff = givenDate.diff(startDate, givenUnits[0])
      let diffRemainder = givenDate.subtract(diff, givenUnits[0])
      let newduration = duration.add(diff, givenUnits[0])
      return (givenUnits.length > 1)
        ? whittleDate(diffRemainder, givenUnits.slice(1), newduration)
        : newduration
    }
  return whittleDate(endDate, units, dayjs.duration())
}

What the code does

It takes a start date and an end date and returns a Day.js Duration object. It also has an option for counting weeks as a unit of time too.

So:

  • The units constant inside the function is basically a list of the shorthands of all these units (with week concatenated into the list if includeWeeks is set to true).

  • The whittleDate function then takes a givenDate, a given list of units, and a Duration object, and works out the difference between the startDate and the givenDate by the first unit in the given list. The trick being that it’s called with end date and initial list of units and an empty Duration object to begin with. Once it’s done working out the difference according to one unit, it:

    • subtracts that difference from the givenDate,
    • adds the same difference to the Duration object, and
    • deletes the first item in the units list.

    It then calls itself with these new values.

    Once it reaches the end of the list a condition returns the final object which cascades up through the stack to given the final accumulated Duration object.

And… yet another bug

While doing this, I found another bug with the way that time is added onto dates using Duration. And even when I got my term field and my end date field working together, I still had some decisions to make about the resolution of the inputs… But more about that in a future post, potentially.