🕰️ 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
This commit is contained in:
Marco Beretta 2026-08-24 00:41:14 +02:00 committed by GitHub
parent b421f900dd
commit 7834ebab33
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1020 additions and 148 deletions

View file

@ -15,7 +15,7 @@ type TContentProps = {
};
const Markdown = memo(function Markdown({ content = '', isLatestMessage }: TContentProps) {
const { isSubmitting = false } = useMessageContext();
const { isSubmitting = false } = useMessageContext() ?? {};
const smoothStreaming = useSmoothStreaming();
const LaTeXParsing = useRecoilValue<boolean>(store.LaTeXParsing);
const isInitializing = content === '';

View file

@ -1,27 +1,15 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import { render, screen } from '@testing-library/react';
import {
useMessageContext,
useOptionalMessagesConversation,
useOptionalMessagesOperations,
} from '~/Providers';
import { useConversationUIResources } from '~/hooks/Messages/useConversationUIResources';
import { UI_RESOURCE_MARKER } from '~/components/MCPUIResource/plugin';
import { useGetMessagesByConvoId } from '~/data-provider';
import MarkdownLite from '../MarkdownLite';
import { useLocalize } from '~/hooks';
import Markdown from '../Markdown';
// Mocks for hooks used by MCPUIResource when rendered inside Markdown.
// Keep Provider components intact while mocking only the hooks we use.
jest.mock('~/Providers', () => ({
...jest.requireActual('~/Providers'),
useMessageContext: jest.fn(),
useOptionalMessagesConversation: jest.fn(),
useOptionalMessagesOperations: jest.fn(),
// Mock specific leaf hook rather than barrel exports to avoid circular module evaluation in Jest
jest.mock('~/hooks/Messages/useConversationUIResources', () => ({
useConversationUIResources: jest.fn(),
}));
jest.mock('~/data-provider');
jest.mock('~/hooks');
// Mock @mcp-ui/client to render identifiable elements for assertions
jest.mock('@mcp-ui/client', () => ({
@ -30,35 +18,13 @@ jest.mock('@mcp-ui/client', () => ({
),
}));
const mockUseMessageContext = useMessageContext as jest.MockedFunction<typeof useMessageContext>;
const mockUseMessagesConversation = useOptionalMessagesConversation as jest.MockedFunction<
typeof useOptionalMessagesConversation
const mockUseConversationUIResources = useConversationUIResources as jest.MockedFunction<
typeof useConversationUIResources
>;
const mockUseMessagesOperations = useOptionalMessagesOperations as jest.MockedFunction<
typeof useOptionalMessagesOperations
>;
const mockUseGetMessagesByConvoId = useGetMessagesByConvoId as jest.MockedFunction<
typeof useGetMessagesByConvoId
>;
const mockUseLocalize = useLocalize as jest.MockedFunction<typeof useLocalize>;
describe('Markdown with MCP UI markers (resource IDs)', () => {
let currentTestMessages: any[] = [];
beforeEach(() => {
jest.clearAllMocks();
currentTestMessages = [];
mockUseMessageContext.mockReturnValue({ messageId: 'msg-weather' } as any);
mockUseMessagesConversation.mockReturnValue({
conversation: { conversationId: 'conv1' },
conversationId: 'conv1',
} as any);
mockUseMessagesOperations.mockReturnValue({
ask: jest.fn(),
getMessages: () => currentTestMessages,
} as any);
mockUseLocalize.mockReturnValue(((key: string) => key) as any);
});
it('renders two UIResourceRenderer components for markers with resource IDs across separate attachments', () => {
@ -76,17 +42,11 @@ describe('Markdown with MCP UI markers (resource IDs)', () => {
text: '<div>NYC Weather</div>',
};
currentTestMessages = [
{
messageId: 'msg-weather',
attachments: [
{ type: 'ui_resources', ui_resources: [paris] },
{ type: 'ui_resources', ui_resources: [nyc] },
],
},
];
mockUseGetMessagesByConvoId.mockReturnValue({ data: currentTestMessages } as any);
const resourceMap = new Map<string, any>([
['abc123', paris],
['def456', nyc],
]);
mockUseConversationUIResources.mockReturnValue(resourceMap as any);
const content = [
'Here are the current weather conditions for both Paris and New York:',

View file

@ -22,59 +22,40 @@ import { unicodeCitation } from '~/components/Web';
* whole-message renderer and the per-block memoized renderer so both produce
* identical output.
*
* These are exposed as lazily-initialized, cached getters rather than top-level
* consts on purpose: `MarkdownComponents` participates in a circular import
* (`MarkdownComponents` `CodeBlock` `Parts` `Markdown` here
* `MarkdownComponents`). Reading `code`/`a`/ at module-evaluation time throws
* `Cannot access 'code' before initialization` under native ESM. Deferring the
* read to first call (render time) sidesteps the temporal dead zone, and caching
* keeps a stable reference so react-markdown does not rebuild its processor.
* These are exposed as lazily-initialized getters rather than top-level
* consts on purpose: MarkdownComponents participates in a circular import
* (MarkdownComponents -> CodeBlock -> Parts -> Markdown -> here ->
* MarkdownComponents). Reading code/a/... at module-evaluation time throws
* Cannot access 'code' before initialization under native ESM. Deferring the
* read to call time (when components render or memoize) sidesteps the
* temporal dead zone.
*/
let remarkPluginsCache: PluggableList | null = null;
let rehypePluginsCache: PluggableList | null = null;
let markdownComponentsCache: { [nodeType: string]: ElementType } | null = null;
export const getRemarkPlugins = (): PluggableList => [
remarkApproxTilde,
supersub,
remarkGfm,
remarkDirective,
artifactPlugin,
[remarkMath, { singleDollarTextMath: false }],
unicodeCitation,
mcpUIResourcePlugin,
];
export const getRemarkPlugins = (): PluggableList => {
if (remarkPluginsCache === null) {
remarkPluginsCache = [
remarkApproxTilde,
supersub,
remarkGfm,
remarkDirective,
artifactPlugin,
[remarkMath, { singleDollarTextMath: false }],
unicodeCitation,
mcpUIResourcePlugin,
];
}
return remarkPluginsCache;
};
export const getRehypePlugins = (): PluggableList => [
[rehypeKatex],
[rehypeHighlight, { detect: true, ignoreMissing: true, subset: langSubset }],
];
export const getRehypePlugins = (): PluggableList => {
if (rehypePluginsCache === null) {
rehypePluginsCache = [
[rehypeKatex],
[rehypeHighlight, { detect: true, ignoreMissing: true, subset: langSubset }],
];
}
return rehypePluginsCache;
};
export const getMarkdownComponents = (): { [nodeType: string]: ElementType } => {
if (markdownComponentsCache === null) {
markdownComponentsCache = {
code,
a,
p,
img,
table,
artifact: Artifact,
citation: Citation,
'highlighted-text': HighlightedText,
'composite-citation': CompositeCitation,
'mcp-ui-resource': MCPUIResource,
'mcp-ui-carousel': MCPUIResourceCarousel,
};
}
return markdownComponentsCache;
};
export const getMarkdownComponents = (): { [nodeType: string]: ElementType } => ({
code,
a,
p,
img,
table,
artifact: Artifact,
citation: Citation,
'highlighted-text': HighlightedText,
'composite-citation': CompositeCitation,
'mcp-ui-resource': MCPUIResource,
'mcp-ui-carousel': MCPUIResourceCarousel,
});

View file

@ -1,4 +1,5 @@
import { useTranslation } from 'react-i18next';
import useClockFormat from '~/hooks/useClockFormat';
import { cn, getMessageTimestamp } from '~/utils';
import useTimeTick from '~/hooks/useTimeTick';
@ -34,16 +35,18 @@ function TimestampText({
function RecentTimestamp({
value,
language,
hour12,
className,
revealOnHover,
}: {
value?: string | null;
language: string;
hour12: boolean;
className?: string;
revealOnHover?: boolean;
}) {
useTimeTick();
const timestamp = getMessageTimestamp(value, language);
const timestamp = getMessageTimestamp(value, language, hour12);
if (!timestamp) {
return null;
@ -71,7 +74,8 @@ export default function MessageTimestamp({
revealOnHover?: boolean;
}) {
const { i18n } = useTranslation();
const timestamp = getMessageTimestamp(value, i18n.language);
const hour12 = useClockFormat();
const timestamp = getMessageTimestamp(value, i18n.language, hour12);
if (!timestamp) {
return null;
@ -82,6 +86,7 @@ export default function MessageTimestamp({
<RecentTimestamp
value={value}
language={i18n.language}
hour12={hour12}
className={className}
revealOnHover={revealOnHover}
/>

View file

@ -19,7 +19,7 @@ import {
OGDialogTrigger,
} from '@librechat/client';
import type { TDialogProps } from '~/common';
import { useUserKey, useLocalize } from '~/hooks';
import { useUserKey, useLocalize, useClockFormat } from '~/hooks';
import { NotificationSeverity } from '~/common';
import { formatKeyExpiryLabel } from './utils';
import CustomConfig from './CustomEndpoint';
@ -364,11 +364,12 @@ const SetKeyDialog = ({
const EndpointComponent = endpointComponents[configuredEndpoint] ?? endpointComponents['default'];
const expiryTime = getExpiry();
const hour12 = useClockFormat();
let currentExpiryLabel: string | null = null;
if (expiryTime === 'never') {
currentExpiryLabel = localize('com_endpoint_config_key_never_expires');
} else if (expiryTime !== undefined) {
currentExpiryLabel = formatKeyExpiryLabel(localize, expiryTime);
currentExpiryLabel = formatKeyExpiryLabel(localize, expiryTime, hour12);
}
return (

View file

@ -3,8 +3,8 @@ import type { TranslationKeys } from '~/hooks';
type Localize = (phraseKey: TranslationKeys, options?: TOptions) => string;
export function formatKeyExpiryLabel(localize: Localize, expiry: string): string {
const formattedExpiry = new Date(expiry).toLocaleString();
export function formatKeyExpiryLabel(localize: Localize, expiry: string, hour12?: boolean): string {
const formattedExpiry = new Date(expiry).toLocaleString(undefined, { hour12 });
const localizedLabel = localize('com_endpoint_config_key_encryption', {
0: formattedExpiry,
});

View file

@ -19,7 +19,9 @@ import DisplayUsernameMessages from '../SettingsTabs/Account/DisplayUsernameMess
import ConversationModeSwitch from '../SettingsTabs/Speech/ConversationModeSwitch';
import EnableTwoFactorItem from '../SettingsTabs/Account/TwoFactorAuthentication';
import LangfuseConnection from '../SettingsTabs/Integrations/LangfuseConnection';
import ClockFormatSelector from '../SettingsTabs/General/ClockFormatSelector';
import ImportConversations from '../SettingsTabs/Data/ImportConversations';
import WeekStartSelector from '../SettingsTabs/General/WeekStartSelector';
import { ArchiveAllChats } from '../SettingsTabs/Data/ArchiveAllChats';
import { toggleControl, ThemeSetting, LangSetting } from './controls';
import BackupCodesItem from '../SettingsTabs/Account/BackupCodesItem';
@ -87,6 +89,22 @@ export const registry: SettingEntry[] = [
keywords: ['rtl', 'ltr'],
Component: ChatDirection,
},
{
id: 'clockFormat',
tab: GENERAL,
section: 'appearance',
labelKey: 'com_nav_clock_format',
keywords: ['time', '12-hour', '24-hour', 'am', 'pm', 'meridiem'],
Component: ClockFormatSelector,
},
{
id: 'weekStart',
tab: GENERAL,
section: 'appearance',
labelKey: 'com_nav_week_start',
keywords: ['week', 'calendar', 'sunday', 'monday'],
Component: WeekStartSelector,
},
// General · Layout
{
id: 'maximizeChatSpace',

View file

@ -5,7 +5,7 @@ import { getRefillEligibilityDate } from 'librechat-data-provider';
import type { RefillIntervalUnit, TBalanceResponse } from 'librechat-data-provider';
import type { TranslationKeys } from '~/hooks';
import { useLocalize } from '~/hooks';
import { useLocalize, useClockFormat } from '~/hooks';
function ensureExhaustive(value: never): void {
void value;
@ -25,6 +25,7 @@ const AutoRefillSettings: React.FC<AutoRefillSettingsProps> = ({
refillIntervalValue,
}) => {
const localize = useLocalize();
const hour12 = useClockFormat();
const lastRefillDate = lastRefill ? new Date(lastRefill) : null;
const refillEligibilityDate = lastRefillDate
@ -65,7 +66,7 @@ const AutoRefillSettings: React.FC<AutoRefillSettingsProps> = ({
<h3 className="text-lg font-medium">{localize('com_nav_balance_auto_refill_settings')}</h3>
<div className="mb-1 flex justify-between text-sm">
<span>{localize('com_nav_balance_last_refill')}</span>
<span>{lastRefillDate ? lastRefillDate.toLocaleString() : '-'}</span>
<span>{lastRefillDate ? lastRefillDate.toLocaleString(undefined, { hour12 }) : '-'}</span>
</div>
<div className="mb-1 flex justify-between text-sm">
<span>{localize('com_nav_balance_refill_amount')}</span>
@ -85,7 +86,9 @@ const AutoRefillSettings: React.FC<AutoRefillSettingsProps> = ({
</div>
<span className="text-sm font-medium text-text-primary" role="note">
{refillEligibilityDate ? refillEligibilityDate.toLocaleString() : '-'}
{refillEligibilityDate
? refillEligibilityDate.toLocaleString(undefined, { hour12 })
: '-'}
</span>
</div>
</div>

View file

@ -0,0 +1,36 @@
import { render, fireEvent, waitFor } from '@testing-library/react';
import ClockFormatSelector from './ClockFormatSelector';
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));
describe('ClockFormatSelector', () => {
beforeEach(() => {
localStorage.clear();
global.ResizeObserver = class MockedResizeObserver {
observe = jest.fn();
unobserve = jest.fn();
disconnect = jest.fn();
} as unknown as typeof ResizeObserver;
});
it('renders with System selected by default', () => {
const { getByText, getByTestId } = render(<ClockFormatSelector />);
expect(getByText('com_nav_clock_format')).toBeInTheDocument();
expect(getByTestId('clock-format-selector')).toHaveTextContent('com_nav_clock_format_system');
});
it('persists the selected preference to the clockFormat atom', async () => {
const { getByTestId, getByText } = render(<ClockFormatSelector />);
fireEvent.click(getByTestId('clock-format-selector'));
fireEvent.click(getByText('com_nav_clock_format_24h'));
await waitFor(() => {
expect(getByTestId('clock-format-selector')).toHaveTextContent('com_nav_clock_format_24h');
});
expect(JSON.parse(localStorage.getItem('clockFormat') ?? '""')).toBe('24h');
});
});

View file

@ -0,0 +1,33 @@
import { useAtom } from 'jotai';
import { Dropdown } from '@librechat/client';
import type { ClockFormatPreference } from '~/store/clockFormat';
import { clockFormatAtom } from '~/store/clockFormat';
import { useLocalize } from '~/hooks';
export default function ClockFormatSelector() {
const localize = useLocalize();
const [clockFormat, setClockFormat] = useAtom(clockFormatAtom);
const options = [
{ value: 'system', label: localize('com_nav_clock_format_system') },
{ value: '12h', label: localize('com_nav_clock_format_12h') },
{ value: '24h', label: localize('com_nav_clock_format_24h') },
];
const labelId = 'clock-format-selector-label';
return (
<div className="flex w-full items-center justify-between">
<div id={labelId}>{localize('com_nav_clock_format')}</div>
<Dropdown
value={clockFormat}
options={options}
onChange={(value) => setClockFormat(value as ClockFormatPreference)}
testId="clock-format-selector"
sizeClasses="z-50 w-[150px]"
className="z-50"
aria-labelledby={labelId}
/>
</div>
);
}

View file

@ -0,0 +1,36 @@
import { render, fireEvent, waitFor } from '@testing-library/react';
import WeekStartSelector from './WeekStartSelector';
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));
describe('WeekStartSelector', () => {
beforeEach(() => {
localStorage.clear();
global.ResizeObserver = class MockedResizeObserver {
observe = jest.fn();
unobserve = jest.fn();
disconnect = jest.fn();
} as unknown as typeof ResizeObserver;
});
it('renders with System selected by default', () => {
const { getByText, getByTestId } = render(<WeekStartSelector />);
expect(getByText('com_nav_week_start')).toBeInTheDocument();
expect(getByTestId('week-start-selector')).toHaveTextContent('com_nav_week_start_system');
});
it('persists the selected preference to the weekStart atom', async () => {
const { getByTestId, getByText } = render(<WeekStartSelector />);
fireEvent.click(getByTestId('week-start-selector'));
fireEvent.click(getByText('com_nav_week_start_monday'));
await waitFor(() => {
expect(getByTestId('week-start-selector')).toHaveTextContent('com_nav_week_start_monday');
});
expect(JSON.parse(localStorage.getItem('weekStart') ?? '""')).toBe('monday');
});
});

View file

@ -0,0 +1,33 @@
import { useAtom } from 'jotai';
import { Dropdown } from '@librechat/client';
import type { WeekStartPreference } from '~/store/weekStart';
import { weekStartAtom } from '~/store/weekStart';
import { useLocalize } from '~/hooks';
export default function WeekStartSelector() {
const localize = useLocalize();
const [weekStart, setWeekStart] = useAtom(weekStartAtom);
const options = [
{ value: 'system', label: localize('com_nav_week_start_system') },
{ value: 'sunday', label: localize('com_nav_week_start_sunday') },
{ value: 'monday', label: localize('com_nav_week_start_monday') },
];
const labelId = 'week-start-selector-label';
return (
<div className="flex w-full items-center justify-between">
<div id={labelId}>{localize('com_nav_week_start')}</div>
<Dropdown
value={weekStart}
options={options}
onChange={(value) => setWeekStart(value as WeekStartPreference)}
testId="week-start-selector"
sizeClasses="z-50 w-[150px]"
className="z-50"
aria-labelledby={labelId}
/>
</div>
);
}

View file

@ -3,8 +3,8 @@ import { Button } from '@librechat/client';
import { alternateName, getEndpointField } from 'librechat-data-provider';
import type { TEndpointsConfig } from 'librechat-data-provider';
import { formatKeyExpiryLabel } from '~/components/Input/SetKeyDialog/utils';
import { useUserKey, useLocalize, useClockFormat } from '~/hooks';
import { SetKeyDialog } from '~/components/Input/SetKeyDialog';
import { useUserKey, useLocalize } from '~/hooks';
import { icons } from '~/hooks/Endpoint/Icons';
import { getIconKey } from '~/utils';
@ -26,6 +26,7 @@ export default function ProviderKeyRow({ endpoint, endpointsConfig }: ProviderKe
const label = useMemo(() => alternateName[endpoint] || endpoint, [endpoint]);
const expiry = getExpiry();
const hasKey = !!expiry && checkExpiry();
const hour12 = useClockFormat();
const expiryLabel = useMemo(() => {
if (!expiry) {
return localize('com_ui_provider_api_keys_not_set');
@ -33,8 +34,8 @@ export default function ProviderKeyRow({ endpoint, endpointsConfig }: ProviderKe
if (expiry === 'never') {
return localize('com_endpoint_config_key_never_expires');
}
return formatKeyExpiryLabel(localize, expiry);
}, [expiry, localize]);
return formatKeyExpiryLabel(localize, expiry, hour12);
}, [expiry, localize, hour12]);
return (
<>

View file

@ -6,6 +6,7 @@ import ProviderKeyRow from '../ProviderKeyRow';
const mockExpiry = '2026-08-11T12:00:00.000Z';
jest.mock('~/hooks', () => ({
useClockFormat: () => true,
useLocalize: jest.requireActual('~/hooks/useLocalize').default,
useUserKey: () => ({
getExpiry: () => mockExpiry,

View file

@ -16,8 +16,8 @@ import type { TConversation } from 'librechat-data-provider';
import type { MeasuredCellParent } from '~/components/Conversations/Conversations';
import ConversationEndpointIcon from '~/components/Conversations/ConversationEndpointIcon';
import { areConversationListItemFieldsEqual } from '~/components/Conversations/utils';
import { useLocalize, useNavigateToConvo, useClockFormat } from '~/hooks';
import { DateLabel } from '~/components/Conversations/Conversations';
import { useLocalize, useNavigateToConvo } from '~/hooks';
import { cn, groupConversationsByDate } from '~/utils';
import ProjectChatOptions from './ProjectChatOptions';
import { useActiveJobs } from '~/data-provider';
@ -79,10 +79,13 @@ const ConversationRow = memo(
({ conversation, isGenerating }: { conversation: TConversation; isGenerating: boolean }) => {
const { navigateToConvo } = useNavigateToConvo();
const localize = useLocalize();
const hour12 = useClockFormat();
const conversationId = conversation.conversationId ?? '';
const title = conversation.title || localize('com_ui_untitled');
const updatedAt = conversation.updatedAt || conversation.createdAt;
const formattedDate = updatedAt ? new Date(updatedAt).toLocaleString() : '';
const formattedDate = updatedAt
? new Date(updatedAt).toLocaleString(undefined, { hour12 })
: '';
const [isMenuOpen, setIsMenuOpen] = useState(false);
return (

View file

@ -3,7 +3,7 @@ import { formatDistanceToNow } from 'date-fns';
import { TooltipAnchor } from '@librechat/client';
import { Zap, Circle, CheckCircle2 } from 'lucide-react';
import type { TPrompt, TPromptGroup } from 'librechat-data-provider';
import { useLocalize } from '~/hooks';
import { useLocalize, useClockFormat } from '~/hooks';
import { cn } from '~/utils';
const VersionBadge = ({
@ -75,6 +75,7 @@ const VersionCard = ({
isProduction: boolean;
}) => {
const localize = useLocalize();
const hour12 = useClockFormat();
const versionNumber = totalVersions - index;
return (
@ -142,7 +143,7 @@ const VersionCard = ({
<time
className="mt-1 text-xs text-text-secondary"
dateTime={prompt.createdAt}
title={new Date(prompt.createdAt).toLocaleString()}
title={new Date(prompt.createdAt).toLocaleString(undefined, { hour12 })}
>
{formatDistanceToNow(new Date(prompt.createdAt), { addSuffix: true })}
</time>

View file

@ -10,7 +10,7 @@ import {
TooltipAnchor,
} from '@librechat/client';
import type { VersionRecord } from './types';
import { useLocalize } from '~/hooks';
import { useLocalize, useClockFormat } from '~/hooks';
import { cn } from '~/utils';
type VersionItemProps = {
@ -45,6 +45,7 @@ export default function VersionItem({
onRestore,
}: VersionItemProps) {
const localize = useLocalize();
const hour12 = useClockFormat();
const [open, setOpen] = useState(false);
const versionNumber = versionsLength - index;
@ -60,7 +61,7 @@ export default function VersionItem({
: 'com_ui_agent_version_no_date',
);
const relativeLabel = date ? formatDistanceToNow(date, { addSuffix: true }) : fallbackDateLabel;
const absoluteLabel = date ? date.toLocaleString() : relativeLabel;
const absoluteLabel = date ? date.toLocaleString(undefined, { hour12 }) : relativeLabel;
const toolsCount = countItems(version.tools);
const capabilitiesCount = countItems(version.capabilities);

View file

@ -4,6 +4,7 @@ import type { VersionRecord } from '../types';
import VersionItem from '../VersionItem';
jest.mock('~/hooks', () => ({
useClockFormat: () => true,
useLocalize: jest
.fn()
.mockImplementation(() => (key: string, params?: Record<string, unknown>) => {

View file

@ -15,7 +15,7 @@ import type { TUserMemory } from 'librechat-data-provider';
import { getMemoryKeyError, getMemoryValueError, getMemoryApiErrorMessage } from '~/utils/memory';
import { useUpdateMemoryMutation, useMemoriesQuery } from '~/data-provider';
import { getMemoryAddress, getMemoryUpdateAddress } from './address';
import { useLocalize, useHasAccess } from '~/hooks';
import { useLocalize, useHasAccess, useClockFormat } from '~/hooks';
import MemoryUsageBadge from './MemoryUsageBadge';
interface MemoryEditDialogProps {
@ -26,13 +26,14 @@ interface MemoryEditDialogProps {
triggerRef?: React.MutableRefObject<HTMLButtonElement | null>;
}
const formatDateTime = (dateString: string): string => {
const formatDateTime = (dateString: string, hour12?: boolean): string => {
return new Date(dateString).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12,
});
};
@ -46,6 +47,7 @@ export default function MemoryEditDialog({
const localize = useLocalize();
const { showToast } = useToastContext();
const { data: memData } = useMemoriesQuery();
const hour12 = useClockFormat();
const hasUpdateAccess = useHasAccess({
permissionType: PermissionTypes.MEMORIES,
@ -168,7 +170,7 @@ export default function MemoryEditDialog({
{/* Date - Center */}
<span className="text-xs text-text-secondary">
{formatDateTime(memory.updated_at)}
{formatDateTime(memory.updated_at, hour12)}
</span>
{/* Usage badge - Right (memory-specific) */}

View file

@ -22,7 +22,7 @@ import {
useUpdateScheduleMutation,
useRunScheduleNowMutation,
} from '~/data-provider';
import { useLocalize, useHasAccess } from '~/hooks';
import { useLocalize, useHasAccess, useClockFormat, useWeekStart } from '~/hooks';
import { useAgentsMapContext } from '~/Providers';
import { getMessageTimestamp } from '~/utils';
import ScheduleDialog from './ScheduleDialog';
@ -132,18 +132,26 @@ export default function ScheduleCard({ schedule, projectName }: ScheduleCardProp
});
}, [schedule.id, deleteSchedule, showToast, localize]);
const cadenceText = describeCadence(schedule.cadence, localize, i18n.language);
const hour12 = useClockFormat();
const weekStartsOn = useWeekStart();
const cadenceText = describeCadence(
schedule.cadence,
localize,
i18n.language,
hour12,
weekStartsOn,
);
const nextRunText = useMemo(() => {
if (!schedule.enabled || schedule.nextRunAt == null) {
return null;
}
const timestamp = getMessageTimestamp(schedule.nextRunAt, i18n.language);
const timestamp = getMessageTimestamp(schedule.nextRunAt, i18n.language, hour12);
if (!timestamp) {
return null;
}
return localize('com_ui_schedule_next_run', { time: timestamp.relative });
}, [schedule.enabled, schedule.nextRunAt, i18n.language, localize]);
}, [schedule.enabled, schedule.nextRunAt, i18n.language, hour12, localize]);
const dropdownItems = useMemo(
() => [

View file

@ -51,9 +51,10 @@ import {
useCreateScheduleMutation,
useUpdateScheduleMutation,
} from '~/data-provider';
import { useLocalize, useClockFormat, useWeekStart } from '~/hooks';
import { useChatProjectPicker } from './useScheduleProjects';
import { VariableEditor } from '~/components/Variables';
import { useLocalize } from '~/hooks';
import { rotateWeekFrom } from '~/utils/clock';
import { cn } from '~/utils';
interface ScheduleDialogProps {
@ -98,9 +99,6 @@ const DEFAULT_CRON = '0 9 * * 1-5';
* days, so a new weekly schedule opens on the same day an API-created one fires. */
const DEFAULT_WEEKLY_DAYS = [1];
/** Sunday-first, matching the numeric day indices the cadence stores. */
const WEEKDAY_INDEXES = [0, 1, 2, 3, 4, 5, 6];
/** Enough previewed occurrences to show the SHAPE of a cadence (that `0 9,17 * * 1-5`
* fires twice a day), which a single row cannot. Kept small deliberately: the dialog
* turns scrolling off at `md` (see the template className below), so every preview
@ -224,6 +222,13 @@ export default function ScheduleDialog({
const daysOfWeek = watch('daysOfWeek');
const expression = watch('expression');
const timezone = watch('timezone');
/** Not named `hour12`: that is already the form's own 12-hour clock VALUE (1-12).
* This is the preference deciding whether a time is written with a meridiem. */
const prefersMeridiem = useClockFormat();
const weekStartsOn = useWeekStart();
/** The day pills read in the user's own week order. Their VALUES are still the
* Sunday-first indices the cadence stores; only the presentation rotates. */
const weekdayIndexes = useMemo(() => rotateWeekFrom(weekStartsOn), [weekStartsOn]);
const { data: agents } = useListAgentsQuery(
{ requiredPermission: PermissionBits.VIEW },
@ -526,20 +531,21 @@ export default function ScheduleDialog({
* something the user picks rather than the browser's own. Memoized because it
* builds an Intl formatter and the dialog re-renders per keystroke. */
const timezoneOffset = useMemo(() => formatTimezoneOffset(timezone, locale), [timezone, locale]);
const summary = `${describeCadence(previewCadence, localize, locale)} · ${
const summary = `${describeCadence(previewCadence, localize, locale, prefersMeridiem, weekStartsOn)} · ${
timezoneOffset ? `${timezone} (${timezoneOffset})` : timezone
}`;
/** Both labels for every pill, built once per locale: inline they cost fourteen
* `Intl.DateTimeFormat` constructions on every keystroke this form re-renders on. */
/** Both labels for every pill, in week order, built once per locale and week start:
* inline they cost fourteen `Intl.DateTimeFormat` constructions on every keystroke
* this form re-renders on. */
const weekdayOptions = useMemo(
() =>
WEEKDAY_INDEXES.map((day) => ({
weekdayIndexes.map((day) => ({
day,
label: formatScheduleDay(day, locale),
narrow: formatScheduleDayNarrow(day, locale),
})),
[locale],
[locale, weekdayIndexes],
);
/** A CELL in the cadence grid rather than a row of its own, in both modes. The
@ -999,7 +1005,9 @@ export default function ScheduleDialog({
</p>
<ul className="space-y-0.5 text-xs text-text-secondary">
{previewRuns.map((run) => (
<li key={run.getTime()}>{formatRunInstant(run, timezone, locale)}</li>
<li key={run.getTime()}>
{formatRunInstant(run, timezone, locale, prefersMeridiem)}
</li>
))}
</ul>
</div>

View file

@ -7,8 +7,13 @@ import type { TSchedule } from 'librechat-data-provider';
import type { ReactNode } from 'react';
import ScheduleDialog from '../ScheduleDialog';
const mockUseClockFormat = jest.fn(() => true);
const mockUseWeekStart = jest.fn(() => 0);
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
useClockFormat: () => mockUseClockFormat(),
useWeekStart: () => mockUseWeekStart(),
}));
/** `@librechat/client` primitives localize through their own `useLocalize`, so the
@ -116,6 +121,8 @@ describe('ScheduleDialog', () => {
afterEach(() => {
jest.clearAllMocks();
mockLimits = { maxPerUser: 10, minIntervalMinutes: 0, requireProject: false };
mockUseClockFormat.mockReturnValue(true);
mockUseWeekStart.mockReturnValue(0);
mockFetchedProject = undefined;
});
@ -415,6 +422,30 @@ describe('ScheduleDialog', () => {
});
});
describe('clock and week preferences', () => {
it('orders the day pills from the preferred first day of the week', async () => {
const user = userEvent.setup();
mockUseWeekStart.mockReturnValue(1);
renderDialog();
await user.click(screen.getByRole('radio', { name: 'com_ui_schedule_weekly' }));
const pills = screen
.getAllByRole('button')
.filter((button) => button.dataset.testid?.startsWith('schedule-day-'));
// The VALUES stay Sunday-first (the indices the cadence stores); only the
// presentation rotates, so Monday leads and Sunday trails.
expect(pills.map((pill) => pill.getAttribute('aria-label'))).toEqual([
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
]);
});
});
describe('timezone', () => {
it('defaults a new schedule to the browser zone and submits it', async () => {
const user = userEvent.setup();

View file

@ -59,3 +59,47 @@ describe('buildTimezoneOptions', () => {
}
});
});
describe('clock format and week start', () => {
const weekly = (daysOfWeek: number[]): TScheduleCadence => ({
frequency: 'weekly',
hour: 21,
minute: 5,
daysOfWeek,
});
it('forces 24-hour notation regardless of locale when the clock is 24-hour', () => {
expect(
describeCadence({ frequency: 'daily', hour: 21, minute: 5 }, localize, 'en-US', false),
).toContain('"time":"21:05"');
});
it('forces a meridiem regardless of locale when the clock is 12-hour', () => {
expect(
describeCadence({ frequency: 'daily', hour: 21, minute: 5 }, localize, 'de-DE', true),
).toMatch(/9:05\s*PM/i);
});
it('falls back to the locale default when no preference is given', () => {
expect(
describeCadence({ frequency: 'daily', hour: 21, minute: 5 }, localize, 'de-DE'),
).toContain('"time":"21:05"');
});
it('reads a wrap-around day selection in the user own week order', () => {
// Sat(6) + Sun(0) + Mon(1) is a wrap-around selection: ascending-by-index reads
// "Sun, Mon, Sat", but in a Monday-first week the calendar order is Mon, Sat, Sun.
expect(describeCadence(weekly([6, 0, 1]), localize, 'en-US', undefined, 0)).toContain(
'"days":"Sunday, Monday, Saturday"',
);
expect(describeCadence(weekly([6, 0, 1]), localize, 'en-US', undefined, 1)).toContain(
'"days":"Monday, Saturday, Sunday"',
);
});
it('formats a previewed occurrence in the preferred clock format', () => {
const instant = new Date('2026-01-15T21:05:00Z');
expect(formatRunInstant(instant, 'UTC', 'en-US', false)).toContain('21:05');
expect(formatRunInstant(instant, 'UTC', 'en-US', false)).not.toMatch(/PM/i);
});
});

View file

@ -1,5 +1,6 @@
import { isCronCadence } from 'librechat-data-provider';
import type { TScheduleCadence } from 'librechat-data-provider';
import type { WeekStartDay } from '~/utils/clock';
import type { LocalizeFunction } from '~/common';
export type Meridiem = 'AM' | 'PM';
@ -23,8 +24,13 @@ export const to12Hour = (hour: number): { hour12: number; meridiem: Meridiem } =
export const to24Hour = (hour12: number, meridiem: Meridiem): number =>
meridiem === 'PM' ? (hour12 % 12) + 12 : hour12 % 12;
export const formatScheduleTime = (hour: number, minute: number, locale?: string): string =>
new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit' }).format(
export const formatScheduleTime = (
hour: number,
minute: number,
locale?: string,
hour12?: boolean,
): string =>
new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', hour12 }).format(
new Date(2000, 0, 1, hour, minute),
);
@ -46,6 +52,8 @@ export const describeCadence = (
cadence: TScheduleCadence,
localize: LocalizeFunction,
locale?: string,
hour12?: boolean,
weekStartsOn: WeekStartDay = 0,
): string => {
if (isCronCadence(cadence)) {
// Shown verbatim rather than translated into prose. A five-field expression can
@ -60,7 +68,7 @@ export const describeCadence = (
});
}
const time = formatScheduleTime(hour, minute, locale);
const time = formatScheduleTime(hour, minute, locale, hour12);
if (frequency === 'weekdays') {
return localize('com_ui_schedule_runs_weekdays', { time });
}
@ -69,14 +77,25 @@ export const describeCadence = (
// default weekly day — so render it as weekly (not daily) using that same day.
const effectiveDays =
daysOfWeek != null && daysOfWeek.length > 0 ? daysOfWeek : [WEEKLY_DEFAULT_DAY];
const days = effectiveDays.map((day) => formatScheduleDay(day, locale)).join(', ');
// Reads in the user's own week order (e.g. "Fri, Sat, Sun" when the week starts
// Monday), not raw ascending Sunday-first, which would otherwise read a
// wrap-around selection like Sat+Sun+Mon as "Sun, Mon, Sat".
const sortedDays = [...effectiveDays].sort(
(a, b) => ((a - weekStartsOn + 7) % 7) - ((b - weekStartsOn + 7) % 7),
);
const days = sortedDays.map((day) => formatScheduleDay(day, locale)).join(', ');
return localize('com_ui_schedule_runs_weekly', { days, time });
}
return localize('com_ui_schedule_runs_daily', { time });
};
/** One previewed occurrence, in the schedule's own zone. */
export const formatRunInstant = (date: Date, timezone: string, locale?: string): string =>
export const formatRunInstant = (
date: Date,
timezone: string,
locale?: string,
hour12?: boolean,
): string =>
new Intl.DateTimeFormat(locale, {
timeZone: timezone,
weekday: 'short',
@ -84,6 +103,7 @@ export const formatRunInstant = (date: Date, timezone: string, locale?: string):
month: 'short',
hour: 'numeric',
minute: '2-digit',
hour12,
}).format(date);
export const resolveLocalTimezone = (): string =>

View file

@ -0,0 +1,59 @@
import { Provider, useAtom, createStore } from 'jotai';
import { render, screen, act } from '@testing-library/react';
import { clockFormatAtom } from '~/store/clockFormat';
import useClockFormat from '../useClockFormat';
// `mock`-prefixed so jest's hoisted factory below may reference it.
const mockSystemLocale = jest.fn();
jest.mock('~/utils/clock', () => ({
...jest.requireActual('~/utils/clock'),
systemLocale: () => mockSystemLocale(),
}));
function Probe() {
const hour12 = useClockFormat();
const [, setPreference] = useAtom(clockFormatAtom);
return (
<>
<output data-testid="hour12">{String(hour12)}</output>
<button onClick={() => setPreference('24h')}>{'force 24h'}</button>
</>
);
}
/** A fresh Jotai store per render. The module-level default store (and the
* localStorage behind `atomWithStorage`) outlives a test, so a preference one test
* writes leaks into the next and the locale branch under test never runs. */
const renderProbe = () =>
render(
<Provider store={createStore()}>
<Probe />
</Provider>,
);
describe('useClockFormat', () => {
afterEach(() => {
mockSystemLocale.mockReset();
localStorage.clear();
});
it('reacts to the clockFormat atom changing', async () => {
mockSystemLocale.mockReturnValue('en-US');
renderProbe();
// en-US defaults to a meridiem clock under the 'system' preference
expect(screen.getByTestId('hour12')).toHaveTextContent('true');
await act(async () => {
screen.getByRole('button').click();
});
expect(screen.getByTestId('hour12')).toHaveTextContent('false');
});
it('reads the regional runtime locale, not the normalized translation locale', () => {
// `i18n.language` would be 'en' here (en-GB has no bundle of its own) and would
// report a 12-hour clock, which is exactly what this must not do.
mockSystemLocale.mockReturnValue('en-GB');
renderProbe();
expect(screen.getByTestId('hour12')).toHaveTextContent('false');
});
});

View file

@ -0,0 +1,58 @@
import { Provider, useAtom, createStore } from 'jotai';
import { render, screen, act } from '@testing-library/react';
import { weekStartAtom } from '~/store/weekStart';
import useWeekStart from '../useWeekStart';
// `mock`-prefixed so jest's hoisted factory below may reference it.
const mockSystemLocale = jest.fn();
jest.mock('~/utils/clock', () => ({
...jest.requireActual('~/utils/clock'),
systemLocale: () => mockSystemLocale(),
}));
function Probe() {
const weekStartsOn = useWeekStart();
const [, setPreference] = useAtom(weekStartAtom);
return (
<>
<output data-testid="weekStartsOn">{String(weekStartsOn)}</output>
<button onClick={() => setPreference('monday')}>{'force monday'}</button>
</>
);
}
/** A fresh Jotai store per render. The module-level default store (and the
* localStorage behind `atomWithStorage`) outlives a test, so a preference one test
* writes leaks into the next and the locale branch under test never runs. */
const renderProbe = () =>
render(
<Provider store={createStore()}>
<Probe />
</Provider>,
);
describe('useWeekStart', () => {
afterEach(() => {
mockSystemLocale.mockReset();
localStorage.clear();
});
it('reacts to the weekStart atom changing', async () => {
mockSystemLocale.mockReturnValue('en-US');
renderProbe();
// en-US defaults to Sunday-first under the 'system' preference
expect(screen.getByTestId('weekStartsOn')).toHaveTextContent('0');
await act(async () => {
screen.getByRole('button').click();
});
expect(screen.getByTestId('weekStartsOn')).toHaveTextContent('1');
});
it('reads the regional runtime locale, not the normalized translation locale', () => {
// `i18n.language` would be 'en' here and would report Sunday-first; en-GB is not.
mockSystemLocale.mockReturnValue('en-GB');
renderProbe();
expect(screen.getByTestId('weekStartsOn')).toHaveTextContent('1');
});
});

View file

@ -49,3 +49,5 @@ export {
resetCatalogWarmup,
} from './useCatalogWarmup';
export type { CatalogId } from './useCatalogWarmup';
export { default as useClockFormat } from './useClockFormat';
export { default as useWeekStart } from './useWeekStart';

View file

@ -0,0 +1,13 @@
import { useMemo } from 'react';
import { useAtomValue } from 'jotai';
import { resolveHour12, systemLocale } from '~/utils/clock';
import { clockFormatAtom } from '~/store/clockFormat';
/** Resolves the "Clock format" setting to a concrete `hour12` boolean. The
* 'system' branch reads the RUNTIME locale rather than `i18n.language`, which is
* normalized to a translation bundle and no longer carries the region the
* convention depends on (see `systemLocale`). */
export default function useClockFormat(): boolean {
const preference = useAtomValue(clockFormatAtom);
return useMemo(() => resolveHour12(preference, systemLocale()), [preference]);
}

View file

@ -0,0 +1,13 @@
import { useMemo } from 'react';
import { useAtomValue } from 'jotai';
import type { WeekStartDay } from '~/utils/clock';
import { resolveWeekStartsOn, systemLocale } from '~/utils/clock';
import { weekStartAtom } from '~/store/weekStart';
/** Resolves the "Week starts on" setting to a concrete day index (0 = Sunday,
* 1 = Monday). Its 'system' branch reads the RUNTIME locale for the same reason
* `useClockFormat` does: `en-GB` normalizes to `en` and would report Sunday. */
export default function useWeekStart(): WeekStartDay {
const preference = useAtomValue(weekStartAtom);
return useMemo(() => resolveWeekStartsOn(preference, systemLocale()), [preference]);
}

View file

@ -486,6 +486,10 @@
"com_nav_clear_conversation": "Clear conversations",
"com_nav_clear_conversation_confirm_message": "Are you sure you want to clear all conversations? This is irreversible.",
"com_nav_client_image_resize": "Resize images before upload",
"com_nav_clock_format": "Clock Format",
"com_nav_clock_format_12h": "12-hour",
"com_nav_clock_format_24h": "24-hour",
"com_nav_clock_format_system": "System",
"com_nav_collapse_user_messages": "Collapse long user messages",
"com_nav_close_sidebar": "Close sidebar",
"com_nav_confirm_archive_all": "Confirm Archive",
@ -659,6 +663,10 @@
"com_nav_user_msg_markdown": "Render user messages as markdown",
"com_nav_user_name_display": "Display username in messages",
"com_nav_voice_select": "Voice",
"com_nav_week_start": "Week Starts On",
"com_nav_week_start_monday": "Monday",
"com_nav_week_start_sunday": "Sunday",
"com_nav_week_start_system": "System",
"com_shortcut_archive_conversation": "Archive conversation",
"com_shortcut_bookmark_conversation": "Bookmark conversation",
"com_shortcut_continue_response": "Continue response",

View file

@ -0,0 +1,10 @@
import { createStorageAtom } from './jotai-utils';
export type ClockFormatPreference = 'system' | '12h' | '24h';
const DEFAULT_CLOCK_FORMAT: ClockFormatPreference = 'system';
export const clockFormatAtom = createStorageAtom<ClockFormatPreference>(
'clockFormat',
DEFAULT_CLOCK_FORMAT,
);

View file

@ -0,0 +1,10 @@
import { createStorageAtom } from './jotai-utils';
export type WeekStartPreference = 'system' | 'sunday' | 'monday';
const DEFAULT_WEEK_START: WeekStartPreference = 'system';
export const weekStartAtom = createStorageAtom<WeekStartPreference>(
'weekStart',
DEFAULT_WEEK_START,
);

View file

@ -0,0 +1,149 @@
import {
resolveHour12,
localeUsesMeridiem,
resolveWeekStartsOn,
localeWeekStartsOn,
rotateWeekFrom,
} from '../clock';
describe('resolveHour12', () => {
it('forces true for the 12h preference regardless of locale', () => {
expect(resolveHour12('12h', 'de-DE')).toBe(true);
});
it('forces false for the 24h preference regardless of locale', () => {
expect(resolveHour12('24h', 'en-US')).toBe(false);
});
it('defers to the locale for the system preference', () => {
expect(resolveHour12('system', 'en-US')).toBe(true);
expect(resolveHour12('system', 'de-DE')).toBe(false);
});
});
describe('localeUsesMeridiem', () => {
it('does not throw on a garbage locale tag, and returns a boolean', () => {
expect(typeof localeUsesMeridiem('not-a-real-locale')).toBe('boolean');
});
});
describe('resolveWeekStartsOn', () => {
it('forces Sunday (0) for the sunday preference regardless of locale', () => {
expect(resolveWeekStartsOn('sunday', 'fr-FR')).toBe(0);
});
it('forces Monday (1) for the monday preference regardless of locale', () => {
expect(resolveWeekStartsOn('monday', 'en-US')).toBe(1);
});
it('defers to the locale for the system preference', () => {
// en-US: Sunday-first; fr-FR/de-DE/en-GB: Monday-first (CLDR week data)
expect(resolveWeekStartsOn('system', 'en-US')).toBe(0);
expect(resolveWeekStartsOn('system', 'fr-FR')).toBe(1);
expect(resolveWeekStartsOn('system', 'de-DE')).toBe(1);
});
});
describe('localeWeekStartsOn', () => {
it('does not throw on a garbage locale tag, and returns a day index', () => {
expect([0, 1, 2, 3, 4, 5, 6]).toContain(localeWeekStartsOn('not-a-real-locale'));
});
it('reports Saturday for locales whose week starts there, not a folded 0 or 1', () => {
// Only meaningful where the engine ships week data; without it the region
// heuristic below answers instead.
const resolved = new Intl.Locale('ar-EG') as Intl.Locale & {
getWeekInfo?: () => { firstDay: number };
weekInfo?: { firstDay: number };
};
const weekInfo =
typeof resolved.getWeekInfo === 'function' ? resolved.getWeekInfo() : resolved.weekInfo;
if (weekInfo?.firstDay !== 6) {
return;
}
expect(localeWeekStartsOn('ar-EG')).toBe(6);
});
/** Deletes the engine's week data for the duration, so the region heuristic
* is what answers, on every engine rather than only pre-Baseline-2024 ones. */
const withoutEngineWeekData = (run: () => void) => {
const proto = Intl.Locale.prototype as Intl.Locale & {
getWeekInfo?: () => { firstDay: number };
weekInfo?: { firstDay: number };
};
const getWeekInfo = Object.getOwnPropertyDescriptor(proto, 'getWeekInfo');
const weekInfo = Object.getOwnPropertyDescriptor(proto, 'weekInfo');
if (getWeekInfo != null) {
delete proto.getWeekInfo;
}
if (weekInfo != null) {
delete proto.weekInfo;
}
try {
run();
} finally {
if (getWeekInfo != null) {
Object.defineProperty(proto, 'getWeekInfo', getWeekInfo);
}
if (weekInfo != null) {
Object.defineProperty(proto, 'weekInfo', weekInfo);
}
}
};
it('keeps Saturday-first regions on Saturday in the no-week-data fallback', () => {
withoutEngineWeekData(() => {
// CLDR: Egypt and Iran start the week on Saturday; folding them to Sunday
// or Monday left those users no route back, the selector having no
// explicit Saturday option.
expect(localeWeekStartsOn('ar-EG')).toBe(6);
expect(localeWeekStartsOn('fa-IR')).toBe(6);
});
});
it('keeps the Maldives on Friday in the same fallback', () => {
withoutEngineWeekData(() => {
// CLDR's lone Friday-first territory, reachable as dv-MV or bare dv.
expect(localeWeekStartsOn('dv-MV')).toBe(5);
expect(localeWeekStartsOn('dv')).toBe(5);
});
});
it('keeps Sunday-first and Monday-first regions apart in the same fallback', () => {
withoutEngineWeekData(() => {
expect(localeWeekStartsOn('en-US')).toBe(0);
expect(localeWeekStartsOn('he-IL')).toBe(0);
// From the long tail the original hand-picked list missed.
expect(localeWeekStartsOn('en-IN')).toBe(0);
expect(localeWeekStartsOn('th-TH')).toBe(0);
expect(localeWeekStartsOn('fr-FR')).toBe(1);
// CLDR moved the UAE to Monday when its weekend moved to Sat-Sun.
expect(localeWeekStartsOn('ar-AE')).toBe(1);
});
});
it('infers the likely region for a language-only tag instead of defaulting', () => {
// A runtime can report a bare language ('ar', 'en'); maximize() supplies the
// likely region, so those users are not all folded onto Monday.
withoutEngineWeekData(() => {
expect(localeWeekStartsOn('ar')).toBe(6);
expect(localeWeekStartsOn('fa')).toBe(6);
expect(localeWeekStartsOn('en')).toBe(0);
expect(localeWeekStartsOn('fr')).toBe(1);
});
});
});
describe('rotateWeekFrom', () => {
it('is a no-op rotation for Sunday-first (identity)', () => {
expect(rotateWeekFrom(0)).toEqual([0, 1, 2, 3, 4, 5, 6]);
});
it('rotates to start at Monday, wrapping Sunday to the end', () => {
expect(rotateWeekFrom(1)).toEqual([1, 2, 3, 4, 5, 6, 0]);
});
it('rotates to start at Saturday, wrapping Sunday through Friday to the end', () => {
expect(rotateWeekFrom(6)).toEqual([6, 0, 1, 2, 3, 4, 5]);
});
});

258
client/src/utils/clock.ts Normal file
View file

@ -0,0 +1,258 @@
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)];
};

View file

@ -473,6 +473,7 @@ const formatRelativeTime = (from: Date, to: Date, locale?: string): string => {
export const getMessageTimestamp = (
value?: string | null,
locale?: string,
hour12?: boolean,
): MessageTimestamp | null => {
if (!isValidTimestamp(value)) {
return null;
@ -488,6 +489,7 @@ export const getMessageTimestamp = (
absolute: new Intl.DateTimeFormat(safeLocale, {
dateStyle: 'medium',
timeStyle: 'short',
hour12,
}).format(date),
isRecent: Math.abs(now.getTime() - date.getTime()) < RECENT_THRESHOLD_MS,
};

View file

@ -0,0 +1,49 @@
import { render, screen } from '@testing-library/react';
import Dropdown from './Dropdown';
jest.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
const OPTIONS = [
{ value: 'system', label: 'System' },
{ value: '12h', label: '12-hour' },
{ value: '24h', label: '24-hour' },
];
describe('Dropdown accessible name', () => {
it('announces the selected value alongside the field label', () => {
// `aria-labelledby` REPLACES the trigger's child text, and that text is the
// selected option: pointing it at the field label alone announced "Clock Format"
// with no way to hear which format was selected.
render(
<>
<span id="clock-label">{'Clock Format'}</span>
<Dropdown value="24h" options={OPTIONS} aria-labelledby="clock-label" />
</>,
);
expect(screen.getByRole('combobox', { name: 'Clock Format 24-hour' })).toBeInTheDocument();
});
it('does not reference the value span in iconOnly mode, where it never renders', () => {
// Appending the span's id unconditionally left a dangling token in the
// accessible-name computation whenever `iconOnly` dropped the span.
render(
<>
<span id="clock-label">{'Clock Format'}</span>
<Dropdown value="24h" options={OPTIONS} aria-labelledby="clock-label" iconOnly />
</>,
);
const trigger = screen.getByRole('combobox', { name: 'Clock Format' });
expect(trigger.getAttribute('aria-labelledby')).toBe('clock-label');
});
it('falls back to the value alone when no label is supplied', () => {
render(<Dropdown value="12h" options={OPTIONS} ariaLabel="Clock Format" />);
// `ariaLabel` names it outright, so the labelled-by relationship stays off.
expect(screen.getByRole('combobox', { name: 'Clock Format' })).toBeInTheDocument();
});
});

View file

@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react';
import React, { useId, useMemo, useState } from 'react';
import { Search } from 'lucide-react';
import { matchSorter } from 'match-sorter';
import * as Select from '@ariakit/react/select';
@ -67,6 +67,7 @@ const Dropdown: React.FC<DropdownProps> = ({
searchPlaceholder,
searchEmptyText,
}) => {
const valueId = `${useId()}value`;
const [searchValue, setSearchValue] = useState('');
const handleChange = (value: string) => {
@ -158,12 +159,19 @@ const Dropdown: React.FC<DropdownProps> = ({
)}
data-testid={testId}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
// `aria-labelledby` REPLACES the trigger's child text, and that text is the
// selected option: pointing it at the field label alone announced "Clock
// Format" with no way to hear which format is selected. Naming the value span
// alongside the caller's label keeps both. Not in `iconOnly` mode, where the
// span does not render and the id would dangle.
aria-labelledby={
ariaLabelledBy == null || iconOnly ? ariaLabelledBy : `${ariaLabelledBy} ${valueId}`
}
>
<div className={cn('flex items-center gap-2', iconOnly ? 'shrink-0' : 'w-full')}>
{icon}
{!iconOnly && (
<span className="block truncate">
<span id={valueId} className="block truncate">
{label}
{(() => {
const matchedOption = getOptionObject(selectedValue);
@ -182,6 +190,12 @@ const Dropdown: React.FC<DropdownProps> = ({
portalElement={portalElement}
store={selectProps}
className={cn(
// `className` sizes the TRIGGER only (applied above on Select.Select).
// Forwarding it here too meant a caller's trigger height (e.g. `h-10`)
// became the popover's height as well, clipping every option below the
// first out of view. `sizeClasses` is the popover's own sizing prop; the
// shared `.popover-ui` class already caps height to the viewport via
// `--popover-available-height` and scrolls, so nothing else is needed here.
'popover-ui z-40 text-sm',
'[pointer-events:auto]', // Override body's pointer-events:none when in modal
sizeClasses,