REFACTOR: local dates to improve reliability with DST and recurrence (#9379)

REFACTOR: local dates to improve reliability with DST and recurrence (#9379)

This commit improves testing and separates local-date generation from dates with zone manipulations.

diff --git a/plugins/discourse-local-dates/assets/javascripts/discourse-local-dates.js.es6 b/plugins/discourse-local-dates/assets/javascripts/discourse-local-dates.js.es6
deleted file mode 100644
index aba41b6..0000000
--- a/plugins/discourse-local-dates/assets/javascripts/discourse-local-dates.js.es6
+++ /dev/null
@@ -1,357 +0,0 @@
-// discourse-skip-module
-(function($) {
-  const DATE_TEMPLATE = `
-    <span>
-      <svg class="fa d-icon d-icon-globe-americas svg-icon" xmlns="http://www.w3.org/2000/svg">
-        <use xlink:href="#globe-americas"></use>
-      </svg>
-      <span class="relative-time"></span>
-    </span>
-  `;
-
-  const PREVIEW_TEMPLATE = `
-    <div class='preview'>
-      <span class='timezone'></span>
-      <span class='date-time'></span>
-    </div>
-  `;
-
-  function processElement($element, options = {}) {
-    clearTimeout(this.timeout);
-
-    const utc = moment().utc();
-    const dateTime = options.time
-      ? `${options.date} ${options.time}`
-      : options.date;
-
-    let displayedTimezone;
-    if (options.time) {
-      displayedTimezone = options.displayedTimezone || moment.tz.guess();
-    } else {
-      displayedTimezone =
-        options.displayedTimezone || options.timezone || moment.tz.guess();
-    }
-
-    // if timezone given we convert date and time from given zone to Etc/UTC
-    let utcDateTime;
-    if (options.timezone) {
-      utcDateTime = _applyZoneToDateTime(dateTime, options.timezone);
-    } else {
-      utcDateTime = moment.utc(dateTime);
-    }
-
-    if (utcDateTime < utc) {
-      // if event is in the past we want to bump it no next occurrence when
-      // recurring is set
-      if (options.recurring) {
-        utcDateTime = _applyRecurrence(utcDateTime, options);
-      } else {
-        $element.addClass("past");
-      }
-    }
-
-    // once we have the correct UTC date we want
-    // we adjust it to watching user timezone
-    const adjustedDateTime = utcDateTime.tz(displayedTimezone);
-
-    const previews = _generatePreviews(
-      adjustedDateTime.clone(),
-      displayedTimezone,
-      options
-    );
-    const textPreview = _generateTextPreview(previews);
-    const htmlPreview = _generateHtmlPreview(previews);
-
-    const formatedDateTime = _applyFormatting(
-      adjustedDateTime,
-      displayedTimezone,
-      options
-    );
-
-    $element
-      .html(DATE_TEMPLATE)
-      .attr("aria-label", textPreview)
-      .attr(
-        "data-html-tooltip",
-        `<div class="locale-dates-previews">${htmlPreview}</div>`
-      )
-      .addClass("cooked-date")
-      .find(".relative-time")
-      .text(formatedDateTime);
-
-    this.timeout = setTimeout(
-      () => processElement($element, options),
-      60 * 1000
-    );
-  }
-
-  function _formatTimezone(timezone) {
-    return timezone
-      .replace("_", " ")
-      .replace("Etc/", "")
-      .split("/");
-  }
-
-  function _zoneWithoutPrefix(timezone) {
-    const parts = _formatTimezone(timezone);
-    return parts[1] || parts[0];
-  }
-
-  function _applyZoneToDateTime(dateTime, timezone) {
-    return moment.tz(dateTime, timezone).utc();
-  }
-
-  function _translateCalendarKey(time, key) {
-    const translated = I18n.t(`discourse_local_dates.relative_dates.${key}`, {
-      time: "LT"
-    });
-
-    if (time) {
-      return translated
-        .split("LT")
-        .map(w => `[${w}]`)
-        .join("LT");
-    } else {
-      return `[${translated.replace(" LT", "")}]`;
-    }
-  }
-
-  function _calendarFormats(time) {
-    return {
-      sameDay: _translateCalendarKey(time, "today"),
-      nextDay: _translateCalendarKey(time, "tomorrow"),
-      lastDay: _translateCalendarKey(time, "yesterday"),
-      sameElse: "L"
-    };
-  }
-
-  function _isEqualZones(timezoneA, timezoneB) {
-    if ((timezoneA || timezoneB) && (!timezoneA || !timezoneB)) {
-      return false;
-    }
-
-    if (timezoneA.includes(timezoneB) || timezoneB.includes(timezoneA)) {
-      return true;
-    }
-
-    return (
-      moment.tz(timezoneA).utcOffset() === moment.tz(timezoneB).utcOffset()
-    );
-  }
-
-  function _applyFormatting(dateTime, displayedTimezone, options) {
-    if (options.countdown) {
-      const diffTime = dateTime.diff(moment());
-      if (diffTime < 0) {
-        return I18n.t("discourse_local_dates.relative_dates.countdown.passed");
-      } else {
-        return moment.duration(diffTime).humanize();
-      }
-    }
-
-    const sameTimezone = _isEqualZones(displayedTimezone, moment.tz.guess());
-    const inCalendarRange = dateTime.isBetween(
-      moment().subtract(2, "days"),
-      moment()
-        .add(1, "days")
-        .endOf("day")
-    );
-
-    if (options.calendar && inCalendarRange) {
-      if (sameTimezone) {
-        if (options.time) {
-          dateTime = dateTime.calendar(null, _calendarFormats(options.time));
-        } else {
-          dateTime = dateTime.calendar(null, _calendarFormats(null));
-        }
-      } else {
-        dateTime = dateTime.format(options.format);
-        dateTime = dateTime.replace("TZ", "");
-        dateTime = `${dateTime} (${_zoneWithoutPrefix(displayedTimezone)})`;
-      }
-    } else {
-      if (options.time) {
-        dateTime = dateTime.format(options.format);
-
-        if (options.displayedTimezone && !sameTimezone) {
-          dateTime = dateTime.replace("TZ", "");
-          dateTime = `${dateTime} (${_zoneWithoutPrefix(displayedTimezone)})`;
-        } else {
-          dateTime = dateTime.replace(
-            "TZ",
-            _formatTimezone(displayedTimezone).join(": ")
-          );
-        }
-      } else {
-        dateTime = dateTime.format(options.format);
-
-        if (!sameTimezone) {
-          dateTime = dateTime.replace("TZ", "");
-          dateTime = `${dateTime} (${_zoneWithoutPrefix(displayedTimezone)})`;
-        } else {
-          dateTime = dateTime.replace(
-            "TZ",
-            _zoneWithoutPrefix(displayedTimezone)
-          );
-        }
-      }
-    }
-
-    return dateTime;
-  }
-
-  function _applyRecurrence(dateTime, { recurring, timezone }) {
-    const parts = recurring.split(".");
-    const count = parseInt(parts[0], 10);
-    const type = parts[1];
-    const diff = moment().diff(dateTime, type);
-    const add = Math.ceil(diff + count);
-
-    // we create new moment object from format
-    // to ensure it's created in user context
-    const wasDST = moment(dateTime.format()).isDST();
-    let dateTimeWithRecurrence = moment(dateTime).add(add, type);
-    const isDST = moment(dateTimeWithRecurrence.format()).isDST();
-
-    // these dates are more or less DST "certain"
-    const noDSTOffset = moment
-      .tz({ month: 0, day: 1 }, timezone || "Etc/UTC")
-      .utcOffset();
-    const withDSTOffset = moment
-      .tz({ month: 5, day: 1 }, timezone || "Etc/UTC")
-      .utcOffset();
-
-    // we remove the DST offset present when the date was created,
-    // and add current DST offset
-    if (!wasDST && isDST) {
-      dateTimeWithRecurrence.add(-withDSTOffset + noDSTOffset, "minutes");
-    }
-
-    // we add the DST offset present when the date was created,
-    // and remove current DST offset
-    if (wasDST && !isDST) {
-      dateTimeWithRecurrence.add(withDSTOffset - noDSTOffset, "minutes");
-    }
-
-    return dateTimeWithRecurrence;
-  }
-
-  function _createDateTimeRange(dateTime, timezone) {
-    const dt = moment(dateTime).tz(timezone);
-
-    return [dt.format("LLL"), "→", dt.add(24, "hours").format("LLL")].join(" ");
-  }
-
-  function _generatePreviews(dateTime, displayedTimezone, options) {
-    const previewedTimezones = [];
-    const watchingUserTimezone = moment.tz.guess();
-    const timezones = options.timezones.filter(
-      timezone =>
-        !_isEqualZones(timezone, watchingUserTimezone) &&
-        !_isEqualZones(timezone, options.timezone)
-    );
-
-    previewedTimezones.push({
-      timezone: watchingUserTimezone,

[... diff too long, it was truncated ...]

GitHub sha: 25f95af4

This commit appears in #9379 which was merged by jjaffeux.