Source: src/js/common/DateUtility.js

"use strict";

define([], () => {
  const pad2 = (value) => String(value).padStart(2, "0");

  /**
   * @class DateUtility
   * @classdesc Utility helpers for parsing, grouping, and formatting dates.
   * @since 2.37.0
   */
  class DateUtility {
    /**
     * Check if a value is a valid Date instance.
     * @param {*} value Candidate value.
     * @returns {boolean} True when value is a valid Date.
     */
    static isValidDate(value) {
      return value instanceof Date && !Number.isNaN(value.getTime());
    }

    /**
     * Convert a value into a Date.
     * @param {string|number|Date} value Date-like value.
     * @returns {Date|null} Parsed Date or null when invalid.
     */
    static toDate(value) {
      if (value === null || value === undefined || value === "") {
        return null;
      }
      const date =
        value instanceof Date ? new Date(value.getTime()) : new Date(value);
      return DateUtility.isValidDate(date) ? date : null;
    }

    /**
     * Build a Date for start-of-day in the requested timezone mode.
     * @param {string|number|Date} value Date-like value.
     * @param {("local"|"UTC")} [groupingTimeZone] Day boundary mode.
     * @returns {Date|null} Midnight Date or null when invalid.
     */
    static toMidnightDate(value, groupingTimeZone = "local") {
      const date = DateUtility.toDate(value);
      if (!date) return null;
      if (groupingTimeZone === "UTC") {
        return new Date(
          Date.UTC(
            date.getUTCFullYear(),
            date.getUTCMonth(),
            date.getUTCDate(),
          ),
        );
      }
      return new Date(date.getFullYear(), date.getMonth(), date.getDate());
    }

    /**
     * Convert a date into a YYYY-MM-DD string.
     * @param {string|number|Date} value Date-like value.
     * @param {("local"|"UTC")} [groupingTimeZone] Day boundary mode.
     * @returns {string} Date-only string, or empty string when invalid.
     */
    static toISODateOnly(value, groupingTimeZone = "local") {
      const date = DateUtility.toDate(value);
      if (!date) return "";
      const useUTC = groupingTimeZone === "UTC";
      const year = useUTC ? date.getUTCFullYear() : date.getFullYear();
      const month = useUTC ? date.getUTCMonth() + 1 : date.getMonth() + 1;
      const day = useUTC ? date.getUTCDate() : date.getDate();
      return `${year}-${pad2(month)}-${pad2(day)}`;
    }

    /**
     * Convert a date into a stable day identifier.
     * @param {string|number|Date} value Date-like value.
     * @param {("local"|"UTC")} [groupingTimeZone] Day boundary mode.
     * @param {string} [prefix] Prefix to include in ID.
     * @returns {string} Stable ID string.
     */
    static toDayId(value, groupingTimeZone = "local", prefix = "date") {
      const dateOnly = DateUtility.toISODateOnly(value, groupingTimeZone);
      return dateOnly ? `${prefix}:${dateOnly}` : `${prefix}:invalid`;
    }

    /**
     * Format a date using locale-aware date formatting.
     * @param {string|number|Date} value Date-like value.
     * @param {object} [options] Formatting options.
     * @param {string} [options.locale] Locale override. Set to null to use the
     * default locale.
     * @param {Intl.DateTimeFormatOptions} [options.formatOptions] Options for
     * date formatting. Defaults to { year: "numeric", month: "long", day: "numeric" }.
     * @returns {string} Formatted date or empty string when invalid.
     */
    static toLocaleDateString(
      value,
      {
        locale,
        formatOptions = { year: "numeric", month: "long", day: "numeric" },
      } = {},
    ) {
      const date = DateUtility.toDate(value);
      if (!date) return "";
      return date.toLocaleDateString(locale, formatOptions);
    }

    /**
     * Format a local timestamp with a short timezone token.
     * @param {string|number|Date} value Date-like value.
     * @returns {string} Formatted timestamp or empty string when invalid.
     */
    static toLocalTimestampWithZone(value) {
      const date = DateUtility.toDate(value);
      if (!date) return "";

      const y = date.getFullYear();
      const m = pad2(date.getMonth() + 1);
      const d = pad2(date.getDate());
      const hours24 = date.getHours();
      const minutes = pad2(date.getMinutes());

      const tz = date
        .toLocaleTimeString(undefined, { timeZoneName: "short" })
        .split(" ")
        .pop();

      return `${y}-${m}-${d} ${hours24}:${minutes} ${tz}`;
    }

    /**
     * Convert a date-ish value into an ISO-8601 timestamp.
     * @param {string|number|Date} value Date-like value.
     * @returns {string} ISO timestamp or empty string when invalid.
     */
    static toISOString(value) {
      const date = DateUtility.toDate(value);
      if (!date) return "";
      try {
        return date.toISOString();
      } catch (e) {
        // eslint-disable-next-line no-console
        console.warn("Error converting date to ISO string:", e);
        return "";
      }
    }

    /**
     * Return a human-readable string describing how much newer or older a date
     * is compared to a reference date, e.g. "one day newer" or "two days
     * older". It will resolve to seconds, minutes, hours, days, months, or
     * years as appropriate. If the date is the same as the reference date,
     * return "current".
     * @param {string|number|Date} value Date-like value to compare.
     * @param {string|number|Date} referenceValue Date-like reference value.
     * @param {object} [options] Formatting options.
     * @param {string} [options.newerWord] Set to customize the "newer" label,
     * for example, "before". Defaults to "newer".
     * @param {string} [options.olderWord] Set to customize the "older" label,
     * for example, "after". Defaults to "older".
     * @param {string} [options.currentWord] Set to customize the "current"
     * label. For example, "right now". Defaults to "current".
     * @returns {string} Relative date string, or empty string when invalid.
     */
    static getRelativeDateString(
      value,
      referenceValue,
      {
        newerWord = "newer",
        olderWord = "older",
        currentWord = "current",
      } = {},
    ) {
      const date = DateUtility.toDate(value);
      const referenceDate = DateUtility.toDate(referenceValue);
      if (!date || !referenceDate) return "";

      const diffMs = date.getTime() - referenceDate.getTime();
      if (diffMs === 0) return currentWord;

      const absDiffMs = Math.abs(diffMs);
      const isNewer = diffMs > 0;

      const units = [
        { label: "year", ms: 365.25 * 24 * 60 * 60 * 1000 },
        { label: "month", ms: 30.44 * 24 * 60 * 60 * 1000 },
        { label: "day", ms: 24 * 60 * 60 * 1000 },
        { label: "hour", ms: 60 * 60 * 1000 },
        { label: "minute", ms: 60 * 1000 },
        { label: "second", ms: 1000 },
      ];

      const unit = units.find((u) => {
        const diffInUnits = Math.round(absDiffMs / u.ms);
        return diffInUnits >= 1;
      });

      if (unit) {
        const diffInUnits = Math.round(absDiffMs / unit.ms);
        return `${diffInUnits} ${unit.label}${diffInUnits > 1 ? "s" : ""} ${
          isNewer ? newerWord : olderWord
        }`;
      }

      return `less than 1 second ${isNewer ? newerWord : olderWord}`;
    }
  }

  return DateUtility;
});