DEV Community

loading...

How to humanize duration accurately in JavaScript, including weeks, months and years

patarapolw profile image Pacharapol Withayasakpunt Originally published at polv.cc ・3 min read

It is very simple, that you might not need a library, although there are cautions.

That is, using Date object. Be aware of months and leap years. Don't try to use straight simple approximation of raw milliseconds.

Natively adding numbers, e.g. days, months, to Date object

type Unit = "ms" | "s" | "min" | "h" | "d" | "w" | "mo" | "y";

const addDate: Record<Unit, (d: Date, n: number) => Date> = {
  ms: (d, n) => {
    d.setMilliseconds(d.getMilliseconds() + n);
    return new Date(d);
  },
  s: (d, n) => {
    d.setSeconds(d.getSeconds() + n);
    return new Date(d);
  },
  min: (d, n) => {
    d.setMinutes(d.getMinutes() + n);
    return new Date(d);
  },
  h: (d, n) => {
    d.setHours(d.getHours() + n);
    return new Date(d);
  },
  d: (d, n) => {
    d.setDate(d.getDate() + n);
    return new Date(d);
  },
  w: (d, n) => {
    d.setDate(d.getDate() + n * 7);
    return new Date(d);
  },
  mo: (d, n) => {
    d.setMonth(d.getMonth() + n);
    return new Date(d);
  },
  y: (d, n) => {
    d.setFullYear(d.getFullYear() + n);
    return new Date(d);
  },
};
Enter fullscreen mode Exit fullscreen mode

Adding number to Date is not that hard. Just remember to refresh the Date object, by cloning new Date(d).

Principles of calculating Duration

  • First, plan for appropriate Date component mapping - Record<Unit, number>
  • Second, mitigate negatives
    parse(
      current: (d: Date) => number,
      upper?: {
        get: (d: Date) => number;
        set: (d: Date, v: number) => void;
        inc: (d: Date) => number;
      }
    ) {
      let a = current(this.dates[1]) - current(this.dates[0]);

      if (upper) {
        while (a < 0) {
          a += upper.inc(this.dates[0]);

          upper.set(this.dates[1], upper.get(this.dates[1]) - 1);
          this.dates[1] = new Date(this.dates[1]);
        }
      }

      return a;
    }
Enter fullscreen mode Exit fullscreen mode

Now, the problem here is that you will need to account for leap years.

    inc: (d) => {
      const y = d.getFullYear();
      let isLeapYear = true;
      if (y % 4) {
        isLeapYear = false;
      } else if (y % 100) {
        isLeapYear = true;
      } else if (y % 400) {
        isLeapYear = false;
      }

      return [
        31, // Jan
        isLeapYear ? 29 : 28, // Feb
        31, // Mar
        30, // Apr
        31, // May
        30, // Jun
        31, // Jul
        31, // Aug
        30, // Sep
        31, // Oct
        30, // Nov
        31, // Dec
      ][d.getMonth()];
    },
Enter fullscreen mode Exit fullscreen mode

Note that .getMonth() is zero-indexed, i.e. January is 0, December is 11.

Weeks need to be calculated separately.

  const w = Math.floor(d / 7);
  d = d % 7;
Enter fullscreen mode Exit fullscreen mode

Constructing an ordered Record, removing unnecessity, and joining to a useful string

  const m = durationToRecord(from, to);
  const str = Object.entries(m.d)
    .filter(([, v]) => v)
    .reverse()
    .slice(0, trim)
    .map(([k, v]) => `${v.toLocaleString()}${unit[k as Unit] || k}`)
    .join(" ");
Enter fullscreen mode Exit fullscreen mode

Testing

I made it cursorly, so I don't even bother installing ts-mocha or jest-ts.

const maxAcceptable: Partial<Record<Unit, number>> = {
  ms: 1000,
  s: 60,
  min: 60,
  h: 24,
  d: 31,
  w: 4,
  mo: 12,
};

const now = new Date();

/**
 * 10k repeats
 */
