|
|
@ -1,125 +1,235 @@ |
|
|
|
'use strict'; |
|
|
|
import * as dayjs from 'dayjs'; |
|
|
|
import * as advancedFormat from 'dayjs/plugin/advancedFormat'; |
|
|
|
import * as relativeTime from 'dayjs/plugin/relativeTime'; |
|
|
|
import * as updateLocale from 'dayjs/plugin/updateLocale'; |
|
|
|
|
|
|
|
dayjs.default.extend(advancedFormat.default); |
|
|
|
dayjs.default.extend(relativeTime.default, { |
|
|
|
thresholds: [ |
|
|
|
{ l: 's', r: 44, d: 'second' }, |
|
|
|
{ l: 'm', r: 89 }, |
|
|
|
{ l: 'mm', r: 44, d: 'minute' }, |
|
|
|
{ l: 'h', r: 89 }, |
|
|
|
{ l: 'hh', r: 21, d: 'hour' }, |
|
|
|
{ l: 'd', r: 35 }, |
|
|
|
{ l: 'dd', r: 6, d: 'day' }, |
|
|
|
{ l: 'w', r: 7 }, |
|
|
|
{ l: 'ww', r: 3, d: 'week' }, |
|
|
|
{ l: 'M', r: 4 }, |
|
|
|
{ l: 'MM', r: 10, d: 'month' }, |
|
|
|
{ l: 'y', r: 17 }, |
|
|
|
{ l: 'yy', d: 'year' }, |
|
|
|
], |
|
|
|
}); |
|
|
|
dayjs.default.extend(updateLocale.default); |
|
|
|
|
|
|
|
dayjs.default.updateLocale('en', { |
|
|
|
relativeTime: { |
|
|
|
future: 'in %s', |
|
|
|
past: '%s ago', |
|
|
|
s: 'seconds', |
|
|
|
m: 'a minute', |
|
|
|
mm: '%d minutes', |
|
|
|
h: 'an hour', |
|
|
|
hh: '%d hours', |
|
|
|
d: 'a day', |
|
|
|
dd: '%d days', |
|
|
|
w: 'a week', |
|
|
|
ww: '%d weeks', |
|
|
|
M: 'a month', |
|
|
|
MM: '%d months', |
|
|
|
y: 'a year', |
|
|
|
yy: '%d years', |
|
|
|
}, |
|
|
|
}); |
|
|
|
|
|
|
|
const shortLocale = { |
|
|
|
name: 'en-short', |
|
|
|
weekdays: 'Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday'.split('_'), |
|
|
|
months: 'January_February_March_April_May_June_July_August_September_October_November_December'.split('_'), |
|
|
|
weekStart: 1, |
|
|
|
weekdaysShort: 'Sun_Mon_Tue_Wed_Thu_Fri_Sat'.split('_'), |
|
|
|
monthsShort: 'Jan_Feb_Mar_Apr_May_Jun_Jul_Aug_Sep_Oct_Nov_Dec'.split('_'), |
|
|
|
weekdaysMin: 'Su_Mo_Tu_We_Th_Fr_Sa'.split('_'), |
|
|
|
// relativeTime: {
|
|
|
|
// future: 'in %s',
|
|
|
|
// past: '%s',
|
|
|
|
// s: 'now',
|
|
|
|
// m: '1 min',
|
|
|
|
// mm: '%d mins',
|
|
|
|
// h: '1 hr',
|
|
|
|
// hh: '%d hrs',
|
|
|
|
// d: '1 day',
|
|
|
|
// dd: '%d days',
|
|
|
|
// w: '1 wk',
|
|
|
|
// ww: '%d wks',
|
|
|
|
// M: '1 mo',
|
|
|
|
// MM: '%d mos',
|
|
|
|
// y: '1 yr',
|
|
|
|
// yy: '%d yrs',
|
|
|
|
// },
|
|
|
|
relativeTime: { |
|
|
|
future: 'in %s', |
|
|
|
past: '%s', |
|
|
|
s: 'now', |
|
|
|
m: '1m', |
|
|
|
mm: '%dm', |
|
|
|
h: '1h', |
|
|
|
hh: '%dh', |
|
|
|
d: '1d', |
|
|
|
dd: '%dd', |
|
|
|
w: '1wk', |
|
|
|
ww: '%dwk', |
|
|
|
M: '1mo', |
|
|
|
MM: '%dmo', |
|
|
|
y: '1yr', |
|
|
|
yy: '%dyr', |
|
|
|
}, |
|
|
|
formats: { |
|
|
|
LTS: 'h:mm:ss A', |
|
|
|
LT: 'h:mm A', |
|
|
|
L: 'MM/DD/YYYY', |
|
|
|
LL: 'MMMM D, YYYY', |
|
|
|
LLL: 'MMMM D, YYYY h:mm A', |
|
|
|
LLLL: 'dddd, MMMM D, YYYY h:mm A', |
|
|
|
}, |
|
|
|
ordinal: (n: number) => { |
|
|
|
const s = ['th', 'st', 'nd', 'rd']; |
|
|
|
const v = n % 100; |
|
|
|
return `[${n}${s[(v - 20) % 10] || s[v] || s[0]}]`; |
|
|
|
}, |
|
|
|
}; |
|
|
|
|
|
|
|
dayjs.default.locale('en-short', shortLocale, true); |
|
|
|
|
|
|
|
export const MillisecondsPerMinute = 60000; // 60 * 1000
|
|
|
|
export const MillisecondsPerHour = 3600000; // 60 * 60 * 1000
|
|
|
|
export const MillisecondsPerDay = 86400000; // 24 * 60 * 60 * 1000
|
|
|
|
|
|
|
|
// NOTE@eamodio If this changes we need to update the replacement function too (since its parameter number/order relies on the matching)
|
|
|
|
const customDateTimeFormatParserRegex = |
|
|
|
/(?<literal>\[.*?\])|(?<year>YYYY|YY)|(?<month>M{1,4})|(?<day>Do|DD?)|(?<weekday>d{2,4})|(?<hour>HH?|hh?)|(?<minute>mm?)|(?<second>ss?)|(?<fractionalSecond>SSS)|(?<dayPeriod>A|a)|(?<timeZoneName>ZZ?)/g; |
|
|
|
const dateTimeFormatCache = new Map<string | undefined, Intl.DateTimeFormat>(); |
|
|
|
const dateTimeFormatRegex = /(?<dateStyle>full|long|medium|short)(?:\+(?<timeStyle>full|long|medium|short))?/; |
|
|
|
let defaultRelativeTimeFormat: InstanceType<typeof Intl.RelativeTimeFormat> | undefined; |
|
|
|
let defaultShortRelativeTimeFormat: InstanceType<typeof Intl.RelativeTimeFormat> | undefined; |
|
|
|
let locale: string | undefined; |
|
|
|
const relativeUnitThresholds: [Intl.RelativeTimeFormatUnit, number, string][] = [ |
|
|
|
['year', 24 * 60 * 60 * 1000 * 365, 'yr'], |
|
|
|
['month', (24 * 60 * 60 * 1000 * 365) / 12, 'mo'], |
|
|
|
['week', 24 * 60 * 60 * 1000 * 7, 'wk'], |
|
|
|
['day', 24 * 60 * 60 * 1000, 'd'], |
|
|
|
['hour', 60 * 60 * 1000, 'h'], |
|
|
|
['minute', 60 * 1000, 'm'], |
|
|
|
['second', 1000, 's'], |
|
|
|
]; |
|
|
|
|
|
|
|
type DateStyle = 'full' | 'long' | 'medium' | 'short'; |
|
|
|
type TimeStyle = 'full' | 'long' | 'medium' | 'short'; |
|
|
|
export type DateTimeFormat = DateStyle | `${DateStyle}+${TimeStyle}`; |
|
|
|
|
|
|
|
export interface DateFormatter { |
|
|
|
fromNow(locale?: string): string; |
|
|
|
format(format: string): string; |
|
|
|
fromNow(short?: boolean): string; |
|
|
|
format(format: DateTimeFormat | string | null | undefined): string; |
|
|
|
} |
|
|
|
|
|
|
|
export function getFormatter(date: Date): DateFormatter { |
|
|
|
const formatter = dayjs.default(date); |
|
|
|
return { |
|
|
|
fromNow: function (locale?: string) { |
|
|
|
return (locale ? formatter.locale(locale) : formatter).fromNow(); |
|
|
|
fromNow: function (short?: boolean) { |
|
|
|
const elapsed = date.getTime() - new Date().getTime(); |
|
|
|
|
|
|
|
for (const [unit, threshold, shortUnit] of relativeUnitThresholds) { |
|
|
|
const elapsedABS = Math.abs(elapsed); |
|
|
|
if (elapsedABS >= threshold || threshold === 1000 /* second */) { |
|
|
|
if (short) { |
|
|
|
if (locale == null) { |
|
|
|
if (defaultShortRelativeTimeFormat != null) { |
|
|
|
locale = defaultShortRelativeTimeFormat.resolvedOptions().locale; |
|
|
|
} else if (defaultRelativeTimeFormat != null) { |
|
|
|
locale = defaultRelativeTimeFormat.resolvedOptions().locale; |
|
|
|
} else { |
|
|
|
defaultShortRelativeTimeFormat = new Intl.RelativeTimeFormat(undefined, { |
|
|
|
localeMatcher: 'best fit', |
|
|
|
numeric: 'always', |
|
|
|
style: 'narrow', |
|
|
|
}); |
|
|
|
locale = defaultShortRelativeTimeFormat.resolvedOptions().locale; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
if (locale === 'en' || locale?.startsWith('en-')) { |
|
|
|
const value = Math.round(elapsedABS / threshold); |
|
|
|
return `${value}${shortUnit}`; |
|
|
|
} |
|
|
|
|
|
|
|
if (defaultShortRelativeTimeFormat == null) { |
|
|
|
defaultShortRelativeTimeFormat = new Intl.RelativeTimeFormat(undefined, { |
|
|
|
localeMatcher: 'best fit', |
|
|
|
numeric: 'always', |
|
|
|
style: 'narrow', |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
return defaultShortRelativeTimeFormat.format(Math.round(elapsed / threshold), unit); |
|
|
|
} |
|
|
|
|
|
|
|
if (defaultRelativeTimeFormat == null) { |
|
|
|
defaultRelativeTimeFormat = new Intl.RelativeTimeFormat(undefined, { |
|
|
|
localeMatcher: 'best fit', |
|
|
|
numeric: 'auto', |
|
|
|
style: 'long', |
|
|
|
}); |
|
|
|
} |
|
|
|
return defaultRelativeTimeFormat.format(Math.round(elapsed / threshold), unit); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
return ''; |
|
|
|
}, |
|
|
|
format: function (format: string) { |
|
|
|
return formatter.format(format); |
|
|
|
format: function (format: 'full' | 'long' | 'medium' | 'short' | string | null | undefined) { |
|
|
|
format = format ?? undefined; |
|
|
|
|
|
|
|
let formatter = dateTimeFormatCache.get(format); |
|
|
|
if (formatter == null) { |
|
|
|
const options = getDateTimeFormatOptionsFromFormatString(format); |
|
|
|
formatter = new Intl.DateTimeFormat(undefined, options); |
|
|
|
dateTimeFormatCache.set(format, formatter); |
|
|
|
} |
|
|
|
|
|
|
|
if (format == null || dateTimeFormatRegex.test(format)) { |
|
|
|
return formatter.format(date); |
|
|
|
} |
|
|
|
|
|
|
|
const parts = formatter.formatToParts(date); |
|
|
|
return format.replace( |
|
|
|
customDateTimeFormatParserRegex, |
|
|
|
( |
|
|
|
_match, |
|
|
|
literal, |
|
|
|
_year, |
|
|
|
_month, |
|
|
|
_day, |
|
|
|
_weekday, |
|
|
|
_hour, |
|
|
|
_minute, |
|
|
|
_second, |
|
|
|
_fractionalSecond, |
|
|
|
_dayPeriod, |
|
|
|
_timeZoneName, |
|
|
|
_offset, |
|
|
|
_s, |
|
|
|
groups, |
|
|
|
) => { |
|
|
|
if (literal != null) return (literal as string).substring(1, literal.length - 1); |
|
|
|
|
|
|
|
for (const key in groups) { |
|
|
|
const value = groups[key]; |
|
|
|
if (value == null) continue; |
|
|
|
|
|
|
|
const part = parts.find(p => p.type === key); |
|
|
|
|
|
|
|
if (value === 'Do' && part?.type === 'day') { |
|
|
|
return formatWithOrdinal(Number(part.value)); |
|
|
|
} else if (value === 'a' && part?.type === 'dayPeriod') { |
|
|
|
return part.value.toLocaleLowerCase(); |
|
|
|
} |
|
|
|
return part?.value ?? ''; |
|
|
|
} |
|
|
|
|
|
|
|
return ''; |
|
|
|
}, |
|
|
|
); |
|
|
|
}, |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
function getDateTimeFormatOptionsFromFormatString( |
|
|
|
format: DateTimeFormat | string | undefined, |
|
|
|
): Intl.DateTimeFormatOptions { |
|
|
|
if (format == null) return { localeMatcher: 'best fit', dateStyle: 'full', timeStyle: 'short' }; |
|
|
|
|
|
|
|
const match = dateTimeFormatRegex.exec(format); |
|
|
|
if (match?.groups != null) { |
|
|
|
const { dateStyle, timeStyle } = match.groups; |
|
|
|
return { |
|
|
|
localeMatcher: 'best fit', |
|
|
|
dateStyle: (dateStyle as Intl.DateTimeFormatOptions['dateStyle']) || 'full', |
|
|
|
timeStyle: (timeStyle as Intl.DateTimeFormatOptions['timeStyle']) || undefined, |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
const options: Intl.DateTimeFormatOptions = { localeMatcher: 'best fit' }; |
|
|
|
|
|
|
|
for (const { groups } of format.matchAll(customDateTimeFormatParserRegex)) { |
|
|
|
if (groups == null) continue; |
|
|
|
|
|
|
|
for (const key in groups) { |
|
|
|
const value = groups[key]; |
|
|
|
if (value == null) continue; |
|
|
|
|
|
|
|
switch (key) { |
|
|
|
case 'year': |
|
|
|
options.year = value.length === 4 ? 'numeric' : '2-digit'; |
|
|
|
break; |
|
|
|
case 'month': |
|
|
|
switch (value.length) { |
|
|
|
case 4: |
|
|
|
options.month = 'long'; |
|
|
|
break; |
|
|
|
case 3: |
|
|
|
options.month = 'short'; |
|
|
|
break; |
|
|
|
case 2: |
|
|
|
options.month = '2-digit'; |
|
|
|
break; |
|
|
|
case 1: |
|
|
|
options.month = 'numeric'; |
|
|
|
break; |
|
|
|
} |
|
|
|
break; |
|
|
|
case 'day': |
|
|
|
if (value === 'DD') { |
|
|
|
options.day = '2-digit'; |
|
|
|
} else { |
|
|
|
options.day = 'numeric'; |
|
|
|
} |
|
|
|
break; |
|
|
|
case 'weekday': |
|
|
|
switch (value.length) { |
|
|
|
case 4: |
|
|
|
options.weekday = 'long'; |
|
|
|
break; |
|
|
|
case 3: |
|
|
|
options.weekday = 'short'; |
|
|
|
break; |
|
|
|
case 2: |
|
|
|
options.weekday = 'narrow'; |
|
|
|
break; |
|
|
|
} |
|
|
|
break; |
|
|
|
case 'hour': |
|
|
|
options.hour = value.length === 2 ? '2-digit' : 'numeric'; |
|
|
|
options.hour12 = value === 'hh' || value === 'h'; |
|
|
|
break; |
|
|
|
case 'minute': |
|
|
|
options.minute = value.length === 2 ? '2-digit' : 'numeric'; |
|
|
|
break; |
|
|
|
case 'second': |
|
|
|
options.second = value.length === 2 ? '2-digit' : 'numeric'; |
|
|
|
break; |
|
|
|
case 'fractionalSecond': |
|
|
|
(options as any).fractionalSecondDigits = 3; |
|
|
|
break; |
|
|
|
case 'dayPeriod': |
|
|
|
options.dayPeriod = 'narrow'; |
|
|
|
options.hour12 = true; |
|
|
|
break; |
|
|
|
case 'timeZoneName': |
|
|
|
options.timeZoneName = (value.length === 2 ? 'longOffset' : 'shortOffset') as any; |
|
|
|
break; |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
return options; |
|
|
|
} |
|
|
|
|
|
|
|
const ordinals = ['th', 'st', 'nd', 'rd']; |
|
|
|
function formatWithOrdinal(n: number): string { |
|
|
|
const v = n % 100; |
|
|
|
return `${n}${ordinals[(v - 20) % 10] ?? ordinals[v] ?? ordinals[0]}`; |
|
|
|
} |