@openmrs/esm-utils
Version:
Helper utilities for OpenMRS
587 lines (586 loc) • 21.2 kB
JavaScript
function _check_private_redeclaration(obj, privateCollection) {
if (privateCollection.has(obj)) {
throw new TypeError("Cannot initialize the same private elements twice on an object");
}
}
function _class_apply_descriptor_get(receiver, descriptor) {
if (descriptor.get) {
return descriptor.get.call(receiver);
}
return descriptor.value;
}
function _class_extract_field_descriptor(receiver, privateMap, action) {
if (!privateMap.has(receiver)) {
throw new TypeError("attempted to " + action + " private field on non-instance");
}
return privateMap.get(receiver);
}
function _class_private_field_get(receiver, privateMap) {
var descriptor = _class_extract_field_descriptor(receiver, privateMap, "get");
return _class_apply_descriptor_get(receiver, descriptor);
}
function _class_private_field_init(obj, privateMap, value) {
_check_private_redeclaration(obj, privateMap);
privateMap.set(obj, value);
}
/**
* @module
* @category Date and Time
*/ import { createCalendar, toCalendar } from "@internationalized/date";
import { attempt } from "any-date-parser";
import dayjs from "dayjs";
import isToday from "dayjs/plugin/isToday.js";
import objectSupport from "dayjs/plugin/objectSupport.js";
import utc from "dayjs/plugin/utc.js";
import { isNil, omit } from "lodash-es";
import { getLocale } from "../index.js";
dayjs.extend(isToday);
dayjs.extend(utc);
dayjs.extend(objectSupport);
const isoFormat = 'YYYY-MM-DDTHH:mm:ss.SSSZZ';
/**
* This function checks whether a date string is the OpenMRS ISO format.
* The format should be YYYY-MM-DDTHH:mm:ss.SSSZZ
*/ export function isOmrsDateStrict(omrsPayloadString) {
// omrs format 2018-03-19T00:00:00.000+0300
if (omrsPayloadString === null || omrsPayloadString === undefined || omrsPayloadString.trim().length !== 28) {
return false;
}
omrsPayloadString = omrsPayloadString.trim();
// 11th character will always be T
if (omrsPayloadString[10] !== 'T') {
return false;
}
// checking time format
if (omrsPayloadString[13] !== ':' || omrsPayloadString[16] !== ':' || omrsPayloadString[19] !== '.') {
return false;
}
// checking UTC offset format
if (!(omrsPayloadString[23] === '+' || omrsPayloadString[23] === '-')) {
return false;
}
return dayjs(omrsPayloadString, isoFormat).isValid();
}
/**
* Checks if the provided date is today.
*
* @param date The date to check.
* @returns `true` if the date is today, `false` otherwise.
*/ export function isOmrsDateToday(date) {
return dayjs(date).isToday();
}
/**
* Converts the object to a date object if it is an OpenMRS ISO date time string.
* Otherwise returns null.
*/ export function toDateObjectStrict(omrsDateString) {
if (!isOmrsDateStrict(omrsDateString)) {
return null;
}
return dayjs(omrsDateString, isoFormat).toDate();
}
/**
* Formats the input to OpenMRS ISO format: "YYYY-MM-DDTHH:mm:ss.SSSZZ".
*/ export function toOmrsIsoString(date, toUTC = false) {
let d = dayjs(date);
if (toUTC) {
d = d.utc();
}
return d.format(isoFormat);
}
/**
* Utility function to parse an arbitrary string into a date.
* Uses `dayjs(dateString)`.
*/ export function parseDate(dateString) {
return dayjs(dateString).toDate();
}
var _registry = /*#__PURE__*/ new WeakMap();
/**
* Internal cache for per-locale calendars
*/ class LocaleCalendars {
register(locale, calendar) {
_class_private_field_get(this, _registry).set(locale, calendar);
}
getCalendar(locale) {
if (!Boolean(locale)) {
return undefined;
}
if (locale.calendar) {
return locale.calendar;
}
if (locale.region) {
const key = `${locale.language}-${locale.region}`;
if (_class_private_field_get(this, _registry).has(key)) {
return _class_private_field_get(this, _registry).get(key);
}
}
if (locale.language && _class_private_field_get(this, _registry).has(locale.language)) {
return _class_private_field_get(this, _registry).get(locale.language);
}
const defaultCalendar = new Intl.DateTimeFormat(locale.toString()).resolvedOptions().calendar;
// cache this result
_class_private_field_get(this, _registry).set(`${locale.language}${locale.region ? `-${locale.region}` : ''}`, defaultCalendar);
return defaultCalendar;
}
constructor(){
_class_private_field_init(this, _registry, {
writable: true,
value: new Map()
});
}
}
const registeredLocaleCalendars = new LocaleCalendars();
/**
* Provides the name of the calendar to associate, as a default, with the given base locale.
*
* @example
* ```
* registerDefaultCalendar('en', 'buddhist') // sets the default calendar for the 'en' locale to Buddhist.
* ```
*
* @param locale the locale to register this calendar for
* @param calendar the calendar to use for this registration
*/ export function registerDefaultCalendar(locale, calendar) {
registeredLocaleCalendars.register(locale, calendar);
}
/**
* Retrieves the default calendar for the specified locale if any.
*
* @param locale the locale to look-up
*/ export function getDefaultCalendar(locale) {
const locale_ = locale ?? getLocale();
return registeredLocaleCalendars.getCalendar(locale_ instanceof Intl.Locale ? locale_ : new Intl.Locale(locale_));
}
const defaultOptions = {
mode: 'standard',
time: 'for today',
day: true,
month: true,
year: true,
noToday: false
};
/**
* Formats the string representing a date, including partial representations of dates, according to the current
* locale and the given options.
*
* Default options:
* - mode: "standard",
* - time: "for today",
* - day: true,
* - month: true,
* - year: true
* - noToday: false
*
* If the date is today then "Today" is produced (in the locale language).
* This behavior can be disabled with `noToday: true`.
*
* When time is included, it is appended with a comma and a space. This
* agrees with the output of `Date.prototype.toLocaleString` for *most*
* locales.
*
* @param dateString The date string to parse and format.
* @param options Optional formatting options.
* @returns The formatted date string, or `null` if the input cannot be parsed.
*/ // TODO: Shouldn't throw on null input
export function formatPartialDate(dateString, options = {}) {
const locale = getLocale();
let parsed = attempt(dateString, locale);
if (parsed.invalid) {
console.warn(`Could not parse invalid date '${dateString}'`);
return null;
}
// hack here but any date interprets 2000-01, etc. as yyyy-dd rather than yyyy-mm
if (!isNil(parsed.day) && isNil(parsed.month)) {
parsed = Object.assign({}, omit(parsed, 'day'), {
month: parsed.day
});
}
// dayjs' object support uses 0-based months, whereas any-date-parser uses 1-based months
if (parsed.month) {
parsed.month -= 1;
}
// in dayjs day is day of week; in any-date-parser, its day of month, so we need to convert them
if (parsed.day) {
parsed = Object.assign({}, omit(parsed, 'day'), {
date: parsed.day
});
}
const date = dayjs().set(parsed).toDate();
if (isNil(parsed.year)) {
options.year = false;
}
if (isNil(parsed.month)) {
options.month = false;
}
if (isNil(parsed.date)) {
options.day = false;
}
return formatDate(date, options);
}
/**
* Formats the input date according to the current locale and the
* given options.
*
* Default options:
* - mode: "standard",
* - time: "for today",
* - day: true,
* - month: true,
* - year: true
* - noToday: false
*
* If the date is today then "Today" is produced (in the locale language).
* This behavior can be disabled with `noToday: true`.
*
* When time is included, it is appended with a comma and a space. This
* agrees with the output of `Date.prototype.toLocaleString` for *most*
* locales.
*
* @param date The date to format.
* @param options Optional formatting options.
* @returns The formatted date string.
*/ // TODO: Shouldn't throw on null input
export function formatDate(date, options) {
let locale = options?.locale ?? getLocale();
const _locale = new Intl.Locale(locale);
const { calendar, mode, time, day, month, year, noToday, numberingSystem } = {
...defaultOptions,
...{
noToday: _locale.language === 'am' ? true : false
},
...options
};
const formatCalendar = calendar ?? getDefaultCalendar(_locale);
const formatterOptions = {
calendar: formatCalendar,
year: year ? 'numeric' : undefined,
month: month ? 'short' : undefined,
day: day ? '2-digit' : undefined,
numberingSystem
};
let localeString;
const isToday = dayjs(date).isToday();
if (isToday && !noToday) {
// This produces the word "Today" in the language of `locale`
const rtf = new Intl.RelativeTimeFormat(locale, {
numeric: 'auto'
});
localeString = rtf.format(0, 'day');
localeString = localeString[0].toLocaleUpperCase(locale) + localeString.slice(1);
} else {
if (_locale.language === 'en') {
// This locale override is here rather than in `getLocale`
// because Americans should see AM/PM for times.
locale = 'en-GB';
}
const formatter = new Intl.DateTimeFormat(locale, formatterOptions);
let parts = formatter.formatToParts(date);
if ((_locale.language === 'en' || _locale.language === 'am') && mode == 'standard' && year && day) {
// Custom formatting for English and Amharic. Use hyphens instead of spaces.
parts = parts.map(formatParts('-'));
}
if (mode == 'wide') {
parts = parts.map(formatParts(' — ')); // space-emdash-space
}
// omit the era when using the Ethiopic calendar
if (formatterOptions.calendar === 'ethiopic') {
parts = parts.filter((part, idx, values)=>{
if (part.type === 'era' || part.type === 'literal' && idx < values.length - 1 && values[idx + 1].type === 'era') {
return false;
}
return true;
});
}
localeString = parts.map((p)=>p.value).join('');
}
if (time === true || isToday && time === 'for today') {
localeString += `, ${formatTime(date)}`;
}
return localeString;
}
// Internal curried call-back for map()
const formatParts = (separator)=>{
return (part, idx, values)=>{
if (part.type !== 'literal' || part.value !== ' ') {
return part;
}
if (idx < values.length - 1 && values[idx + 1].type === 'era') {
return part;
}
return {
type: 'literal',
value: separator
};
};
};
/**
* Formats the input as a time, according to the current locale.
* 12-hour or 24-hour clock depends on locale.
*
* @param date The date whose time portion should be formatted.
* @returns The formatted time string (e.g., "2:30 PM" or "14:30").
*/ export function formatTime(date) {
return date.toLocaleTimeString(getLocale(), {
hour: '2-digit',
minute: '2-digit'
});
}
/**
* Formats the input into a string showing the date and time, according
* to the current locale. The `mode` parameter is as described for
* `formatDate`.
*
* This is created by concatenating the results of `formatDate`
* and `formatTime` with a comma and space. This agrees with the
* output of `Date.prototype.toLocaleString` for *most* locales.
*
* @param date The date to format.
* @param options Optional formatting options (same as formatDate, except time is always included).
* @returns The formatted date and time string.
*/ export function formatDatetime(date, options) {
return formatDate(date, {
...options,
time: true
});
}
/**
* Converts a calendar date to the equivalent locale calendar date.
* @returns CalendarDate
*/ export function convertToLocaleCalendar(date, locale) {
let locale_ = typeof locale === 'string' ? new Intl.Locale(locale) : locale;
const localCalendarName = getDefaultCalendar(locale_);
return localCalendarName ? toCalendar(date, createCalendar(localCalendarName)) : date;
}
/**
* Formats the input duration according to the current locale.
*
* @param duration The duration to format (DurationInput object).
* @param options Optional options for formatting.
* @returns The formatted duration string.
*/ export function formatDuration(duration, options) {
const formatter = new Intl.DurationFormat(getLocale(), options);
return formatter.format(duration);
}
/**
* Parses a date input into a dayjs object. String inputs are interpreted using
* any-date-parser with corrections for its month/day representation differences
* with dayjs. Non-string inputs are passed directly to dayjs.
*
* @param dateInput The date to parse.
* @param referenceDate Used as the base when resolving partial string dates (e.g., '2000' resolves missing fields from this date).
* @returns A dayjs object, or null if the string could not be parsed.
*/ export function parseDateInput(dateInput, referenceDate) {
if (dateInput == null) {
return null;
}
if (typeof dateInput === 'string') {
const locale = getLocale();
let parsedDate = attempt(dateInput, locale);
if (parsedDate.invalid) {
console.warn(`Could not interpret '${dateInput}' as a date`);
return null;
}
// hack here but any date interprets 2000-01, etc. as yyyy-dd rather than yyyy-mm
if (parsedDate.day && !parsedDate.month) {
parsedDate = {
...omit(parsedDate, 'day'),
...{
month: parsedDate.day
}
};
}
// dayjs' object support uses 0-based months, whereas any-date-parser uses 1-based months
if (parsedDate.month) {
parsedDate.month -= 1;
}
// in dayjs day is day of week; in any-date-parser, its day of month, so we need to convert them
if (parsedDate.day) {
parsedDate = {
...omit(parsedDate, 'day'),
...{
date: parsedDate.day
}
};
}
return dayjs(referenceDate).set(parsedDate);
}
return dayjs(dateInput);
}
const UNIT_ORDER = [
'years',
'months',
'days',
'hours',
'minutes',
'seconds'
];
const SINGULAR_TO_PLURAL = {
second: 'seconds',
minute: 'minutes',
hour: 'hours',
day: 'days',
month: 'months',
year: 'years'
};
function normalizeUnit(unit) {
return SINGULAR_TO_PLURAL[unit] ?? unit;
}
/**
* Normalizes threshold keys from singular/plural to plural, then merges with defaults.
*/ function normalizeThresholds(thresholds) {
const result = {
...DEFAULT_THRESHOLDS
};
if (thresholds) {
for (const [key, value] of Object.entries(thresholds)){
if (value !== undefined) {
result[normalizeUnit(key)] = value;
}
}
}
return result;
}
const DEFAULT_THRESHOLDS = {
seconds: 45,
minutes: 45,
hours: 22,
days: 26,
months: 11,
years: Infinity
};
/**
* Auto-selects the appropriate unit based on the magnitude of the duration,
* using the provided thresholds (or defaults).
*/ function autoSelectUnit(from, to, thresholds) {
const t = normalizeThresholds(thresholds);
if (to.diff(from, 'seconds') < t.seconds) return 'seconds';
if (to.diff(from, 'minutes') < t.minutes) return 'minutes';
if (to.diff(from, 'hours') < t.hours) return 'hours';
if (to.diff(from, 'days') < t.days) return 'days';
if (to.diff(from, 'months') < t.months) return 'months';
return 'years';
}
/**
* Finds the largest unit with a non-zero diff, or falls back to smallestUnit.
* Mirrors the Temporal.Duration.round() 'auto' behavior for largestUnit.
*/ function autoLargestUnit(from, to, smallestUnit) {
const smallestIdx = UNIT_ORDER.indexOf(smallestUnit);
for(let i = 0; i < UNIT_ORDER.length; i++){
const unit = UNIT_ORDER[i];
if (to.diff(from, unit) > 0) {
// Return this unit or smallestUnit, whichever is coarser (lower index)
return i <= smallestIdx ? unit : smallestUnit;
}
}
return smallestUnit;
}
/**
* Decomposes the duration between two dates across a range of units, from
* largestUnit down to smallestUnit. Each unit's value is the remainder after
* subtracting all larger units.
*/ function decompose(from, to, largestUnit, smallestUnit) {
const startIdx = UNIT_ORDER.indexOf(largestUnit);
const endIdx = UNIT_ORDER.indexOf(smallestUnit);
const units = UNIT_ORDER.slice(startIdx, endIdx + 1);
const result = {};
let current = from;
for (const unit of units){
const diff = to.diff(current, unit);
result[unit] = diff;
current = current.add(diff, unit);
}
return result;
}
/**
* Calculates the duration between two dates as a structured duration object.
*
* When called with no options or a single unit string, the unit is auto-selected
* using dayjs relativeTime thresholds:
* - < 45 seconds → seconds
* - < 45 minutes → minutes
* - < 22 hours → hours
* - < 26 days → days
* - < 11 months → months
* - otherwise → years
*
* With a {@link DurationOptions} object, you can override thresholds and/or request
* a multi-unit decomposition via largestUnit/smallestUnit.
*
* @param startDate The start date. If null, returns null.
* @param endDate Optional. Defaults to now.
* @param options A unit string for single-unit output, or a DurationOptions object.
* @returns A DurationInput object, or null if either date is null or unparseable.
*
* @example
* // Auto-selects the appropriate unit
* duration('2022-01-01', '2024-07-30') // => { years: 2 }
*
* @example
* // Multi-unit decomposition
* duration('2022-01-01', '2024-07-30', { largestUnit: 'year', smallestUnit: 'day' })
* // => { years: 2, months: 6, days: 29 }
*/ export function duration(startDate, endDate = dayjs(), options) {
const to = dayjs(endDate);
const from = parseDateInput(startDate, to);
if (from == null) {
return null;
}
if (typeof options === 'string') {
const normalized = normalizeUnit(options);
return {
[normalized]: to.diff(from, normalized)
};
}
const { thresholds, largestUnit: rawLargest, smallestUnit: rawSmallest } = options ?? {};
if (rawLargest !== undefined || rawSmallest !== undefined) {
const smallest = rawSmallest ? normalizeUnit(rawSmallest) : undefined;
let largest;
if (rawLargest === 'auto' || rawLargest === undefined) {
// 'auto' or omitted: resolve to the largest non-zero unit, or smallestUnit, whichever is coarser
const effectiveSmallest = smallest ?? 'seconds';
largest = autoLargestUnit(from, to, effectiveSmallest);
} else {
largest = normalizeUnit(rawLargest);
}
return decompose(from, to, largest, smallest ?? largest);
}
const selected = autoSelectUnit(from, to, thresholds);
return {
[selected]: to.diff(from, selected)
};
}
const DEFAULT_FORMAT_OPTIONS = {
style: 'short',
localeMatcher: 'lookup'
};
/**
* Calculates the duration between two dates and formats it as a locale-aware string.
* Uses the same unit-selection logic as {@link duration} and delegates formatting
* to {@link formatDuration}.
*
* @param startDate The start date. If null, returns null.
* @param endDate Optional. Defaults to now.
* @param options A unit string for single-unit output, or a {@link DurationOptionsWithFormat} object.
* The `formatOptions` field is passed to Intl.DurationFormat (defaults to short style).
* @returns A formatted duration string, or null if either date is null or unparseable.
*
* @example
* formatDurationBetween('2022-01-01', '2024-07-30') // => '2 yrs'
*
* @example
* // Multi-unit with long-form formatting
* formatDurationBetween('2022-01-01', '2024-07-30', {
* largestUnit: 'year',
* smallestUnit: 'day',
* formatOptions: { style: 'long' },
* }) // => '2 years, 6 months, 29 days'
*/ export function formatDurationBetween(startDate, endDate = dayjs(), options) {
const durationInput = duration(startDate, endDate, options);
if (durationInput == null) {
return null;
}
const formatOpts = typeof options === 'object' && options.formatOptions ? {
...DEFAULT_FORMAT_OPTIONS,
...options.formatOptions
} : DEFAULT_FORMAT_OPTIONS;
return formatDuration(durationInput, formatOpts);
}