Array(10000)
  .fill(null)
  .map(() => {
    /**
     * From minutes to about 20 years' duration
     */
    Array.from(Array(8), (_, i) => (Math.random() + 0.1) * 10 ** (i + 5)).map((n) => {
      const to = new Date(+now + n);
      console.log(
        durationToString(now, to, {
          sign: false,
          trim: 2,
        })
      );

      const map = durationToRecord(now, to);

      Object.entries(map.d).map(([k, v]) => {
        const max = maxAcceptable[k as Unit];
        if (max && v > max) {
          console.error({ k, v, map });
          throw new Error("Some value exceeded the limit");
        }
      });

      const calculated = Object.entries(map.d).reduce(
        (prev, [k, v]) => addDate[k as Unit](prev, v),
        new Date(now)
      );

      const ratio = (+calculated - +now) / n;

      if (ratio < 0.95 || ratio > 1.05) {
        console.error({ now, to, calculated, map });
        throw new Error("Duration might be miscalculated (CI 95%)");
      }
    });
  });
Enter fullscreen mode Exit fullscreen mode

Indeed, a more correct way would be to use ISO date-time string, and remove smallest parts. I have tried it, and sometimes I missed by 1 day.

Fine tuning

export function humanizeDurationToNow(epoch: number) {
  const now = new Date();
  const msec = +now - epoch;

  if (msec < 5000) {
    return 'Just posted';
  }

  return durationToString(new Date(epoch), now, { trim: 2 }) + ' ago';
}
Enter fullscreen mode Exit fullscreen mode

Real code

The real code can be found on GitHub and NPM. It uses absolutely zero dependencies.

GitHub logo patarapolw / native-duration

[JS/TS] Calculate duration between two dates with zero dependencies (via Date object)

Discussion (1)

pic
Editor guide
Collapse
patarapolw profile image
Pacharapol Withayasakpunt Author

A bug found.

Missed by exactly 1 DAY sometimes, when reconstructing Date from Duration. #2

It is never more than one day. Two days or more never.

  1) 3mo 3w 2d 17h 46min 40s +/- 50%
       duration is precise:
     Error: Duration might be miscalculated: 16 / 1,000
      at Context.<anonymous> (src/index.spec.ts:96:19)
      at processImmediate (internal/timers.js:461:21)

