diff --git a/client/src/components/Chat/Messages/Content/Markdown.tsx b/client/src/components/Chat/Messages/Content/Markdown.tsx index 11f163d17b..8727638dda 100644 --- a/client/src/components/Chat/Messages/Content/Markdown.tsx +++ b/client/src/components/Chat/Messages/Content/Markdown.tsx @@ -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(store.LaTeXParsing); const isInitializing = content === ''; diff --git a/client/src/components/Chat/Messages/Content/__tests__/Markdown.mcpui.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/Markdown.mcpui.test.tsx index 62ed8118b9..8bb5e0f3f7 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/Markdown.mcpui.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/Markdown.mcpui.test.tsx @@ -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; -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; 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: '
NYC Weather
', }; - 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([ + ['abc123', paris], + ['def456', nyc], + ]); + mockUseConversationUIResources.mockReturnValue(resourceMap as any); const content = [ 'Here are the current weather conditions for both Paris and New York:', diff --git a/client/src/components/Chat/Messages/Content/markdownConfig.ts b/client/src/components/Chat/Messages/Content/markdownConfig.ts index 90ac6a7044..2d2483f52c 100644 --- a/client/src/components/Chat/Messages/Content/markdownConfig.ts +++ b/client/src/components/Chat/Messages/Content/markdownConfig.ts @@ -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, +}); diff --git a/client/src/components/Chat/Messages/ui/MessageTimestamp.tsx b/client/src/components/Chat/Messages/ui/MessageTimestamp.tsx index 54679046e9..e3262ee84f 100644 --- a/client/src/components/Chat/Messages/ui/MessageTimestamp.tsx +++ b/client/src/components/Chat/Messages/ui/MessageTimestamp.tsx @@ -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({ diff --git a/client/src/components/Input/SetKeyDialog/SetKeyDialog.tsx b/client/src/components/Input/SetKeyDialog/SetKeyDialog.tsx index a6e96f95c0..440197dc86 100644 --- a/client/src/components/Input/SetKeyDialog/SetKeyDialog.tsx +++ b/client/src/components/Input/SetKeyDialog/SetKeyDialog.tsx @@ -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 ( diff --git a/client/src/components/Input/SetKeyDialog/utils.ts b/client/src/components/Input/SetKeyDialog/utils.ts index 0e29dadeaa..42777f4f1c 100644 --- a/client/src/components/Input/SetKeyDialog/utils.ts +++ b/client/src/components/Input/SetKeyDialog/utils.ts @@ -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, }); diff --git a/client/src/components/Nav/Settings/registry.tsx b/client/src/components/Nav/Settings/registry.tsx index 081959157d..2a42db8759 100644 --- a/client/src/components/Nav/Settings/registry.tsx +++ b/client/src/components/Nav/Settings/registry.tsx @@ -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', diff --git a/client/src/components/Nav/SettingsTabs/Balance/AutoRefillSettings.tsx b/client/src/components/Nav/SettingsTabs/Balance/AutoRefillSettings.tsx index de615b5da8..d040d15607 100644 --- a/client/src/components/Nav/SettingsTabs/Balance/AutoRefillSettings.tsx +++ b/client/src/components/Nav/SettingsTabs/Balance/AutoRefillSettings.tsx @@ -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 = ({ 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 = ({

{localize('com_nav_balance_auto_refill_settings')}

{localize('com_nav_balance_last_refill')} - {lastRefillDate ? lastRefillDate.toLocaleString() : '-'} + {lastRefillDate ? lastRefillDate.toLocaleString(undefined, { hour12 }) : '-'}
{localize('com_nav_balance_refill_amount')} @@ -85,7 +86,9 @@ const AutoRefillSettings: React.FC = ({
- {refillEligibilityDate ? refillEligibilityDate.toLocaleString() : '-'} + {refillEligibilityDate + ? refillEligibilityDate.toLocaleString(undefined, { hour12 }) + : '-'} diff --git a/client/src/components/Nav/SettingsTabs/General/ClockFormatSelector.spec.tsx b/client/src/components/Nav/SettingsTabs/General/ClockFormatSelector.spec.tsx new file mode 100644 index 0000000000..e4b2174261 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/General/ClockFormatSelector.spec.tsx @@ -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(); + + 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(); + + 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'); + }); +}); diff --git a/client/src/components/Nav/SettingsTabs/General/ClockFormatSelector.tsx b/client/src/components/Nav/SettingsTabs/General/ClockFormatSelector.tsx new file mode 100644 index 0000000000..1e38c46793 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/General/ClockFormatSelector.tsx @@ -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 ( +
+
{localize('com_nav_clock_format')}
+ setClockFormat(value as ClockFormatPreference)} + testId="clock-format-selector" + sizeClasses="z-50 w-[150px]" + className="z-50" + aria-labelledby={labelId} + /> +
+ ); +} diff --git a/client/src/components/Nav/SettingsTabs/General/WeekStartSelector.spec.tsx b/client/src/components/Nav/SettingsTabs/General/WeekStartSelector.spec.tsx new file mode 100644 index 0000000000..cf45fb764f --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/General/WeekStartSelector.spec.tsx @@ -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(); + + 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(); + + 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'); + }); +}); diff --git a/client/src/components/Nav/SettingsTabs/General/WeekStartSelector.tsx b/client/src/components/Nav/SettingsTabs/General/WeekStartSelector.tsx new file mode 100644 index 0000000000..5596464eea --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/General/WeekStartSelector.tsx @@ -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 ( +
+
{localize('com_nav_week_start')}
+ setWeekStart(value as WeekStartPreference)} + testId="week-start-selector" + sizeClasses="z-50 w-[150px]" + className="z-50" + aria-labelledby={labelId} + /> +
+ ); +} diff --git a/client/src/components/Nav/SettingsTabs/ProviderKeys/ProviderKeyRow.tsx b/client/src/components/Nav/SettingsTabs/ProviderKeys/ProviderKeyRow.tsx index 10b2f665f3..5382736471 100644 --- a/client/src/components/Nav/SettingsTabs/ProviderKeys/ProviderKeyRow.tsx +++ b/client/src/components/Nav/SettingsTabs/ProviderKeys/ProviderKeyRow.tsx @@ -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 ( <> diff --git a/client/src/components/Nav/SettingsTabs/ProviderKeys/__tests__/ProviderKeyRow.spec.tsx b/client/src/components/Nav/SettingsTabs/ProviderKeys/__tests__/ProviderKeyRow.spec.tsx index 87ada52e46..2ff7537795 100644 --- a/client/src/components/Nav/SettingsTabs/ProviderKeys/__tests__/ProviderKeyRow.spec.tsx +++ b/client/src/components/Nav/SettingsTabs/ProviderKeys/__tests__/ProviderKeyRow.spec.tsx @@ -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, diff --git a/client/src/components/Projects/ProjectChatList.tsx b/client/src/components/Projects/ProjectChatList.tsx index 2957af2337..0b850fda91 100644 --- a/client/src/components/Projects/ProjectChatList.tsx +++ b/client/src/components/Projects/ProjectChatList.tsx @@ -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 ( diff --git a/client/src/components/Prompts/display/PromptVersions.tsx b/client/src/components/Prompts/display/PromptVersions.tsx index 01513d629a..63eadc6f73 100644 --- a/client/src/components/Prompts/display/PromptVersions.tsx +++ b/client/src/components/Prompts/display/PromptVersions.tsx @@ -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 = ({ diff --git a/client/src/components/SidePanel/Agents/Version/VersionItem.tsx b/client/src/components/SidePanel/Agents/Version/VersionItem.tsx index 7590688201..8756b60474 100644 --- a/client/src/components/SidePanel/Agents/Version/VersionItem.tsx +++ b/client/src/components/SidePanel/Agents/Version/VersionItem.tsx @@ -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); diff --git a/client/src/components/SidePanel/Agents/Version/__tests__/VersionItem.spec.tsx b/client/src/components/SidePanel/Agents/Version/__tests__/VersionItem.spec.tsx index a32ce5d9f2..c67542415b 100644 --- a/client/src/components/SidePanel/Agents/Version/__tests__/VersionItem.spec.tsx +++ b/client/src/components/SidePanel/Agents/Version/__tests__/VersionItem.spec.tsx @@ -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) => { diff --git a/client/src/components/SidePanel/Memories/MemoryEditDialog.tsx b/client/src/components/SidePanel/Memories/MemoryEditDialog.tsx index a05f8fa75d..5e729324fc 100644 --- a/client/src/components/SidePanel/Memories/MemoryEditDialog.tsx +++ b/client/src/components/SidePanel/Memories/MemoryEditDialog.tsx @@ -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; } -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 */} - {formatDateTime(memory.updated_at)} + {formatDateTime(memory.updated_at, hour12)} {/* Usage badge - Right (memory-specific) */} diff --git a/client/src/components/SidePanel/Schedules/ScheduleCard.tsx b/client/src/components/SidePanel/Schedules/ScheduleCard.tsx index 06884f0cc2..a0dfe0b56f 100644 --- a/client/src/components/SidePanel/Schedules/ScheduleCard.tsx +++ b/client/src/components/SidePanel/Schedules/ScheduleCard.tsx @@ -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( () => [ diff --git a/client/src/components/SidePanel/Schedules/ScheduleDialog.tsx b/client/src/components/SidePanel/Schedules/ScheduleDialog.tsx index 7535e19630..ac8d01d0f4 100644 --- a/client/src/components/SidePanel/Schedules/ScheduleDialog.tsx +++ b/client/src/components/SidePanel/Schedules/ScheduleDialog.tsx @@ -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({

    {previewRuns.map((run) => ( -
  • {formatRunInstant(run, timezone, locale)}
  • +
  • + {formatRunInstant(run, timezone, locale, prefersMeridiem)} +
  • ))}
diff --git a/client/src/components/SidePanel/Schedules/__tests__/ScheduleDialog.spec.tsx b/client/src/components/SidePanel/Schedules/__tests__/ScheduleDialog.spec.tsx index b1de7a5b8c..0aebcca94d 100644 --- a/client/src/components/SidePanel/Schedules/__tests__/ScheduleDialog.spec.tsx +++ b/client/src/components/SidePanel/Schedules/__tests__/ScheduleDialog.spec.tsx @@ -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(); diff --git a/client/src/components/SidePanel/Schedules/__tests__/cadence.spec.ts b/client/src/components/SidePanel/Schedules/__tests__/cadence.spec.ts index c083842bec..5f36c8a2de 100644 --- a/client/src/components/SidePanel/Schedules/__tests__/cadence.spec.ts +++ b/client/src/components/SidePanel/Schedules/__tests__/cadence.spec.ts @@ -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); + }); +}); diff --git a/client/src/components/SidePanel/Schedules/cadence.ts b/client/src/components/SidePanel/Schedules/cadence.ts index 2ec8daa65d..61a3f18bdc 100644 --- a/client/src/components/SidePanel/Schedules/cadence.ts +++ b/client/src/components/SidePanel/Schedules/cadence.ts @@ -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 => diff --git a/client/src/hooks/__tests__/useClockFormat.spec.tsx b/client/src/hooks/__tests__/useClockFormat.spec.tsx new file mode 100644 index 0000000000..95708366eb --- /dev/null +++ b/client/src/hooks/__tests__/useClockFormat.spec.tsx @@ -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 ( + <> + {String(hour12)} + + + ); +} + +/** 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( + + + , + ); + +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'); + }); +}); diff --git a/client/src/hooks/__tests__/useWeekStart.spec.tsx b/client/src/hooks/__tests__/useWeekStart.spec.tsx new file mode 100644 index 0000000000..d7bba9764d --- /dev/null +++ b/client/src/hooks/__tests__/useWeekStart.spec.tsx @@ -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 ( + <> + {String(weekStartsOn)} + + + ); +} + +/** 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( + + + , + ); + +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'); + }); +}); diff --git a/client/src/hooks/index.ts b/client/src/hooks/index.ts index fe7868ebd3..73450a4bff 100644 --- a/client/src/hooks/index.ts +++ b/client/src/hooks/index.ts @@ -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'; diff --git a/client/src/hooks/useClockFormat.ts b/client/src/hooks/useClockFormat.ts new file mode 100644 index 0000000000..412de9e0c9 --- /dev/null +++ b/client/src/hooks/useClockFormat.ts @@ -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]); +} diff --git a/client/src/hooks/useWeekStart.ts b/client/src/hooks/useWeekStart.ts new file mode 100644 index 0000000000..e8429d217d --- /dev/null +++ b/client/src/hooks/useWeekStart.ts @@ -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]); +} diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index cba5915f70..13bb8b01c9 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -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", diff --git a/client/src/store/clockFormat.ts b/client/src/store/clockFormat.ts new file mode 100644 index 0000000000..2f447ec379 --- /dev/null +++ b/client/src/store/clockFormat.ts @@ -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( + 'clockFormat', + DEFAULT_CLOCK_FORMAT, +); diff --git a/client/src/store/weekStart.ts b/client/src/store/weekStart.ts new file mode 100644 index 0000000000..7f884fe456 --- /dev/null +++ b/client/src/store/weekStart.ts @@ -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( + 'weekStart', + DEFAULT_WEEK_START, +); diff --git a/client/src/utils/__tests__/clock.spec.ts b/client/src/utils/__tests__/clock.spec.ts new file mode 100644 index 0000000000..e189bfe475 --- /dev/null +++ b/client/src/utils/__tests__/clock.spec.ts @@ -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]); + }); +}); diff --git a/client/src/utils/clock.ts b/client/src/utils/clock.ts new file mode 100644 index 0000000000..10175d4411 --- /dev/null +++ b/client/src/utils/clock.ts @@ -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(); + +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([ + ...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)]; +}; diff --git a/client/src/utils/messages.ts b/client/src/utils/messages.ts index 256e990838..aaa4444b18 100644 --- a/client/src/utils/messages.ts +++ b/client/src/utils/messages.ts @@ -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, }; diff --git a/packages/client/src/components/Dropdown.spec.tsx b/packages/client/src/components/Dropdown.spec.tsx new file mode 100644 index 0000000000..e9e0b82e45 --- /dev/null +++ b/packages/client/src/components/Dropdown.spec.tsx @@ -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( + <> + {'Clock Format'} + + , + ); + + 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( + <> + {'Clock Format'} + + , + ); + + 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(); + + // `ariaLabel` names it outright, so the labelled-by relationship stays off. + expect(screen.getByRole('combobox', { name: 'Clock Format' })).toBeInTheDocument(); + }); +}); diff --git a/packages/client/src/components/Dropdown.tsx b/packages/client/src/components/Dropdown.tsx index c4ba9a1ae9..ea1b3d3f19 100644 --- a/packages/client/src/components/Dropdown.tsx +++ b/packages/client/src/components/Dropdown.tsx @@ -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 = ({ searchPlaceholder, searchEmptyText, }) => { + const valueId = `${useId()}value`; const [searchValue, setSearchValue] = useState(''); const handleChange = (value: string) => { @@ -158,12 +159,19 @@ const Dropdown: React.FC = ({ )} 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}` + } >
{icon} {!iconOnly && ( - + {label} {(() => { const matchedOption = getOptionObject(selectedValue); @@ -182,6 +190,12 @@ const Dropdown: React.FC = ({ 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,