Improve date and time formatting with fallback for unsupported Intl.DateTimeFormat features (#7099)

* Intl.DateTimeFormat fallback

* only set timezone when available or in config
This commit is contained in:
Bernt Christian Egeland 2023-07-11 13:19:58 +02:00 committed by GitHub
parent 20415bb5e0
commit a94346e17e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

View File

@ -43,31 +43,119 @@ 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: Intl.DateTimeFormatOptions = {
dateStyle: date_style,
timeStyle: time_style,
timeZone: timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
hour12: time_format !== 'browser' ? time_format == '12hour' : undefined,
});
return formatter.format(date);
};
// Only set timeZone option when resolvedTimeZone does not match UTC±HH:MM format, or when timezone is set in config
const isUTCOffsetFormat = /^UTC[+-]\d{2}:\d{2}$/.test(resolvedTimeZone);
if (timezone || !isUTCOffsetFormat) {
options.timeZone = timezone || resolvedTimeZone;
}
const formatter = new Intl.DateTimeFormat(locale, options);
const formattedDateTime = formatter.format(date);
// Regex to check for existence of time. This is needed because dateStyle/timeStyle is not always supported.
const containsTime = /\d{1,2}:\d{1,2}/.test(formattedDateTime);
// fallback if the browser does not support dateStyle/timeStyle in Intl.DateTimeFormat
// This works even tough the timezone is undefined, it will use the runtime's default time zone
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 +210,19 @@ 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));
// If timezone is in UTC±HH:MM format, parse it to get offset
const utcOffsetMatch = timezone.match(/^UTC([+-])(\d{2}):(\d{2})$/);
if (utcOffsetMatch) {
const hours = parseInt(utcOffsetMatch[2], 10);
const minutes = parseInt(utcOffsetMatch[3], 10);
return (utcOffsetMatch[1] === '+' ? 1 : -1) * (hours * 60 + minutes);
}
// Otherwise, calculate offset using provided timezone
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;
};