{
  since: 2020-07-31T00:16:04.889Z,
  calculated: 2020-08-01T00:16:04.889Z,
  difference: '+1d',
  now: 2020-12-06T10:03:52.130Z,
  duration: '+4mo 5d 9h 47min 47s 241ms',
  error: '-0.779%'
}
{
  since: 2020-07-31T03:53:48.904Z,
  calculated: 2020-08-01T03:53:48.904Z,
  difference: '+1d',
  now: 2020-12-06T10:03:52.130Z,
  duration: '+4mo 5d 6h 10min 3s 226ms',
  error: '-0.780%'
}
{
  since: 2020-08-31T11:48:27.478Z,
  calculated: 2020-09-01T11:48:27.478Z,
  difference: '+1d',
  now: 2020-12-06T10:03:52.130Z,
  duration: '+3mo 4d 22h 15min 24s 652ms',
  error: '-1.03%'
}
{
  since: 2020-08-31T14:00:40.771Z,
  calculated: 2020-09-01T14:00:40.771Z,
  difference: '+1d',
  now: 2020-12-06T10:03:52.130Z,
  duration: '+3mo 4d 20h 3min 11s 359ms',
  error: '-1.03%'
}
{
  since: 2020-08-30T19:49:20.734Z,
  calculated: 2020-08-31T19:49:20.734Z,
  difference: '+1d',
  now: 2020-12-06T10:03:52.130Z,
  duration: '+3mo 5d 14h 14min 31s 396ms',
  error: '-1.02%'
}
{
  since: 2020-08-31T13:28:24.720Z,
  calculated: 2020-09-01T13:28:24.720Z,
  difference: '+1d',
  now: 2020-12-06T10:03:52.130Z,
  duration: '+3mo 4d 20h 35min 27s 410ms',
  error: '-1.03%'
}
{
  since: 2020-08-31T16:33:11.278Z,
  calculated: 2020-09-01T16:33:11.278Z,
  difference: '+1d',
  now: 2020-12-06T10:03:52.130Z,
  duration: '+3mo 4d 17h 30min 40s 852ms',
  error: '-1.03%'
}
{
  since: 2020-08-30T23:02:44.816Z,
  calculated: 2020-08-31T23:02:44.816Z,
  difference: '+1d',
  now: 2020-12-06T10:03:52.130Z,
  duration: '+3mo 5d 11h 1min 7s 314ms',
  error: '-1.03%'
}
{
  since: 2020-07-31T02:46:42.981Z,
  calculated: 2020-08-01T02:46:42.981Z,
  difference: '+1d',
  now: 2020-12-06T10:03:52.130Z,
  duration: '+4mo 5d 7h 17min 9s 149ms',
  error: '-0.779%'
}
{
  since: 2020-08-31T12:04:22.210Z,
  calculated: 2020-09-01T12:04:22.210Z,
  difference: '+1d',
  now: 2020-12-06T10:03:52.130Z,
  duration: '+3mo 4d 21h 59min 29s 920ms',
  error: '-1.03%'
}
{
  since: 2020-08-30T23:10:44.277Z,
  calculated: 2020-08-31T23:10:44.277Z,
  difference: '+1d',
  now: 2020-12-06T10:03:52.130Z,
  duration: '+3mo 5d 10h 53min 7s 853ms',
  error: '-1.03%'
}
{
  since: 2020-08-30T19:39:19.523Z,
  calculated: 2020-08-31T19:39:19.523Z,
  difference: '+1d',
  now: 2020-12-06T10:03:52.130Z,
  duration: '+3mo 5d 14h 24min 32s 607ms',
  error: '-1.02%'
}
{
  since: 2020-07-31T16:21:26.519Z,
  calculated: 2020-08-01T16:21:26.519Z,
  difference: '+1d',
  now: 2020-12-06T10:03:52.130Z,
  duration: '+4mo 4d 17h 42min 25s 611ms',
  error: '-0.783%'
}
{
  since: 2020-07-31T03:47:39.171Z,
  calculated: 2020-08-01T03:47:39.171Z,
  difference: '+1d',
  now: 2020-12-06T10:03:52.130Z,
  duration: '+4mo 5d 6h 16min 12s 959ms',
  error: '-0.780%'
}
{
  since: 2020-08-30T20:58:57.362Z,
  calculated: 2020-08-31T20:58:57.362Z,
  difference: '+1d',
  now: 2020-12-06T10:03:52.130Z,
  duration: '+3mo 5d 13h 4min 54s 768ms',
  error: '-1.03%'
}
{
  since: 2020-08-30T17:44:38.030Z,
  calculated: 2020-08-31T17:44:38.030Z,
  difference: '+1d',
  now: 2020-12-06T10:03:52.130Z,
  duration: '+3mo 5d 16h 19min 14s 100ms',
  error: '-1.02%'
}
Enter fullscreen mode Exit fullscreen mode
  2) 3y 2mo 9h 46min 40s +/- 50%
       duration is precise:
     Error: Duration might be miscalculated: 13 / 1,000
      at Context.<anonymous> (src/index.spec.ts:96:19)
      at processImmediate (internal/timers.js:461:21)

