Source: src/js/common/DateUtilities.js

"use strict";

define(["common/ValueUtilities"], (ValueUtilities) => {
  const { nullIfEmpty, normalizeText, requireStringChoice } = ValueUtilities;
  const GROUPING_TIME_ZONES = ["local", "UTC"];
  const RELATIVE_DATE_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 DEFAULT_LOCALE_DATE_OPTIONS = {
    year: "numeric",
    month: "long",
    day: "numeric",
  };

  /**
   * Pad a numeric date/time fragment to two digits.
   * @param {number|string} value Numeric date/time fragment.
   * @returns {string} Two-digit string.
   */
  function pad2(value) {
    return String(value).padStart(2, "0");
  }

  /**
   * Normalize the day-grouping timezone option.
   * @param {*} groupingTimeZone Candidate grouping timezone.
   * @returns {"local"|"UTC"} Normalized grouping timezone.
   */
  function normalizeGroupingTimeZone(groupingTimeZone = "local") {
    return requireStringChoice(groupingTimeZone, GROUPING_TIME_ZONES, {
      fieldName: "groupingTimeZone",
      fallback: "local",
    });
  }

  /**
   * Utility helpers for parsing, grouping, and formatting dates.
   * @namespace DateUtilities
   * @classcategory Common
   * @since 2.39.0
   */
  const DateUtilities = {
    /**
     * Check if a value is a valid Date instance.
     * @param {*} value Candidate value.
     * @returns {boolean} True when value is a valid Date.
     */
    isValidDate(value) {
      return value instanceof Date && !Number.isNaN(value.getTime());
    },

    /**
     * Run a date formatter and normalize errors to an empty string.
     * @param {string|number|Date|null|undefined} value Date-like value.
     * @param {Function} formatter Formatter callback.
     * @returns {string} Formatted date string, or empty string when invalid.
     */
    formatDate(value, formatter) {
      const date = DateUtilities.toDate(value);
      if (!date || typeof formatter !== "function") return "";

      try {
        return formatter(date);
      } catch {
        return "";
      }
    },

    /**
     * Return day components for the requested timezone mode.
     * @param {Date} date Valid Date instance.
     * @param {("local"|"UTC")} [groupingTimeZone] Day boundary mode.
     * @returns {{year:number, monthIndex:number, day:number}} Day components.
     */
    getDateParts(date, groupingTimeZone = "local") {
      const normalizedGroupingTimeZone =
        normalizeGroupingTimeZone(groupingTimeZone);

      if (normalizedGroupingTimeZone === "UTC") {
        return {
          year: date.getUTCFullYear(),
          monthIndex: date.getUTCMonth(),
          day: date.getUTCDate(),
        };
      }

      return {
        year: date.getFullYear(),
        monthIndex: date.getMonth(),
        day: date.getDate(),
      };
    },

    /**
     * Extract a short timezone token from a local time representation.
     * @param {Date} date Valid Date instance.
     * @returns {string} Local timezone token, or empty string when unavailable.
     */
    getLocalTimeZoneToken(date) {
      const formatter = new Intl.DateTimeFormat(undefined, {
        hour: "numeric",
        timeZoneName: "short",
      });

      const zonePart = formatter
        .formatToParts(date)
        .find(({ type }) => type === "timeZoneName");

      return zonePart?.value || "";
    },

    /**
     * Format a relative-date difference string.
     * @param {number} diffMs Signed millisecond difference.
     * @param {object} words Relative-word configuration.
     * @param {string} [words.newerWord] Label for positive differences.
     * @param {string} [words.olderWord] Label for negative differences.
     * @param {string} [words.currentWord] Label for equal differences.
     * @returns {string} Human-readable relative date string.
     */
    formatRelativeDiff(
      diffMs,
      {
        newerWord = "newer",
        olderWord = "older",
        currentWord = "current",
      } = {},
    ) {
      if (diffMs === 0) return currentWord;

      const absDiffMs = Math.abs(diffMs);
      const directionWord = diffMs > 0 ? newerWord : olderWord;
      const unit = RELATIVE_DATE_UNITS.find(
        ({ ms }) => Math.round(absDiffMs / ms) >= 1,
      );

      if (!unit) {
        return `less than 1 second ${directionWord}`;
      }

      const diffInUnits = Math.round(absDiffMs / unit.ms);
      const pluralSuffix = diffInUnits === 1 ? "" : "s";
      return `${diffInUnits} ${unit.label}${pluralSuffix} ${directionWord}`;
    },

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

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

      const normalizedGroupingTimeZone =
        normalizeGroupingTimeZone(groupingTimeZone);
      const { year, monthIndex, day } = DateUtilities.getDateParts(
        date,
        normalizedGroupingTimeZone,
      );

      if (normalizedGroupingTimeZone === "UTC") {
        return new Date(Date.UTC(year, monthIndex, day));
      }

      return new Date(year, monthIndex, day);
    },

    /**
     * Convert a date into a YYYY-MM-DD string.
     * @param {string|number|Date|null|undefined} value Date-like value.
     * @param {("local"|"UTC")} [groupingTimeZone] Day boundary mode.
     * @returns {string} Date-only string, or empty string when invalid.
     */
    toISODateOnly(value, groupingTimeZone = "local") {
      const date = DateUtilities.toDate(value);
      if (!date) return "";

      const { year, monthIndex, day } = DateUtilities.getDateParts(
        date,
        groupingTimeZone,
      );
      return `${year}-${pad2(monthIndex + 1)}-${pad2(day)}`;
    },

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

    /**
     * Format a date using locale-aware date formatting.
     * @param {string|number|Date|null|undefined} value Date-like value.
     * @param {object} [options] Formatting options.
     * @param {string|null} [options.locale] Locale override. Set to `null` to
     * use the default locale.
     * @param {Intl.DateTimeFormatOptions} [options.formatOptions] Intl date
     * formatting options.
     * @returns {string} Formatted date or empty string when invalid.
     */
    toLocaleDateString(
      value,
      { locale, formatOptions = DEFAULT_LOCALE_DATE_OPTIONS } = {},
    ) {
      return DateUtilities.formatDate(value, (date) =>
        date.toLocaleDateString(locale ?? undefined, formatOptions),
      );
    },

    /**
     * Format a local timestamp with a short timezone token.
     * @param {string|number|Date|null|undefined} value Date-like value.
     * @returns {string} Formatted timestamp or empty string when invalid.
     */
    toLocalTimestampWithZone(value) {
      return DateUtilities.formatDate(value, (date) => {
        const timezoneToken = DateUtilities.getLocalTimeZoneToken(date);
        const suffix = timezoneToken ? ` ${timezoneToken}` : "";

        return (
          `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-` +
          `${pad2(date.getDate())} ${pad2(date.getHours())}:` +
          `${pad2(date.getMinutes())}${suffix}`
        );
      });
    },

    /**
     * Convert a date-ish value into an ISO-8601 timestamp.
     * @param {string|number|Date|null|undefined} value Date-like value.
     * @returns {string} ISO timestamp or empty string when invalid.
     */
    toISOString(value) {
      return DateUtilities.formatDate(value, (date) => date.toISOString());
    },

    /**
     * Convert a date-ish value into a DataONE-friendly XML timestamp. This
     * preserves the UTC instant but uses an explicit `+00:00` offset.
     * @param {string|number|Date|null|undefined} value Date-like value.
     * @returns {string} XML timestamp or empty string when invalid.
     */
    toXmlDateTimeString(value) {
      const isoString = DateUtilities.toISOString(value);
      return isoString ? isoString.replace(/Z$/, "+00:00") : "";
    },

    /**
     * Return a human-readable string describing how much newer or older a date
     * is compared to a reference date.
     * @param {string|number|Date|null|undefined} value Date-like value to
     * compare.
     * @param {string|number|Date|null|undefined} referenceValue Date-like
     * reference value.
     * @param {object} [options] Formatting options.
     * @param {string} [options.newerWord] Label for positive differences.
     * @param {string} [options.olderWord] Label for negative differences.
     * @param {string} [options.currentWord] Label for equal values.
     * @returns {string} Relative date string, or empty string when invalid.
     */
    getRelativeDateString(value, referenceValue, options = {}) {
      const date = DateUtilities.toDate(value);
      const referenceDate = DateUtilities.toDate(referenceValue);
      if (!date || !referenceDate) return "";

      return DateUtilities.formatRelativeDiff(
        date.getTime() - referenceDate.getTime(),
        options,
      );
    },
  };

  return DateUtilities;
});