From 7834ebab33dd78dd0b72a18128b9aeac0b8212e6 Mon Sep 17 00:00:00 2001 From: Marco Beretta Date: Mon, 24 Aug 2026 00:41:14 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=95=B0=EF=B8=8F=20feat:=20Clock=20Format?= =?UTF-8?q?=20and=20Week=20Start=20Preferences=20(#15121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../Chat/Messages/Content/Markdown.tsx | 2 +- .../Content/__tests__/Markdown.mcpui.test.tsx | 62 +---- .../Chat/Messages/Content/markdownConfig.ts | 87 +++--- .../Chat/Messages/ui/MessageTimestamp.tsx | 9 +- .../Input/SetKeyDialog/SetKeyDialog.tsx | 5 +- .../components/Input/SetKeyDialog/utils.ts | 4 +- .../src/components/Nav/Settings/registry.tsx | 18 ++ .../Balance/AutoRefillSettings.tsx | 9 +- .../General/ClockFormatSelector.spec.tsx | 36 +++ .../General/ClockFormatSelector.tsx | 33 +++ .../General/WeekStartSelector.spec.tsx | 36 +++ .../General/WeekStartSelector.tsx | 33 +++ .../ProviderKeys/ProviderKeyRow.tsx | 7 +- .../__tests__/ProviderKeyRow.spec.tsx | 1 + .../components/Projects/ProjectChatList.tsx | 7 +- .../Prompts/display/PromptVersions.tsx | 5 +- .../SidePanel/Agents/Version/VersionItem.tsx | 5 +- .../Version/__tests__/VersionItem.spec.tsx | 1 + .../SidePanel/Memories/MemoryEditDialog.tsx | 8 +- .../SidePanel/Schedules/ScheduleCard.tsx | 16 +- .../SidePanel/Schedules/ScheduleDialog.tsx | 28 +- .../__tests__/ScheduleDialog.spec.tsx | 31 +++ .../Schedules/__tests__/cadence.spec.ts | 44 +++ .../components/SidePanel/Schedules/cadence.ts | 30 +- .../hooks/__tests__/useClockFormat.spec.tsx | 59 ++++ .../src/hooks/__tests__/useWeekStart.spec.tsx | 58 ++++ client/src/hooks/index.ts | 2 + client/src/hooks/useClockFormat.ts | 13 + client/src/hooks/useWeekStart.ts | 13 + client/src/locales/en/translation.json | 8 + client/src/store/clockFormat.ts | 10 + client/src/store/weekStart.ts | 10 + client/src/utils/__tests__/clock.spec.ts | 149 ++++++++++ client/src/utils/clock.ts | 258 ++++++++++++++++++ client/src/utils/messages.ts | 2 + .../client/src/components/Dropdown.spec.tsx | 49 ++++ packages/client/src/components/Dropdown.tsx | 20 +- 37 files changed, 1020 insertions(+), 148 deletions(-) create mode 100644 client/src/components/Nav/SettingsTabs/General/ClockFormatSelector.spec.tsx create mode 100644 client/src/components/Nav/SettingsTabs/General/ClockFormatSelector.tsx create mode 100644 client/src/components/Nav/SettingsTabs/General/WeekStartSelector.spec.tsx create mode 100644 client/src/components/Nav/SettingsTabs/General/WeekStartSelector.tsx create mode 100644 client/src/hooks/__tests__/useClockFormat.spec.tsx create mode 100644 client/src/hooks/__tests__/useWeekStart.spec.tsx create mode 100644 client/src/hooks/useClockFormat.ts create mode 100644 client/src/hooks/useWeekStart.ts create mode 100644 client/src/store/clockFormat.ts create mode 100644 client/src/store/weekStart.ts create mode 100644 client/src/utils/__tests__/clock.spec.ts create mode 100644 client/src/utils/clock.ts create mode 100644 packages/client/src/components/Dropdown.spec.tsx 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,