mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-09 16:10:04 +00:00
* 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
258 lines
7.7 KiB
TypeScript
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)];
|
|
};
|