Pacharapol Withayasakpunt Pacharapol Withayasakpunt
Sat, December 5, 2020

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

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);
  },
};

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;
    }

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()];
    },

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;

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(" ");

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%)");
      }
    });
  });

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';
}

Real code

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

https://github.com/patarapolw/native-duration