User:Gary/nominations viewer.js

This is an old revision of this page, as edited by Gary (talk | contribs) at 20:22, 26 August 2018 (Update with fixes, especially improvements detecting usernames, etc.). The present address (URL) is a permanent link to this revision, which may differ significantly from the current revision.

Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
// Nominations Viewer
//
// Description: Compact nominations for [[WP:FAC]], [[WP:FAR]], [[WP:FLC]],
//   [[WP:FLRC]], [[WP:FPC]], and [[WP:PR]].
// Documentation: [[Wikipedia:Nominations Viewer]]
//
// ===
//
// Settings
// ---
//
// Default:
//
// NominationsViewer =
// {
//   'enabledPages': ['Wikipedia:Featured article candidates', ...],
//   'nominationData': ['images', 'age', 'nominators', 'participants', 'votes'],
// }
// eslint-disable-next-line max-statements
$(() => {
  // Check the URL to determine if this script should be disabled.
  if (window.location.href.includes('&disable=nomviewer')) {
    return;
  }

  // Check if already ran elsewhere.
  if (window.nominationsViewer) {
    return;
  }

  window.nominationsViewer = true;
  const NominationsViewer = window.NominationsViewer || {};

  if (!NominationsViewer.enabledPages) {
    NominationsViewer.enabledPages = {
      // 'User:Gary/Sandbox': 'nominations',

      'Wikipedia:Featured article candidates': 'nominations',
      'Wikipedia:Featured article review': 'reviews',

      'Wikipedia:Featured list candidates': 'nominations',
      'Wikipedia:Featured list removal candidates': 'reviews',

      'Wikipedia:Featured picture candidates': 'pictures',

      'Wikipedia:Peer review': 'peer reviews',
    };
  }

  if (!NominationsViewer.nominationData) {
    NominationsViewer.nominationData = [
      'images',
      'age',
      'nominators',
      'participants',
      'votes',
    ];
  }

  /**
   * Add empty nomination data holders for a nomination.
   *
   * @param {string} pageName Name of the nomination page.
   * @param {jQuery} $parentNode Parent node containing the entire nomination.
   * @param {Array} ids The ID names to create.
   * @returns {jQuery} The new node we added.
   */
  function addNominationData(pageName, $parentNode, ids) {
    return ids.map((id) => {
      const $span = $(`<span id="${id}-${simplifyPageName(pageName)}"></span>`);

      return $parentNode
        .children()
        .last()
        .before($span);
    });
  }

  function addAllNomInfo($headings) {
    const data = { allH3Length: $headings.length };

    const $expandAllLink = $(
      '<a href="#" id="expand-all-link">expand all</a>',
    ).on('click', data, expandAllNoms);
    const $collapseAllLink = $(
      '<a href="#" id="collapse-all-link">collapse all</a>',
    ).on('click', data, collapseAllNoms);
    const $info = $('<span class="overall-controls"></span>')
      .append(' (')
      .append($expandAllLink)
      .append(' / ')
      .append($collapseAllLink)
      .append(')');

    return $headings
      .first()
      .next()
      .prevUntil('h2')
      .last()
      .prev()
      .append($info);
  }

  function addNomData(params, callback) {
    $.getJSON(mw.util.wikiScript('api'), {
      format: 'json',
      ...params,
    }).done(callback);
  }

  function appendAllNominationData(pageName) {
    // Participants
    addNomData(
      {
        action: 'query',
        prop: 'revisions',
        rvdir: 'newer',
        rvlimit: 500,
        titles: pageName,
      },
      allRevisionsCallback,
    );

    // Images, nominators, votes
    addNomData(
      {
        action: 'query',
        prop: 'revisions',
        rvdir: 'older',
        rvlimit: 1,
        rvprop: 'content',
        titles: pageName,
      },
      currentRevisionCallback,
    );

    // Age
    addNomData(
      {
        action: 'query',
        prop: 'revisions',
        rvdir: 'newer',
        rvlimit: 1,
        titles: pageName,
      },
      firstRevisionCallback,
    );
  }

  // Add data to a nomination.
  function addNewNomData({ pageName, data, id, hoverText }) {
    if (!data) {
      return;
    }

    // Select the element we want to add values to.
    const $id = $(`#${id}-${simplifyPageName(pageName)}`);
    const $newChild = $('<span class="nomv-data"></span>');
    const $abbr = $(`<abbr title="${hoverText}">${data}</abbr>`);

    $newChild.append($abbr);
    $id.append($newChild);
  }

  /**
   * Create the data that appears next to the nomination's listing.
   *
   * @param {string} pageName Page name of the nomination page.
   * @returns {jQuery} The new node we added.
   */
  function createData(pageName) {
    const $newSpan = $('<span class="nomination-data"></span>').append(
      '<span>(<span>',
    );
    const matchArchiveNumber = pageName.match(/([0-9]+)$/);

    const conditions = matchArchiveNumber && matchArchiveNumber[1] > 1;
    const matchArchiveNumberPrint = (() => {
      if (conditions) {
        const number = parseInt(matchArchiveNumber[1], 10);

        const ordinalSuffix = (() => {
          switch (number) {
            case 2:
              return 'nd';
            case 3:
              return 'rd';
            default:
              return 'th';
          }
        })();

        return `: ${number}${ordinalSuffix}`;
      }

      return '';
    })();

    const $viewLink = $(
      `<span><a href="/https/en.m.wikipedia.org/wiki/${pageName}">nomination</a>\
  ${matchArchiveNumberPrint}</span>`,
    );

    return $newSpan.append($viewLink).append('<span>)<span>');
  }

  function createNewNode({ oldNode, showHideLink, newSpan, index }) {
    const $newNode = $(`<div id="nom-title-${index}"></div>`).append(
      oldNode.clone(true),
    );
    const $heading = $newNode.children().first();
    $heading
      .prepend(`<span class="nomination-order">${index + 1}.</span> `)
      .append(' ')
      .append(showHideLink)
      .append(newSpan);
    return $newNode;
  }

  /**
   * Replace a nomination with a new and improved one.
   *
   * @param {Object} options Options.
   * @param {jQuery} options.$h3 The h3 heading of the nomination.
   * @param {number} options.index The index of the nomination among the
   * others.
   * @returns {undefined}
   */
  function createNomination({ $h3, index }) {
    const $editLink = $h3.find('.mw-editsection-bracket').next('a');

    // This nomination is missing an edit link, so something is wrong with it.
    // Skip it.
    if (!$editLink.length) {
      return;
    }

    // Get the name of the nomination page.
    const pageName = decodeURIComponent(
      $editLink
        .attr('href')
        .match(/title=(.*?)&/)[1]
        .replace(/\s/g, '_'),
    );

    // Create the [show] / [hide] link
    const showHideLink = createShowHideLink(index);

    // Create the spot to put the data that we will retrieve via the Wikipedia
    // API
    const newSpan = createData(pageName);

    // Move the nomination into a hidden node
    hideNomination($h3, index);

    // Add placeholders for the data that we will retrieve for the API
    addNominationData(pageName, newSpan, NominationsViewer.nominationData);

    // Create the nomination's title line.
    const newNode = createNewNode({
      oldNode: $h3,
      showHideLink,
      newSpan,
      index,
    });

    // Create the actual nomination
    const nomDiv = generateNomination(index, newNode, $h3);

    // Replace this nomination with the new one we created
    $h3.replaceWith(nomDiv);

    // Ask the API to add data to our placeholders
    appendAllNominationData(pageName);
  }

  function createShowHideLink(index) {
    const span = $('<span class="nomv-show-hide"></span>');
    const link = $(`<a href="#" id="nom-button-${index}">show</a>`).on(
      'click',
      { index },
      toggleNomClick,
    );

    return span
      .append('[')
      .append(link)
      .append(']');
  }

  function generateNomination(index, newNode, oldNode) {
    return $(`<div class="nomination" id="nom-${index}"></div>`)
      .append(newNode.clone(true))
      .append($(oldNode[0].nextSibling).clone(true));
  }

  // This function MUST stay in JavaScript, rather than switch to jQuery, for
  // optmization reasons.
  //
  // The jQuery version slowed the page down by about 28%. This version slows
  // the page down by about 11%, so it is about 17% faster.
  function hideNomination($h3, index) {
    // Re-create all nodes between this H3 node, and the next one, then place it
    // into a new node.
    const hiddenNode = document.createElement('div');
    hiddenNode.className = 'nomination-body';
    hiddenNode.id = `nom-data-${index}`;
    hiddenNode.style.display = 'none';
    let nomNextSibling = $h3[0].nextSibling;

    // Continue to the next node, as long as the next node still exists, it
    // isn't an H2 or H3, and it doesn't have the class "printfooter"
    while (
      nomNextSibling &&
      !['H2', 'H3'].includes(nomNextSibling.nodeName) &&
      !(
        nomNextSibling.classList &&
        nomNextSibling.classList.contains('printfooter')
      )
    ) {
      const nomNextSiblingTemp = nomNextSibling.nextSibling;

      // Move the node, if it isn't a text node
      if (nomNextSibling.nodeType !== 3) {
        hiddenNode.appendChild(nomNextSibling);
      }

      nomNextSibling = nomNextSiblingTemp;
    }

    // Insert hidden content
    return $h3.after(hiddenNode);
  }

  function init() {
    let currentPageIsASubpage;
    let currentPageIsEnabled;
    const pageName = mw.config.get('wgPageName');

    // Check if enabled on this page
    Object.keys(NominationsViewer.enabledPages).forEach((page) => {
      if (pageName === page.replace(/\s/g, '_')) {
        currentPageIsEnabled = true;
      } else if (pageName.startsWith(page.replace(/\s/g, '_'))) {
        currentPageIsASubpage = true;
      }
    });

    if (
      !currentPageIsEnabled ||
      mw.config.get('wgAction') !== 'view' ||
      window.location.href.includes('&oldid=') ||
      currentPageIsASubpage
    ) {
      return false;
    }

    // Only append the CSS if we are definitely running the script on this page.
    nomViewerCss();

    const $parentNode = $('.mw-content-ltr');
    const $h3s = $parentNode.find('h3');
    addAllNomInfo($h3s);

    // Loop through each nomination
    $h3s.each((index, element) =>
      createNomination({
        $h3: $(element),
        index,
      }),
    );

    // Fix any conflicts with collapsed comments (using the special template).
    return $('.collapseButton').each((index, element) => {
      const $link = $(element)
        .children()
        .first();

      const newIndex = $link
        .attr('id')
        .substring(
          $link.attr('id').indexOf('collapseButton') + 'collapseButton'.length,
          $link.attr('id').length,
        );

      return $link.attr('href', '#').on('click', { newIndex }, collapseTable);
    });
  }

  // Helpers
  function collapseTable(event) {
    event.preventDefault();
    const tableIndex = event.data.index;
    const collapseCaption = 'hide';
    const expandCaption = 'show';

    const $button = $(`#collapseButton${tableIndex}`);
    const $table = $(`#collapsibleTable${tableIndex}`);
    if (!$table.length || !$button.length) {
      return false;
    }
    const $rows = $table.find('> tbody > tr');

    if ($button.text() === collapseCaption) {
      $rows.each((index, element) => {
        if (index === 0) {
          return true;
        }
        return $(element).hide();
      });
      return $button.text(expandCaption);
    }
    $rows.each((index, element) => {
      if (index === 0) {
        return true;
      }
      return $(element).show();
    });
    return $button.text(collapseCaption);
  }

  function nomViewerCss() {
    // CSS
    return mw.util.addCSS(
      // eslint-disable-next-line no-multi-str
      '\
  html { overflow-y: scroll; } \
  \
  #content .nomination h3 { margin-bottom: 0; padding-top: 0; } \
  .nomination-data, .nomination-order, .overall-controls { \
  font-size: 75%; font-weight: normal; } \
  \
  .nomination-order { display: inline-block; width: 25px; } \
  .nomv-show-hide { display: inline-block; font-size: 13px;\
  font-weight: normal; margin-right: 2.5px; width: 40px; }\
  .nomv-show-hide a { display: inline-block; text-align: center; width: 31px; }\
  .nomv-data::before { content: " · "; }\
  .nomv-data abbr { white-space: nowrap; }\
  ',
    );
  }

  function expandAllNoms(event) {
    return toggleAllNoms(event, 'expand');
  }

  function collapseAllNoms(event) {
    return toggleAllNoms(event, 'collapse');
  }

  function toggleAllNoms(event, actionParam) {
    let action = actionParam;

    if (!action) {
      action = 'expand';
    }
    event.preventDefault();
    const { allH3Length } = event.data;

    Array(allH3Length)
      .fill()
      .forEach((value, index) => {
        toggleNom(index, action);
      });
  }

  function toggleNom(id, actionParam) {
    let action = actionParam;

    if (!action) {
      action = '';
    }
    const toggleHideNom = ($node, $nomButton) => {
      $node.hide();
      return $nomButton.text('show');
    };

    const toggleShowNom = ($node, $nomButton) => {
      $node.show();
      return $nomButton.text('hide');
    };

    const $node = $(`#nom-data-${id}`);
    const $nomButton = $(`#nom-button-${id}`);

    // These are actions that override the status for all nominations.
    if (action === 'collapse') {
      return toggleHideNom($node, $nomButton);
    }

    if (action === 'expand') {
      return toggleShowNom($node, $nomButton);
    }

    // These have to be separate from the above because they have a lower
    // priority.
    if ($node.is(':visible')) {
      return toggleHideNom($node, $nomButton);
    }

    if ($node.is(':hidden')) {
      return toggleShowNom($node, $nomButton);
    }

    return null;
  }

  function toggleNomClick(event) {
    event.preventDefault();
    const { index } = event.data;
    return toggleNom(index);
  }

  // Callbacks
  function allRevisionsCallback(obj) {
    const vars = formatJSON(obj);
    if (!vars) {
      return null;
    }

    // Participants
    if (dataIsEnabled('participants') && vars.revisions) {
      const users = {};
      let userCount = 0;

      vars.revisions.forEach((revision) => {
        if (!revision.user) {
          return;
        }

        if (users[revision.user]) {
          users[revision.user] += 1;
        } else {
          users[revision.user] = 1;
          userCount += 1;
        }
      });

      const moreThan = obj['query-continue'] ? 'more than ' : '';
      const usersArray = Object.keys(users).map((user) => [
        user,
        parseInt(users[user], 10),
      ]);

      usersArray.sort((a, b) => {
        if (a[1] < b[1]) {
          return 1;
        }

        if (a[1] > b[1]) {
          return -1;
        }

        return 0;
      });

      const usersArray2 = usersArray.map((user) => `${user[0]}: ${user[1]}`);

      return addNewNomData({
        pageName: vars.pageName,
        data: `${moreThan + userCount} ${pluralize('participant', userCount)}`,
        id: 'participants',
        hoverText: `Sorted from most to least edits&#10;Total edits: ${
          vars.revisions.length
        }&#10;Format: &quot;editor: \
number of edits&quot;:&#10;&#10;${usersArray2.join('&#10;')}`,
      });
    }

    return null;
  }

  function addImagesCount(content, pageName) {
    if (!nomType('pictures') || !dataIsEnabled('images')) {
      return;
    }

    // Determine number of images in the nomination
    const pattern1 = /\[\[(File|Image):.*?\]\]/gi;
    const pattern2 = /\n(File|Image):.*\|/gi;
    const matches1 = content.match(pattern1);
    const matches2 = content.match(pattern2);

    const matches = matches1 || (matches2 || []);
    const images = matches.map((match) => {
      const split = match.split('|');
      const filename = $.trim(split[0].replace(/^\[\[/, ''));
      return filename;
    });

    addNewNomData({
      pageName,
      data: `${matches.length} ${pluralize('image', matches.length)}`,
      id: 'images',
      hoverText: `Images (in order of appearance):&#10;&#10;${images.join(
        '&#10;',
      )}`,
    });
  }

  // eslint-disable-next-line complexity
  function addNominators(content, pageName) {
    if (!dataIsEnabled('nominators') || nomType('peer reviews')) {
      return;
    }

    let nomTypeText = '';
    let listOfNominators = {};

    switch (nomType()) {
      case 'nominations':
        nomTypeText = 'nominator';
        listOfNominators = findNominators(content, /Nominator(\(s\))?:.*/);

        // No nominators were found, so try once more with a different pattern.
        if ($.isEmptyObject(listOfNominators)) {
          listOfNominators = findNominators(content, /:<small>''.*/);
        }

        break;
      case 'reviews':
        nomTypeText = 'notification';
        listOfNominators = findNominators(content, /(Notified|Notifying):?.*/);

        break;
      case 'pictures':
        nomTypeText = 'nominator';
        listOfNominators = findNominators(
          content,
          /\* '''Support as nominator''' – .*/,
        );

        break;
      default:
    }

    let allNominators = Object.keys(listOfNominators).map((n) => n);
    allNominators.sort();

    let data;
    if (allNominators.length > 0) {
      data = `${allNominators.length} ${pluralize(
        nomTypeText,
        allNominators.length,
      )}`;

      // We couldn't identify any nominators.
    } else {
      // Use the first username on the page to determine the nominator.
      const matches = content.match(/\[\[User:(.*?)[|\]]/);

      if (nomType('nominations') && matches) {
        allNominators = [matches[1]];
        data = `${allNominators.length} ${pluralize(
          nomTypeText,
          allNominators.length,
        )}`;

        // This is not a nomination-type, and we couldn't find any relevant
        // users, so we have to assume that there are none.
      } else {
        data = `0 ${pluralize(nomTypeText, 0)}`;
      }
    }

    addNewNomData({
      pageName,
      data,
      id: 'nominators',
      hoverText: `${pluralize(
        capitalize(nomTypeText),
        allNominators.length,
      )} (sorted alphabetically):&#10;&#10;${allNominators.join('&#10;')}`,
    });
  }

  // eslint-disable-next-line complexity, max-statements
  function addVotes(content, pageName) {
    if (!dataIsEnabled('votes') || nomType('peer reviews')) {
      return;
    }

    let opposePattern;
    let opposeText;
    let supportPattern;
    let supportText;

    const showOpposesForNominations = false;
    const showOpposesForReviews = true;

    let showSupportsAndOpposes = false;
    const voteBuffer = 25;
    let basicPatternOpen = `'''(.{0,${voteBuffer}})?`;
    let basicPatternClose = `(.{0,${voteBuffer}})?'''`;

    if (nomType('nominations')) {
      supportText = 'support';
      opposeText = 'oppose';

      supportPattern = new RegExp(
        `${basicPatternOpen}support${basicPatternClose}`,
        'gi',
      );
      opposePattern = new RegExp(
        `${basicPatternOpen}oppose${basicPatternClose}`,
        'gi',
      );
    } else if (nomType('reviews')) {
      supportText = 'keep';
      opposeText = 'delist';

      supportPattern = new RegExp(
        `${basicPatternOpen}keep${basicPatternClose}`,
        'gi',
      );
      opposePattern = new RegExp(
        `${basicPatternOpen}delist${basicPatternClose}`,
        'gi',
      );
    } else if (nomType('pictures')) {
      supportText = 'support';
      opposeText = 'oppose';

      basicPatternOpen = "\\*(\\s)?'''.*?";
      basicPatternClose = ".*?'''";

      supportPattern = new RegExp(
        `${basicPatternOpen}Support${basicPatternClose}`,
        'gi',
      );
      opposePattern = new RegExp(
        `${basicPatternOpen}Oppose${basicPatternClose}`,
        'gi',
      );
    }

    const supportMatches = content.match(supportPattern) || [];
    const opposeMatches = content.match(opposePattern) || [];

    const supports = `${supportMatches.length} ${pluralize(
      supportText,
      supportMatches.length,
    )}`;
    const opposes = `, ${opposeMatches.length} ${pluralize(
      opposeText,
      opposeMatches.length,
    )}`;

    if (
      ((nomType('nominations') || nomType('pictures')) &&
        showOpposesForNominations) ||
      (nomType('reviews') && showOpposesForReviews)
    ) {
      showSupportsAndOpposes = true;
    }

    addNewNomData({
      pageName,
      data: showSupportsAndOpposes ? supports + opposes : supports,
      id: 'votes',
      hoverText: supports + opposes,
    });
  }

  function currentRevisionCallback(obj) {
    const vars = formatJSON(obj);

    if (!vars) {
      return;
    }

    const content = vars.firstRevision ? vars.firstRevision['*'] : null;

    if (!content) {
      return;
    }

    // 'images'
    addImagesCount(content, vars.pageName);

    // 'nominators'
    addNominators(content, vars.pageName);

    // 'votes'
    addVotes(content, vars.pageName);
  }

  /**
   * The callback after getting the first revision of a page.
   *
   * @param {Object} obj The response object from the Wikipedia API.
   * @returns {undefined} Generated nomination data.
   */
  function firstRevisionCallback(obj) {
    const vars = formatJSON(obj);

    if (!vars) {
      return;
    }

    // Nomination age
    if (dataIsEnabled('age') && vars.firstRevision) {
      const { timeAgo, then } = getTimeAgo(vars.firstRevision.timestamp);

      addNewNomData({
        pageName: vars.pageName,
        data: timeAgo,
        id: 'age',
        hoverText: `Creation date (local time):&#10;&#10;${then}`,
      });
    }
  }

  // Callback helpers
  function capitalize(string) {
    return string.charAt(0).toUpperCase() + string.slice(1);
  }

  function dataIsEnabled(dataName) {
    return NominationsViewer.nominationData.some((data) => dataName === data);
  }

  // Given `content`, find nominators with the `pattern`. Returns an Object, so
  // that we exclude duplicates.
  function findNominators(content, pattern) {
    const nominatorMatches = content.match(pattern);
    const listOfNominators = {};

    if (!nominatorMatches) {
      return listOfNominators;
    }

    // Find nominator usernames.
    // [[User:Example|Example]], [[Wikipedia talk:WikiProject Example]]
    let nominators = nominatorMatches[0].match(
      /\[\[(User|Wikipedia|WP|WT)([ _]talk)?:.*?\]\]/gi,
    );

    if (nominators) {
      nominators.forEach((nominator) => {
        // Strip unneeded characters from the nominator's URL.
        let username = nominator
          // Strip the start of the username link.
          .replace(/\[\[(User|Wikipedia|WP|WT)([ _]talk)?:/i, '')
          // Strip the displayed portion of the username link.
          .replace(/\|.*/, '')
          // Strip the ending portion of the username link.
          .replace(']]', '')
          // Strip URL anchors.
          .replace(/#.*?$/, '');

        // Does 'username' have a '/' that we have to strip?
        if (username.includes('/')) {
          username = username.substring(0, username.indexOf('/'));
        }

        listOfNominators[username] += 1;
      });
    }

    // {{user|Example}} and similar variants
    const userTemplatePattern = /\{\{user.*?\|(.*?)\}\}/gi;
    nominators = nominatorMatches[0].match(userTemplatePattern);

    if (nominators) {
      nominators.forEach((singleNominator) => {
        listOfNominators[
          singleNominator.replace(userTemplatePattern, '$1')
        ] += 1;
      });
    }

    return listOfNominators;
  }

  function formatJSON(obj) {
    if (!obj.query || !obj.query.pages) {
      return false;
    }

    const vars = [];
    vars.pages = obj.query.pages;

    vars.page = Object.keys(vars.pages).map((page) => page);

    if (vars.page.length !== 1) {
      return false;
    }

    vars.page = obj.query.pages[vars.page[0]];
    vars.pageName = vars.page.title.replace(/\s/g, '_');
    if (!vars.page.revisions) {
      return false;
    }

    [vars.firstRevision] = vars.page.revisions;
    vars.revisions = vars.page.revisions;
    return vars;
  }

  function nomType(type) {
    const pageName = mw.config.get('wgPageName').replace(/_/g, ' ');
    const pageType = NominationsViewer.enabledPages[pageName];

    if (type) {
      return type === pageType;
    }

    return pageType;
  }

  /**
   * Pluralize a word if necessary.
   *
   * @param {string} string The word to possibly pluralize.
   * @param {number} count The number of items there are.
   * @returns {string} The pluralized word.
   */
  function pluralize(string, count) {
    const plural = `${string}s`;

    if (count === 1) {
      return string;
    }

    return plural;
  }

  /**
   * Format a page name by remove any non-word characters.
   *
   * @param {string} pageName The page name to format.
   * @returns {string} The formatted page name.
   */
  function simplifyPageName(pageName) {
    return pageName.replace(/\W/g, '');
  }

  /**
   * Given a timestamp, generally calculate the time ago.
   *
   * @param {string} timestamp A timestamp.
   * @returns {Object.<string, string>} The time ago phrase.
   */
  function getTimeAgo(timestamp) {
    const matches = timestamp.match(
      /(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})Z/,
    );
    const now = new Date();
    const then = new Date(
      Date.UTC(
        matches[1],
        matches[2] - 1,
        matches[3],
        matches[4],
        matches[5],
        matches[6],
      ),
    );

    const millisecondsAgo = now.getTime() - then.getTime();
    const daysAgo = Math.floor(millisecondsAgo / (1000 * 60 * 60 * 24));
    let timeAgo = '';

    if (daysAgo > 0) {
      const weeksAgo = Math.round(daysAgo / 7);
      const monthsAgo = Math.round(daysAgo / 30);
      const yearsAgo = Math.round(daysAgo / 365);

      if (yearsAgo >= 1) {
        timeAgo = `${yearsAgo} ${pluralize('year', yearsAgo)} old`;
      } else if (monthsAgo >= 3) {
        timeAgo = `${monthsAgo} ${pluralize('month', monthsAgo)} old`;
      } else if (weeksAgo >= 1) {
        timeAgo = `${weeksAgo} ${pluralize('week', weeksAgo)} old`;
      } else {
        timeAgo = `${daysAgo} ${pluralize('day', daysAgo)} old`;
      }
    } else {
      timeAgo = 'today';
    }

    return { timeAgo, then };
  }

  init();
});