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 (
+ <>
+
+
+ >
+ );
+}
+
+/** 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 (
+ <>
+
+
+ >
+ );
+}
+
+/** 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,