time-diff.js

/**
 * @file
 * Dynamic time difference formatting.
 */

((Drupal, once) => {
  /**
   * @typedef {object} timeDiffValue
   *
   * @prop {number} [year]
   *   Years count.
   * @prop {number} [month]
   *   Months count.
   * @prop {number} [week]
   *   Weeks count.
   * @prop {number} [day]
   *   Days count.
   * @prop {number} [hour]
   *   Hours count.
   * @prop {number} [minute]
   *   Minutes count.
   * @prop {number} [second]
   *   Seconds count.
   */

  /**
   * @typedef {object} timeDiff
   *
   * @prop {string} formatted
   *   A translated string representation of the interval.
   * @prop {timeDiffValue} value
   *   The elements composing the time difference interval. Example: { day: 2,
   *   hour: 2, minute: 32, second: 15 }.
   */

  /**
   * List of time intervals.
   *
   * @type {object}
   *
   * @prop {number} year
   *   Year duration in seconds.
   * @prop {number} month
   *   Month duration in seconds.
   * @prop {number} week
   *   Week duration in seconds.
   * @prop {number} day
   *   Day duration in seconds.
   * @prop {number} hour
   *   Hour duration in seconds.
   * @prop {number} minute
   *   Minute duration in seconds.
   * @prop {number} second
   *   One second.
   */
  const intervals = {
    year: 31536000,
    month: 2592000,
    week: 604800,
    day: 86400,
    hour: 3600,
    minute: 60,
    second: 1,
  };

  /**
   * List of available time intervals names.
   *
   * @type {string[]}
   */
  const intervalsNames = Object.keys(intervals);

  /**
   *
   * @type {WeakMap<HTMLElement, number>}
   */
  const timers = new WeakMap();

  /**
   * @namespace
   */
  Drupal.timeDiff = {
    /**
     * Fills a HTML5 time element text with a computed time difference string.
     *
     * @param {Element} timeElement
     *   The time DOM element.
     */
    show(timeElement) {
      const timestamp = new Date(
        timeElement.getAttribute('datetime'),
      ).getTime();
      const timeDiffSettings = JSON.parse(
        timeElement.getAttribute('data-drupal-time-diff'),
      );

      const now = Date.now();
      const diff = Math.round((timestamp - now) / 1000);
      const options = { granularity: timeDiffSettings.granularity };
      const timeDiff = Drupal.timeDiff.format(diff, options);
      const format = diff > 0 ? 'future' : 'past';
      timeElement.textContent = Drupal.formatString(
        timeDiffSettings.format[format],
        {
          '@interval': timeDiff.formatted,
        },
      );

      if (timeDiffSettings.refresh > 0) {
        const refreshInterval = Drupal.timeDiff.refreshInterval(
          timeDiff.value,
          timeDiffSettings.refresh,
          timeDiffSettings.granularity,
        );
        clearTimeout(timers.get(timeElement));
        timers.set(
          timeElement,
          setTimeout(Drupal.timeDiff.show, refreshInterval * 1000, timeElement),
        );
      }
    },

    /**
     * Computes the refresh interval.
     *
     * There are cases when the refresh occurs even when it is not needed. For
     * example if the refresh interval is '10 seconds', the granularity is 2 and
     * the time difference is '1 hour 32 minutes', there's no need to refresh
     * every 10 seconds but every 1 minute. This function optimizes the refresh
     * interval to higher values, if the structure of the time difference
     * doesn't require refreshing more often.
     *
     * @param {timeDiffValue} value
     *   The time difference object.
     * @param {number} refresh
     *   The configured refresh interval in seconds.
     * @param {number} granularity
     *   The time difference granularity.
     *
     * @return {number}
     *   The computed refresh interval in seconds.
     */
    refreshInterval(value, refresh, granularity) {
      const units = Object.keys(value);
      const unitsCount = units.length;
      const lastUnit = units.pop();

      // If the lowest unit of time difference is 'minute' or greater but the
      // refresh interval is lower, do not refresh often than the duration of
      // the lowest unit of time difference.
      if (lastUnit !== 'second') {
        // If the time difference value parts count equals the granularity and
        // lowest unit duration is bigger than the refresh interval, use the
        // interval duration. For example, if the refresh interval is
        // '10 seconds', the granularity is 2 and the time difference is
        // '1 hour 32 minutes', do not refresh every 10 seconds but every one
        // minute (60 seconds).
        if (unitsCount === granularity) {
          intervalsNames.every((interval) => {
            const duration = intervals[interval];
            if (interval === lastUnit) {
              refresh = refresh < duration ? duration : refresh;
              return false;
            }
            return true;
          });
          return refresh;
        }
        // The time difference value parts count might be smaller than the
        // granularity when the lowest part is missed because is 0. In this case
        // the missed part interval duration is used as refresh. For example, if
        // the refresh is '10 seconds', the granularity is 2 and the time
        // difference is '59 minutes 59 seconds', on the next refresh the time
        // difference will be '1 hour' (because minutes are 0, therefore are not
        // shown) but we want the next refresh to occur, not in one hour, but in
        // one minute.
        const lastIntervalIndex = intervalsNames.indexOf(lastUnit);
        const nextInterval = intervalsNames[lastIntervalIndex + 1];
        refresh = intervals[nextInterval];
      }
      return refresh;
    },

    /**
     * Formats a time interval between two timestamps.
     *
     * @param {number} diff
     *   A UNIX timestamps difference in seconds.
     * @param {object} [options]
     *   An optional object with additional options.
     * @param {number} [options.granularity=2]
     *   An integer value that signals how many different units to display in the
     *   string. Defaults to 2.
     * @param {boolean} [options.strict=false]
     *   A boolean value indicating whether or not, a negative diff should be
     *   rendered as "0 seconds". If the time difference is negative (i.e. the
     *   timestamp is in the past) and this option is false (default) the result
     *   string will be the formatted time difference. If the option is true the
     *   result string will be "0 seconds".
     *
     * @return {timeDiff}
     *   A time difference type object.
     */
    format(diff, options = {}) {
      // Provide appropriate defaults.
      options = { granularity: 2, strict: false, ...options };

      if (options.strict && diff < 0) {
        return {
          formatted: Drupal.formatPlural(0, '1 second', '@count seconds'),
          value: { second: 0 },
        };
      }
      diff = Math.abs(diff);

      const output = [];
      const value = {};
      let units;
      let { granularity } = options;

      intervalsNames.every((interval) => {
        const duration = intervals[interval];
        units = Math.floor(diff / duration);
        if (units > 0) {
          diff %= units * duration;
          switch (interval) {
            case 'year':
              output.push(Drupal.formatPlural(units, '1 year', '@count years'));
              break;
            case 'month':
              output.push(
                Drupal.formatPlural(units, '1 month', '@count months'),
              );
              break;
            case 'week':
              output.push(Drupal.formatPlural(units, '1 week', '@count weeks'));
              break;
            case 'day':
              output.push(Drupal.formatPlural(units, '1 day', '@count days'));
              break;
            case 'hour':
              output.push(Drupal.formatPlural(units, '1 hour', '@count hours'));
              break;
            case 'minute':
              output.push(
                Drupal.formatPlural(units, '1 minute', '@count minutes'),
              );
              break;
            default:
              output.push(
                Drupal.formatPlural(units, '1 second', '@count seconds'),
              );
          }
          value[interval] = units;

          granularity -= 1;
          if (granularity <= 0) {
            // Limit the granularity of the output.
            return false;
          }
        } else if (output.length > 0) {
          // Exit if there was previous output but not any output at this level,
          // to avoid skipping levels and getting output like "1 year 1 second".
          return false;
        }
        return true;
      });

      if (output.length === 0) {
        return {
          formatted: Drupal.formatPlural(0, '1 second', '@count seconds'),
          value: { second: 0 },
        };
      }
      return { formatted: output.join(' '), value };
    },
  };

  /**
   * Fills all time[data-drupal-time-diff] elements with a refreshing time diff.
   *
   * @type {Drupal~behavior}
   *
   * @prop {Drupal~behaviorAttach} attach
   *   Initializes refresh of time differences.
   *
   * @prop {Drupal~behaviorDetach} detach
   *   Clear timers associated with time diff elements.
   */
  Drupal.behaviors.timeDiff = {
    attach(context) {
      // Replace each <time> element text with a time difference representation.
      once('time-diff', 'time[data-drupal-time-diff]', context).forEach(
        Drupal.timeDiff.show,
      );
    },
    detach(context, settings, trigger) {
      if (trigger === 'unload') {
        once
          .remove('time-diff', 'time[data-drupal-time-diff]', context)
          .forEach((timeElement) => clearTimeout(timers.get(timeElement)));
      }
    },
  };
})(Drupal, once);