UNPKG

@openmrs/esm-utils

Version:
416 lines (355 loc) 12 kB
/** * @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; }