{
  since: 2016-08-31T06:50:17.654Z,
  calculated: 2016-09-01T06:50:17.654Z,
  difference: '+1d',
  now: 2020-12-06T10:03:52.130Z,
  duration: '+4y 3mo 5d 3h 13min 34s 476ms',
  error: '-0.0642%'
}
{
  since: 2018-07-31T15:16:31.460Z,
  calculated: 2018-08-01T15:16:31.460Z,
  difference: '+1d',
  now: 2020-12-06T10:03:52.130Z,
  duration: '+2y 4mo 4d 18h 47min 20s 670ms',
  error: '-0.116%'
}
{
  since: 2017-12-30T22:24:20.233Z,
  calculated: 2017-12-31T22:24:20.233Z,
  difference: '+1d',
  now: 2020-12-06T10:03:52.130Z,
  duration: '+2y 11mo 5d 11h 39min 31s 897ms',
  error: '-0.0933%'
}
{
  since: 2016-05-31T07:23:40.774Z,
  calculated: 2016-06-01T07:23:40.774Z,
  difference: '+1d',
  now: 2020-12-06T10:03:52.130Z,
  duration: '+4y 6mo 5d 2h 40min 11s 356ms',
  error: '-0.0606%'
}
{
  since: 2018-05-30T18:57:02.261Z,
  calculated: 2018-05-31T18:57:02.261Z,
  difference: '+1d',
  now: 2020-12-06T10:03:52.130Z,
  duration: '+2y 6mo 5d 15h 6min 49s 869ms',
  error: '-0.109%'
}
{
  since: 2017-03-30T21:29:20.570Z,
  calculated: 2017-03-31T21:29:20.570Z,
  difference: '+1d',
  now: 2020-12-06T10:03:52.130Z,
  duration: '+3y 8mo 5d 12h 34min 31s 560ms',
  error: '-0.0743%'
}
{
  since: 2018-01-30T17:27:28.142Z,
  calculated: 2018-01-31T17:27:28.142Z,
  difference: '+1d',
  now: 2020-12-06T10:03:52.130Z,
  duration: '+2y 10mo 5d 16h 36min 23s 988ms',
  error: '-0.0961%'
}
{
  since: 2018-10-31T10:29:51.993Z,
  calculated: 2018-11-01T10:29:51.993Z,
  difference: '+1d',
  now: 2020-12-06T10:03:52.130Z,
  duration: '+2y 1mo 4d 23h 34min 137ms',
  error: '-0.130%'
}
{
  since: 2016-05-31T12:21:50.150Z,
  calculated: 2016-06-01T12:21:50.150Z,
  difference: '+1d',
  now: 2020-12-06T10:03:52.130Z,
  duration: '+4y 6mo 4d 21h 42min 1s 980ms',
  error: '-0.0606%'
}
{
  since: 2016-05-31T15:36:26.792Z,
  calculated: 2016-06-01T15:36:26.792Z,
  difference: '+1d',
  now: 2020-12-06T10:03:52.130Z,
  duration: '+4y 6mo 4d 18h 27min 25s 338ms',
  error: '-0.0606%'
}
{
  since: 2016-08-31T00:41:19.869Z,
  calculated: 2016-09-01T00:41:19.869Z,
  difference: '+1d',
  now: 2020-12-06T10:03:52.130Z,
  duration: '+4y 3mo 5d 9h 22min 32s 261ms',
  error: '-0.0642%'
}
{
  since: 2018-08-31T00:34:29.634Z,
  calculated: 2018-09-01T00:34:29.634Z,
  difference: '+1d',
  now: 2020-12-06T10:03:52.130Z,
  duration: '+2y 3mo 5d 9h 29min 22s 496ms',
  error: '-0.121%'
}
{
  since: 2018-07-31T10:52:43.916Z,
  calculated: 2018-08-01T10:52:43.916Z,
  difference: '+1d',
  now: 2020-12-06T10:03:52.130Z,
  duration: '+2y 4mo 4d 23h 11min 8s 214ms',
  error: '-0.116%'
}
Enter fullscreen mode Exit fullscreen mode
  3) 31y 8mo 6d 1h 46min 40s +/- 50%
       duration is precise:
     Error: Duration might be miscalculated: 8 / 1,000
      at Context.<anonymous> (src/index.spec.ts:96:19)
      at processImmediate (internal/timers.js:461:21)

