@burglekitt/gmt
Version:
Temporal-based date and time utilities with timezone support and polyfill integration
94 lines (93 loc) • 3.85 kB
JavaScript
import { Temporal } from "@js-temporal/polyfill";
const UNIT_TO_INTL = {
years: "year",
months: "month",
weeks: "week",
days: "day",
hours: "hour",
minutes: "minute",
seconds: "second",
};
const UNITS_IN_ORDER = [
"years",
"months",
"weeks",
"days",
"hours",
"minutes",
"seconds",
];
/**
* Render an ISO 8601 duration string as a locale-aware, human-readable string.
*
* - Uses Temporal.Duration.from to parse, then Intl.NumberFormat({ style: "unit" })
* to render each nonzero year/month/week/day/hour/minute/second component with
* correct per-locale unit labels and pluralization, joined via Intl.ListFormat.
* - Sub-second components (milliseconds/microseconds/nanoseconds) are folded into
* the seconds component as a fractional value (e.g. "PT1.5S" -> one "1.5 seconds"
* component), since Intl.NumberFormat's "second" unit accepts fractional input.
* - By default, zero-valued components are omitted (e.g. "P1DT0H30M" -> "1 day, 30
* minutes"). Pass { zero: true } to include them.
* - A zero-length duration (e.g. "PT0S") always renders its seconds component
* ("0 seconds") even with the default zero-omitting behavior, since omitting
* every component would otherwise produce "".
* - Negative durations render each component with a leading "-" (Temporal stores
* every field of a negative duration as a negative number).
* - Returns "" for invalid input: non-string value or invalid duration string.
*
* @param value ISO 8601 duration string
* @param locale BCP 47 locale tag, passed to Intl.NumberFormat/Intl.ListFormat; system default if omitted
* @param options optional: { style: "long" | "short" | "narrow" (default "long"), zero: boolean (default false) }
* @returns human-readable rendering of the duration, or "" on invalid input
*
* @example formatDuration("P1DT2H30M", "en-US") // "1 day, 2 hours, and 30 minutes"
* @example formatDuration("PT90M", "en-US", { style: "short" }) // "90 min"
* @example formatDuration("PT90M", "en-US", { style: "narrow" }) // "90m"
* @example formatDuration("P1DT0H30M", "en-US") // "1 day and 30 minutes"
* @example formatDuration("PT0S", "en-US") // "0 seconds"
* @example formatDuration("-P1DT2H", "en-US") // "-1 day and -2 hours"
* @example formatDuration("P1DT2H30M", "de-DE") // "1 Tag, 2 Stunden und 30 Minuten"
* @example formatDuration("invalid") // ""
*/
export function formatDuration(value, locale, options = {}) {
if (typeof value !== "string") {
return "";
}
try {
const duration = Temporal.Duration.from(value);
const style = options.style ?? "long";
const includeZero = options.zero ?? false;
const seconds = duration.seconds +
duration.milliseconds / 1e3 +
duration.microseconds / 1e6 +
duration.nanoseconds / 1e9;
const amounts = {
years: duration.years,
months: duration.months,
weeks: duration.weeks,
days: duration.days,
hours: duration.hours,
minutes: duration.minutes,
seconds,
};
const parts = UNITS_IN_ORDER.filter((unit) => includeZero || amounts[unit] !== 0).map((unit) => new Intl.NumberFormat(locale, {
style: "unit",
unit: UNIT_TO_INTL[unit],
unitDisplay: style,
}).format(amounts[unit]));
if (parts.length === 0) {
parts.push(new Intl.NumberFormat(locale, {
style: "unit",
unit: "second",
unitDisplay: style,
}).format(0));
}
return new Intl.ListFormat(locale, {
style: style === "long" ? "long" : "short",
type: "conjunction",
}).format(parts);
}
catch {
return "";
}
}