LibreChat/client/src/utils/clock.ts
Marco Beretta 7834ebab33
🕰️ feat: Clock Format and Week Start Preferences (#15121)
* feat: clock format and week start preferences

Times were written in whatever convention the browser locale implied, and the
week always started on Sunday. Neither is right for a large part of the user
base: most of Europe reads a 24-hour clock and starts the week on Monday, and a
user running an English interface in a region that does either is currently
given the American convention with no way to change it.

Two General settings, Clock Format (System / 12-hour / 24-hour) and Week Starts
On (System / Sunday / Monday). Their System branch reads the runtime locale
rather than `i18n.language`, which is normalized down to a translation bundle:
`en-GB` and `en-AU` both become `en`, which is exactly the regional part these
two settings depend on, and reading it would report a 12-hour clock and a Sunday
week to a British user.

Week start is typed on the same 0-6 Sunday-first scale the schedule cadence uses
rather than being narrowed to Sunday/Monday, because the System branch reports
whatever the locale says and several (ar-EG, fa-IR) start the week on Saturday.
Engines without `Intl.Locale.prototype.getWeekInfo` fall back to a short list of
Sunday-first regions with Monday, the ISO 8601 default, otherwise: this is a
display default the toggle can always override, so an imperfect fallback degrades
rather than breaking.

Both settings are stored per browser. They describe how this device reads a
clock, which is a property of where someone is sitting rather than of their
account, and a user who moves between a European desktop and a US phone wants
each to read its own way.

Applied to message timestamps, the schedule dialog and card, key expiry and
refill dates, prompt and agent version dates, memory dates, and project chat
lists. The weekday order also drives the schedule dialog's day pills and the way
a weekly cadence reads back, so a wrap-around selection of Sat+Sun+Mon reads
"Monday, Saturday, Sunday" in a Monday-first week instead of "Sunday, Monday,
Saturday".

Dropdown now names its selected value as well as its field label. `aria-labelledby`
REPLACES the trigger's own text, so pointing it only at the caller's label left
the selected value unannounced, which these two settings are the first consumers
to hit.

* fix: teach the week-start fallback the Saturday-first regions

The no-week-data heuristic could only answer Sunday or Monday, folding
ar-EG to Sunday and fa-IR to Monday when CLDR says both start on
Saturday, and the selector offers no explicit Saturday override to
recover with. It now carries CLDR's Saturday-first territories, and the
UAE moves off the Sunday list to the Monday default, where CLDR put it
when its weekend moved to Sat-Sun. The fallback tests delete the
engine's week data for their duration, so they exercise the heuristic
on every engine instead of skipping wherever getWeekInfo exists.

* fix: infer likely regions for bare language tags and stop rebuilding clock formatters

A runtime that reports a language-only locale (bare ar or fa) carried no
region for the week-start heuristic, so those users fell to the Monday
default even though maximize() knows their likely region starts the week
on Saturday. The heuristic now maximizes before defaulting.

The runtime locale and each locale's meridiem answer are also cached at
module scope: every message timestamp mounts useClockFormat, so the
uncached path built a fresh Intl.DateTimeFormat per rendered message,
hundreds in a long conversation, even when the preference ignores the
locale entirely.

* fix: keep the Maldives on Friday in the week-start fallback

CLDR's lone Friday-first territory was in neither fallback set, so
dv-MV (and bare dv, which maximizes to MV) fell to Monday on engines
without week data, with no Friday override in the selector to recover
with. The three per-day sets consolidate into one region-to-day map.

* fix: complete the Sunday-first fallback from CLDR week data

The hand-picked ten Sunday-first regions left the System preference on
Monday for en-IN, id-ID, bn-BD, ur-PK, th-TH and the rest of the long
tail on engines without week data. The list is now every territory whose
und-XX week does not start Monday per CLDR, deprecated codes included,
with a note on how to regenerate it when CLDR moves a territory.

* fix: mock message context across markdown test suites and prevent global plugin cache leak
2026-08-23 18:41:14 -04:00

258 lines
7.7 KiB
TypeScript

import type { ClockFormatPreference } from '~/store/clockFormat';
import type { WeekStartPreference } from '~/store/weekStart';
/**
* The locale "System" means: the runtime's own, NOT the app's translation locale.
* `i18n.language` is normalized down to a translation bundle (`en-GB` and `en-AU`
* both become `en`, `fr-CA` becomes `fr`), which drops exactly the regional part
* these two settings read, and would tell a British user their clock is 12-hour.
* Returns undefined when the runtime cannot say, which every caller below already
* treats as "let Intl pick its own default": the same answer by a shorter route.
*/
let cachedSystemLocale: string | undefined;
let systemLocaleResolved = false;
export const systemLocale = (): string | undefined => {
// Resolved once: the runtime locale cannot change without a reload, and every
// message timestamp mounts a hook that asks, so an uncached answer builds a
// formatter per rendered message.
if (systemLocaleResolved) {
return cachedSystemLocale;
}
systemLocaleResolved = true;
try {
cachedSystemLocale = new Intl.DateTimeFormat().resolvedOptions().locale;
} catch {
cachedSystemLocale = globalThis.navigator?.language;
}
return cachedSystemLocale;
};
/** Whether a locale shows a meridiem, which is what "System" resolves to. Asked of
* `Intl` rather than kept as a region list, because that is the same question every
* date this app formats already answers for itself. Defaults to a 12-hour clock when
* the runtime cannot say, matching `Intl`'s own behaviour for an unknown locale. */
const meridiemCache = new Map<string, boolean>();
export const localeUsesMeridiem = (locale?: string): boolean => {
const cacheKey = locale ?? '';
const cached = meridiemCache.get(cacheKey);
if (cached != null) {
return cached;
}
let usesMeridiem = true;
try {
usesMeridiem =
new Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions().hour12 === true;
} catch {
usesMeridiem = true;
}
meridiemCache.set(cacheKey, usesMeridiem);
return usesMeridiem;
};
/**
* Resolves the "Clock format" setting to a concrete `hour12` boolean for a
* single call site. 'system' defers to the browser's locale; '12h'/'24h'
* override it explicitly, which is the entire point of the setting existing.
*/
export const resolveHour12 = (preference: ClockFormatPreference, locale?: string): boolean => {
if (preference === '12h') {
return true;
}
if (preference === '24h') {
return false;
}
return localeUsesMeridiem(locale);
};
/** First day of the week on the same 0-6 Sunday-first scale the schedule cadence
* uses (the cron day-of-week field). Deliberately not narrowed to Sunday/Monday:
* the setting offers only those two, but its 'system' branch reports whatever the
* locale says, and several (`ar-EG`, `fa-IR`) start the week on Saturday. */
export type WeekStartDay = 0 | 1 | 2 | 3 | 4 | 5 | 6;
/**
* Locale-only guess at the first day of the week, on the scale above.
*
* `Intl.Locale.prototype.getWeekInfo` (Baseline 2024) reports `firstDay` on a
* 1-7 ISO scale where 7 = Sunday; `% 7` folds that back to this app's 0-6
* scale. Engines without it (older Safari/Firefox) fall back to region lists
* generated from CLDR's own weekData (every territory whose `und-XX` week does
* not start Monday, deprecated codes included), with Monday, the ISO 8601
* default, otherwise. Regenerate by asking `getWeekInfo()` for each region on a
* current engine if CLDR moves a territory again.
*/
const SATURDAY_FIRST_FALLBACK_REGIONS = [
'AF',
'BH',
'DJ',
'DZ',
'EG',
'IQ',
'IR',
'JO',
'KW',
'LY',
'OM',
'QA',
'SD',
'SY',
];
const SUNDAY_FIRST_FALLBACK_REGIONS = [
'AG',
'AS',
'BD',
'BR',
'BS',
'BT',
'BU',
'BW',
'BZ',
'CA',
'CO',
'DM',
'DO',
'ET',
'GT',
'GU',
'HK',
'HN',
'ID',
'IL',
'IN',
'IS',
'JM',
'JP',
'JT',
'KE',
'KH',
'KR',
'LA',
'MH',
'MI',
'MM',
'MO',
'MT',
'MX',
'MZ',
'NI',
'NP',
'NT',
'PA',
'PE',
'PH',
'PK',
'PR',
'PT',
'PU',
'PY',
'PZ',
'RH',
'SA',
'SG',
'SV',
'TH',
'TT',
'TW',
'UM',
'US',
'VE',
'VI',
'WK',
'WS',
'YD',
'YE',
'ZA',
'ZW',
];
const FALLBACK_REGION_WEEK_START = new Map<string, WeekStartDay>([
...SATURDAY_FIRST_FALLBACK_REGIONS.map((region): [string, WeekStartDay] => [region, 6]),
...SUNDAY_FIRST_FALLBACK_REGIONS.map((region): [string, WeekStartDay] => [region, 0]),
// The Maldives is CLDR's lone Friday-first territory, and the selector offers
// no Friday override for an affected user to recover with.
['MV', 5],
]);
/** `Intl.Locale.prototype.getWeekInfo`/`.weekInfo` (Baseline 2024) predate this
* project's TS lib target, so neither member is declared on `Intl.Locale` yet. */
interface LocaleWithWeekInfo extends Intl.Locale {
getWeekInfo?: () => { firstDay: number };
weekInfo?: { firstDay: number };
}
/** `globalThis.navigator` rather than the bare global: this module is imported
* through `~/utils`, which server-side rendering and plain-node test runners
* also load, and a bare `navigator` there is a ReferenceError, not undefined. */
const localeTag = (locale?: string): string => locale ?? globalThis.navigator?.language ?? '';
/** The region subtag, for the fallback heuristic only. `Intl.Locale` where it
* parses; otherwise the first subtag SHAPED like a region, because a naive
* `split('-')[1]` reads the script subtag of `zh-Hant-TW` as the region. */
const regionOf = (tag: string): string | undefined => {
try {
const locale = new Intl.Locale(tag);
if (locale.region != null) {
return locale.region.toUpperCase();
}
// A bare language tag ('ar', 'fa') names no region, but its LIKELY one is
// exactly what a heuristic wants: without this, every language-only locale
// fell through to the Monday default, and `ar` alone reads Saturday-first.
const likelyRegion = locale.maximize().region;
if (likelyRegion != null) {
return likelyRegion.toUpperCase();
}
} catch {
// fall through to the manual scan
}
const subtag = tag
.split('-')
.slice(1)
.find((part) => /^[A-Za-z]{2}$/.test(part) || /^\d{3}$/.test(part));
return subtag?.toUpperCase();
};
export const localeWeekStartsOn = (locale?: string): WeekStartDay => {
const tag = localeTag(locale);
try {
const resolved = new Intl.Locale(tag) as LocaleWithWeekInfo;
const weekInfo =
typeof resolved.getWeekInfo === 'function' ? resolved.getWeekInfo() : resolved.weekInfo;
const firstDay = weekInfo?.firstDay;
if (firstDay != null && Number.isInteger(firstDay) && firstDay >= 1 && firstDay <= 7) {
return (firstDay % 7) as WeekStartDay;
}
} catch {
// fall through to the region heuristic below
}
const region = regionOf(tag);
if (region == null) {
return 1;
}
// The mapped days matter doubly here: the type above allows them, but the
// selector offers no Saturday or Friday override, so a user in `ar-EG` or
// `dv-MV` on such an engine has no other route back to their own week order.
return FALLBACK_REGION_WEEK_START.get(region) ?? 1;
};
/** Resolves the "Week starts on" setting to a concrete day index (0 = Sunday, 1 = Monday). */
export const resolveWeekStartsOn = (
preference: WeekStartPreference,
locale?: string,
): WeekStartDay => {
if (preference === 'sunday') {
return 0;
}
if (preference === 'monday') {
return 1;
}
return localeWeekStartsOn(locale);
};
/** Rotates 0-6 (Sunday-first) so it begins at `weekStartsOn`, for rendering a week in order. */
export const rotateWeekFrom = (weekStartsOn: WeekStartDay): number[] => {
const days = [0, 1, 2, 3, 4, 5, 6];
return [...days.slice(weekStartsOn), ...days.slice(0, weekStartsOn)];
};