@openmrs/esm-utils
Version:
Helper utilities for OpenMRS
416 lines (355 loc) • 12 kB
text/typescript
/**
* @module
* @category Date and Time
*/
import {
type CalendarDate,
type CalendarDateTime,
type CalendarIdentifier,
type ZonedDateTime,
createCalendar,
toCalendar,
} from '@internationalized/date';
import { getLocale } from '@openmrs/esm-utils';
import { attempt } from 'any-date-parser';
import dayjs from 'dayjs';
import isToday from 'dayjs/plugin/isToday';
import objectSupport from 'dayjs/plugin/objectSupport';
import utc from 'dayjs/plugin/utc';
import { isNil, omit } from 'lodash-es';
dayjs.extend(isToday);
dayjs.extend(utc);
dayjs.extend(objectSupport);
export type DateInput = string | number | Date;
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: string): boolean {
// 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();
}
/**
*
* @param date Checks if the provided date is today.
*/
export function isOmrsDateToday(date: DateInput) {
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: string): Date | null {
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: DateInput, toUTC = false): string {
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: string) {
return dayjs(dateString).toDate();
}
/**
* Internal cache for per-locale calendars
*/
class LocaleCalendars {
#registry = new Map<string, CalendarIdentifier>();
constructor() {}
register(locale: string, calendar: CalendarIdentifier) {
this.#registry.set(locale, calendar);
}
getCalendar(locale: Intl.Locale): CalendarIdentifier | undefined {
if (!Boolean(locale)) {
return undefined;
}
if (locale.calendar) {
return locale.calendar as CalendarIdentifier;
}
if (locale.region) {
const key = `${locale.language}-${locale.region}`;
if (this.#registry.has(key)) {
return this.#registry.get(key);
}
}
if (locale.language && this.#registry.has(locale.language)) {
return this.#registry.get(locale.language);
}
const defaultCalendar = new Intl.DateTimeFormat(locale.toString()).resolvedOptions().calendar as CalendarIdentifier;
// cache this result
this.#registry.set(`${locale.language}${locale.region ? `-${locale.region}` : ''}`, defaultCalendar);
return defaultCalendar;
}
}
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: string, calendar: CalendarIdentifier) {
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: Intl.Locale | string | undefined) {
const locale_ = locale ?? getLocale();
return registeredLocaleCalendars.getCalendar(locale_ instanceof Intl.Locale ? locale_ : new Intl.Locale(locale_));
}
export type FormatDateMode = 'standard' | 'wide';
export type FormatDateOptions = {
/**
* The calendar to use when formatting this date.
*/
calendar?: string;
/**
* The locale to use when formatting this date
*/
locale?: string;
/**
* - `standard`: "03 Feb 2022"
* - `wide`: "03 — Feb — 2022"
*/
mode: FormatDateMode;
/**
* Whether the time should be included in the output always (`true`),
* never (`false`), or only when the input date is today (`for today`).
*/
time: true | false | 'for today';
/** Whether to include the day number */
day: boolean;
/** Whether to include the month number */
month: boolean;
/** Whether to include the year */
year: boolean;
/** The unicode numbering system to use */
numberingSystem?: string;
/**
* Disables the special handling of dates that are today. If false
* (the default), then dates that are today will be formatted as "Today"
* in the locale language. If true, then dates that are today will be
* formatted the same as all other dates.
*/
noToday: boolean;
};
const defaultOptions: FormatDateOptions = {
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.
*/
// TODO: Shouldn't throw on null input
export function formatPartialDate(dateString: string, options: Partial<FormatDateOptions> = {}) {
const locale = getLocale();
let parsed: ReturnType<typeof attempt> & { date?: number } = 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.
*/
// TODO: Shouldn't throw on null input
export function formatDate(date: Date, options?: Partial<FormatDateOptions>) {
let locale = options?.locale ?? getLocale();
const _locale = new Intl.Locale(locale);
const { calendar, mode, time, day, month, year, noToday, numberingSystem }: FormatDateOptions = {
...defaultOptions,
...{ noToday: _locale.language === 'am' ? true : false },
...options,
};
const formatCalendar = calendar ?? getDefaultCalendar(_locale);
const formatterOptions: Intl.DateTimeFormatOptions = {
calendar: formatCalendar,
year: year ? 'numeric' : undefined,
month: month ? 'short' : undefined,
day: day ? '2-digit' : undefined,
numberingSystem,
};
let localeString: string;
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: string) => {
return (part: Intl.DateTimeFormatPart, idx: number, values: Array<Intl.DateTimeFormatPart>) => {
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 } as Intl.DateTimeFormatPart;
};
};
/**
* Formats the input as a time, according to the current locale.
* 12-hour or 24-hour clock depends on locale.
*/
export function formatTime(date: 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.
*/
export function formatDatetime(date: Date, options?: Partial<Omit<FormatDateOptions, 'time'>>) {
return formatDate(date, { ...options, time: true });
}
/**
* Converts a calendar date to the equivalent locale calendar date.
* @returns CalendarDate
*/
export function convertToLocaleCalendar(
date: CalendarDate | CalendarDateTime | ZonedDateTime,
locale: string | Intl.Locale,
) {
let locale_ = typeof locale === 'string' ? new Intl.Locale(locale) : locale;
const localCalendarName = getDefaultCalendar(locale_);
return localCalendarName ? toCalendar(date, createCalendar(localCalendarName)) : date;
}