diff --git a/web/src/utils/dateUtil.ts b/web/src/utils/dateUtil.ts index d991910c6..0d8e8b6c1 100644 --- a/web/src/utils/dateUtil.ts +++ b/web/src/utils/dateUtil.ts @@ -43,31 +43,112 @@ interface DateTimeStyle { strftime_fmt: string; } +// only used as a fallback if the browser does not support dateStyle/timeStyle in Intl.DateTimeFormat +const formatMap: { + [k: string]: { + date: { year: 'numeric' | '2-digit'; month: 'long' | 'short' | '2-digit'; day: 'numeric' | '2-digit' }; + time: { hour: 'numeric'; minute: 'numeric'; second?: 'numeric'; timeZoneName?: 'short' | 'long' }; + }; +} = { + full: { + date: { year: 'numeric', month: 'long', day: 'numeric' }, + time: { hour: 'numeric', minute: 'numeric', second: 'numeric', timeZoneName: 'long' }, + }, + long: { + date: { year: 'numeric', month: 'long', day: 'numeric' }, + time: { hour: 'numeric', minute: 'numeric', second: 'numeric', timeZoneName: 'long' }, + }, + medium: { + date: { year: 'numeric', month: 'short', day: 'numeric' }, + time: { hour: 'numeric', minute: 'numeric', second: 'numeric' }, + }, + short: { date: { year: '2-digit', month: '2-digit', day: '2-digit' }, time: { hour: 'numeric', minute: 'numeric' } }, +}; + +/** + * Attempts to get the system's time zone using Intl.DateTimeFormat. If that fails (for instance, in environments + * where Intl is not fully supported), it calculates the UTC offset for the current system time and returns + * it in a string format. + * + * Keeping the Intl.DateTimeFormat for now, as this is the recommended way to get the time zone. + * https://stackoverflow.com/a/34602679 + * + * Intl.DateTimeFormat function as of April 2023, works in 95.03% of the browsers used globally + * https://caniuse.com/mdn-javascript_builtins_intl_datetimeformat_resolvedoptions_computed_timezone + * + * @returns {string} The resolved time zone or a calculated UTC offset. + * The returned string will either be a named time zone (e.g., "America/Los_Angeles"), or it will follow + * the format "UTC±HH:MM". + */ +const getResolvedTimeZone = () => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch (error) { + const offsetMinutes = new Date().getTimezoneOffset(); + return `UTC${offsetMinutes < 0 ? '+' : '-'}${Math.abs(offsetMinutes / 60) + .toString() + .padStart(2, '0')}:${Math.abs(offsetMinutes % 60) + .toString() + .padStart(2, '0')}`; + } +}; + +/** + * Formats a Unix timestamp into a human-readable date/time string. + * + * The format of the output string is determined by a configuration object passed as an argument, which + * may specify a time zone, 12- or 24-hour time, and various stylistic options for the date and time. + * If these options are not specified, the function will use system defaults or sensible fallbacks. + * + * The function is robust to environments where the Intl API is not fully supported, and includes a + * fallback method to create a formatted date/time string in such cases. + * + * @param {number} unixTimestamp - The Unix timestamp to be formatted. + * @param {DateTimeStyle} config - User configuration object. + * @returns {string} A formatted date/time string. + * + * @throws {Error} If the given unixTimestamp is not a valid number, the function will return 'Invalid time'. + */ export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: DateTimeStyle): string => { const { timezone, time_format, date_style, time_style, strftime_fmt } = config; const locale = window.navigator?.language || 'en-us'; - if (isNaN(unixTimestamp)) { return 'Invalid time'; } try { const date = new Date(unixTimestamp * 1000); + const resolvedTimeZone = getResolvedTimeZone(); - // use strftime_fmt if defined in config file + // use strftime_fmt if defined in config if (strftime_fmt) { - const strftime_locale = strftime.timezone(getUTCOffset(date, timezone || Intl.DateTimeFormat().resolvedOptions().timeZone)).localizeByIdentifier(locale); + const offset = getUTCOffset(date, timezone || resolvedTimeZone); + const strftime_locale = strftime.timezone(offset).localizeByIdentifier(locale); return strftime_locale(strftime_fmt, date); } - // else use Intl.DateTimeFormat - const formatter = new Intl.DateTimeFormat(locale, { + // DateTime format options + const options = { dateStyle: date_style, timeStyle: time_style, - timeZone: timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, + timeZone: timezone || resolvedTimeZone, hour12: time_format !== 'browser' ? time_format == '12hour' : undefined, - }); - return formatter.format(date); + }; + const formatter = new Intl.DateTimeFormat(locale, options); + const formattedDateTime = formatter.format(date); + + // simple regex to check for existence of time + const containsTime = /\d{1,2}:\d{1,2}/.test(formattedDateTime); + + // fallback if the browser does not support dateStyle/timeStyle in Intl.DateTimeFormat + if (!containsTime) { + const dateOptions = { ...formatMap[date_style]?.date, timeZone: options.timeZone, hour12: options.hour12 }; + const timeOptions = { ...formatMap[time_style]?.time, timeZone: options.timeZone, hour12: options.hour12 }; + + return `${date.toLocaleDateString(locale, dateOptions)} ${date.toLocaleTimeString(locale, timeOptions)}`; + } + + return formattedDateTime; } catch (error) { return 'Invalid time'; } @@ -122,10 +203,10 @@ export const getDurationFromTimestamps = (start_time: number, end_time: number | * @returns number of minutes offset from UTC */ const getUTCOffset = (date: Date, timezone: string): number => { - const utcDate = new Date(date.getTime() - (date.getTimezoneOffset() * 60 * 1000)); + const utcDate = new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000); // locale of en-CA is required for proper locale format let iso = utcDate.toLocaleString('en-CA', { timeZone: timezone, hour12: false }).replace(', ', 'T'); iso += `.${utcDate.getMilliseconds().toString().padStart(3, '0')}`; const target = new Date(`${iso}Z`); - return (target.getTime() - utcDate.getTime()) / 60 / 1000; -} + return (target.getTime() - utcDate.getTime()) / 60 / 1000; +};