{
  since: 1991-01-31T09:27:18.628Z,
  calculated: 1991-02-01T09:27:18.628Z,
  difference: '+1d',
  now: 2020-12-06T10:08:28.646Z,
  duration: '+29y 10mo 5d 41min 10s 18ms',
  error: '-0.00917%'
}
{
  since: 1999-07-31T04:30:48.634Z,
  calculated: 1999-08-01T04:30:48.634Z,
  difference: '+1d',
  now: 2020-12-06T10:08:28.646Z,
  duration: '+21y 4mo 5d 5h 37min 40s 12ms',
  error: '-0.0128%'
}
{
  since: 1997-01-31T11:10:17.988Z,
  calculated: 1997-02-01T11:10:17.988Z,
  difference: '+1d',
  now: 2020-12-06T10:08:28.646Z,
  duration: '+23y 10mo 4d 22h 58min 10s 658ms',
  error: '-0.0115%'
}
{
  since: 1988-10-31T02:42:42.649Z,
  calculated: 1988-11-01T02:42:42.649Z,
  difference: '+1d',
  now: 2020-12-06T10:08:28.646Z,
  duration: '+32y 1mo 5d 7h 25min 45s 997ms',
  error: '-0.00853%'
}
{
  since: 2002-12-30T23:47:01.751Z,
  calculated: 2002-12-31T23:47:01.751Z,
  difference: '+1d',
  now: 2020-12-06T10:08:28.646Z,
  duration: '+17y 11mo 5d 10h 21min 26s 895ms',
  error: '-0.0153%'
}
{
  since: 2004-10-31T15:13:41.103Z,
  calculated: 2004-11-01T15:13:41.103Z,
  difference: '+1d',
  now: 2020-12-06T10:08:28.646Z,
  duration: '+16y 1mo 4d 18h 54min 47s 543ms',
  error: '-0.0170%'
}
{
  since: 1977-12-31T14:32:49.698Z,
  calculated: 1978-01-01T14:32:49.698Z,
  difference: '+1d',
  now: 2020-12-06T10:08:28.646Z,
  duration: '+42y 11mo 4d 19h 35min 38s 948ms',
  error: '-0.00638%'
}
{
  since: 1976-10-30T22:43:19.120Z,
  calculated: 1976-10-31T22:43:19.120Z,
  difference: '+1d',
  now: 2020-12-06T10:08:28.646Z,
  duration: '+44y 1mo 5d 11h 25min 9s 526ms',
  error: '-0.00621%'
}
Enter fullscreen mode Exit fullscreen mode

The problem is, what is the real cause, why error is never more than 1 DAY, nor in other units?

Qualm is this code.

     this.d = this.parse((d) => d.getDate(), {
      get: (d) => d.getMonth(),
      set: (d, v) => d.setMonth(v),
      inc: (d) => {
        const y = d.getFullYear();
        let isLeapYear = true;
        if (y % 4) {
          isLeapYear = false;
        } else if (y % 100) {
          isLeapYear = true;
        } else if (y % 400) {
          isLeapYear = false;
        }

        return [
          31, // Jan
          isLeapYear ? 29 : 28, // Feb
          31, // Mar
          30, // Apr
          31, // May
          30, // Jun
          31, // Jul
          31, // Aug
          30, // Sep
          31, // Oct
          30, // Nov
          31, // Dec
        ][d.getMonth()];
      },
    });

...

  /**
   * @internal
   */
  private parse(
    current: (d: Date) => number,
    upper?: {
      get: (d: Date) => number;
      set: (d: Date, v: number) => void;
      inc: (d: Date) => number;
    }
  ) {
    let a = current(this.dates[1]) - current(this.dates[0]);

    if (upper) {
      while (a < 0) {
        upper.set(this.dates[1], upper.get(this.dates[1]) - 1);
        this.dates[1] = new Date(this.dates[1]);

        a += upper.inc(this.dates[1]);
      }
    }

    return a;
  }
Enter fullscreen mode Exit fullscreen mode

So, I reposted on Hashnode, starting with "bug"