Skip to content

[scheduler] Format the dates with Intl and number the weekdays like ISO 8601 - #24021

Merged
flaviendelangle merged 9 commits into
mui:masterfrom
flaviendelangle:scheduler-intl-date-locale
Oct 28, 2026
Merged

flaviendelangle merged 9 commits into
mui:masterfrom
flaviendelangle:scheduler-intl-date-locale

Conversation

@flaviendelangle

@flaviendelangle flaviendelangle commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

This PR moves the date formatting of the Scheduler from the date adapter to Intl.DateTimeFormat, and the week start to ISO weekdays.

import { EventCalendar } from '@mui/x-scheduler/event-calendar';

<EventCalendar dateLocale="fr-FR" />;

Changes

  • dateLocale is a locale string. The prop takes the locales of Intl.DateTimeFormat, such as "fr-FR", instead of a date-fns locale object, and defaults to "en-US". So does createDateLocaleTheme(). The store keeps the locale in its state, and the adapter no longer carries one.
  • The labels are formatted with Intl.DateTimeFormat. One function per format, such as formatMonthShort(locale, plainDate), formatFullDate(locale, plainDate) or formatTime(locale, minutesInDay, ampm), replaces adapter.format(), adapter.formatByString() and adapter.formats in every view, the header toolbar, the mini calendar, the event dialog, the accessible names of the events and the headers of the Timeline. Each function has its own options, so a bundle keeps only the formats it uses, and its Intl.DateTimeFormat is created once per locale. They format the plain date of a day, or the wall-clock time of a processed date, in UTC and on the Gregorian calendar, so the system timezone and the default calendar of the locale can't change a label. The week labels of the Timeline use formatRange(), so the locale writes the separator and the parts the two dates share once. The day of the month drops the literal some locales add, like the 13. of Danish or the 13日 of Japanese, and the accessible names of the week view columns and of the agenda days write the weekday, the month and the day in the order of the locale. The week numbers use the digits of the locale, like the dates.
  • ampm follows the locale. Without the preference, the times use the hour cycle of the dateLocale, from Intl.DateTimeFormat#resolvedOptions(): 12-hour in en-US, 24-hour in fr-FR or en-GB. The 12-hour times follow the cycle of the locale (午前0:30 in Japanese), and the 24-hour times pad the hour in every locale, so the time axis keeps one label width.
  • dateLocale is checked in development. An invalid tag, like "en_US", or a locale Intl doesn't support logs a warning that names the prop. Each locale is checked once.
  • weekStartsOn uses ISO weekdays. The preference goes from 1 (Monday) to 7 (Sunday). Without it, the weeks start on the first day of the week of Intl.Locale.prototype.getWeekInfo(), or of the weekInfo accessor of older browsers, and in the browsers that have neither, like Firefox, on the first day of the fw extension, of the rg region, of the ISO 8601 calendar (Monday) or of the region of the locale, in that order, like getWeekInfo(), from a table of the regions of CLDR that don't start on Monday. schedulerPreferenceSelectors.weekInfo resolves it with the first week of the year, so the helpers that take a week start no longer take the adapter or an undefined week start.
  • The week numbers follow the first week of the locale. getWeekNumber(plainDate, weekInfo) counts the first week of a year as the first one with minimalDays days in that year. Browsers dropped the minimalDays of getWeekInfo(), so they come from the regions of CLDR whose first week has 4 days, with the rg region override and the ca-iso8601 calendar of the locale. The other regions count the week of January 1st. With weeks starting on Monday and 4 minimal days, it's the ISO 8601 week number.
  • The weekends stay on Saturday and Sunday. The TODO of [scheduler] Read the weekend days from the locale #23985 now points to the weekend of getWeekInfo().
  • Leftovers. createLabelDate(), formatWallClockHour() and formatHourAndMinutes() go: plain dates and wall-clock times are formatted directly, and formatTime() caches the time labels. The text levels of the Timeline header take the format function of their label. The preferences menu checks the week start in use, including the one of the locale.
  • CalendarGrid.HeaderCell takes a day. The internal component takes a day with its plain date and labels it with its weekday unless aria-label is set, instead of an ariaLabelFormat of the adapter.

No file of the BaseTemporal copy changes.

Changelog

Breaking changes

  • The dateLocale prop and createDateLocaleTheme() take a locale string, like the locales of Intl.DateTimeFormat, instead of a date-fns locale object:

    -import { fr } from 'date-fns/locale/fr';
    -
    -<EventCalendar dateLocale={fr} />
    -createDateLocaleTheme(fr)
    +<EventCalendar dateLocale="fr-FR" />
    +createDateLocaleTheme('fr-FR')
  • The weekStartsOn preference uses ISO weekdays, from 1 (Monday) to 7 (Sunday). Sunday is 7 instead of 0:

    -<EventCalendar defaultPreferences={{ weekStartsOn: 0 }} />
    +<EventCalendar defaultPreferences={{ weekStartsOn: 7 }} />

    Migrate the preferences you store, for example from onPreferencesChange: in development, weekStartsOn: 0 throws error 278.

  • Without weekStartsOn, the weeks start on the first day of the week of the locale in Intl.Locale.prototype.getWeekInfo(). Browsers that don't support it, like Firefox, use the first day of the week of the region of the locale.

  • Without ampm, the times use the hour cycle of the locale instead of a 12-hour clock: dateLocale="fr-FR" shows "13:30" instead of "1:30 PM". The default value of defaultPreferences no longer includes ampm: true.

  • The week numbers follow the first week of the year of the locale, also when weekStartsOn is set. They used the ISO 8601 rule then: with dateLocale="en-US" and weekStartsOn: 1, Friday, January 1st, 2027 is in week 1 instead of week 53.

  • The dates use the formats of Intl.DateTimeFormat for the locale, on the Gregorian calendar. For example, in English: "Thursday, July 3, 2025" instead of "Thursday, July 3rd, 2025", "Ends Dec 31" instead of "Ends 31 Dec", "Annually on December 13" instead of "Annually on 13 December", "July 2025" instead of "July, 2025" in the agenda, "07:30" instead of "7:30" in 24-hour format, "Sat, Dec 24 – Fri, Dec 30" instead of "Sat, Dec 24 - Fri, Dec 30" in the week row of the Timeline (an en dash between thin spaces), and "Sunday, May 4" instead of "Sunday 4" for the accessible names of the week view columns and of the agenda days. The days of the month and the week numbers use the digits of the locale, like ١٣ in Arabic (Egypt).

Behavior changes

The breaking changes above, and:

  • The header toolbar and the side panel read the month of the visible date in the display timezone, like the views, instead of in the timezone of the visibleDate value.
  • The preferences menu checks the first day of the week and the time format of the locale when weekStartsOn or ampm isn't set. It checked no week start before.

Adapter usage

Counted like in #23943. The references go from 101 to 43. format, formatByString, formats, getCurrentLocaleCode, getDayOfWeek and getWeekNumber are gone, and so is the isWeekend of a comment, so the Scheduler uses 8 methods and properties instead of 15.

Method or property master This PR Difference
format 23 0 −23
formats 17 0 −17
formatByString 14 0 −14
getCurrentLocaleCode 1 0 −1
getDayOfWeek 1 0 −1
getWeekNumber 1 0 −1
isWeekend 1 0 −1

The ones left read and build the adapter values of the public API: getTime, setTimezone, getTimezone, addMilliseconds, date, isValid, toJsDate and isSameDay.

Bundle size

Measured from source with esbuild, minified, with third-party packages external and without the error minifier, gzip level 9. The formats and the tables of the weeks of the regions are new code; the adapter formatting they replace lives in date-fns, outside the bundles.

Entry master This PR Difference
event-calendar 75.53 kB 76.22 kB +687 B
event-calendar-premium 90.92 kB 91.62 kB +696 B
event-timeline-premium 68.06 kB 68.77 kB +708 B

These entries render most of the formats. A bundle that imports only formatMonthShort keeps only its options: the Event Calendar has neither the range nor the short month of the Timeline.

Validation

  • pnpm test:unit --project "x-scheduler*" --run and pnpm test:browser --project "x-scheduler*" --run pass. A few tests time out under the load of the full jsdom run and pass on their own.

  • New tests:

    • The week info of the locales: the first day of the week, the region of the language, the fw, rg and ca-iso8601 extensions, and the fallback without getWeekInfo().
    • The formats: the locale, a date that a timezone skips, the Gregorian calendar of a locale with another one, the ranges of dates, and the 12-hour and 24-hour times.
    • The hour cycle of the locales, and the ampm selectors without the preference, in English and in French.
    • The week numbers of January 1st, 2027 in the header toolbar, in English and in French, and with weeks starting on another day than the one of the locale.
    • The defaults of the preferences menu, the weeks of the locale in the selectors and in the week view, the month of the header toolbar in the display timezone, the month and year of the agenda, the yearly recurrence label, and the digits of Arabic (Egypt).
    • The check of dateLocale.
  • The tests of getWeekNumber(), getPlainDateWeekStart(), the recurrence presets and the header walk use ISO weekdays and minimal days. The tests that switched the locale of the adapter go, as the adapter has none.

  • The expected labels follow Intl.DateTimeFormat: no ordinals in the full dates, "Dec 31", "07:30".

  • pnpm proptypes, pnpm docs:api, pnpm docs:typescript:formatted and pnpm extract-error-codes were run. The message of error 278 says that weekStartsOn goes from 1 (Monday) to 7 (Sunday); its code doesn't change.

  • A /simplify pass with four reviews (reuse, simplification, efficiency, altitude):

    • Applied: formatTime() takes minutes and caches its labels per locale, like the hour labels before; one weekInfo selector feeds getWeekNumber(); the text levels of the Timeline header name a format; formatProcessedDate() and formatWeekdayAndDayOfMonth() replace copies; the agenda formats its month and year together.
    • Skipped: storing the plain date on SchedulerProcessedDate, which changes a public type before the internal migration ends; making the adapter a module constant everywhere, which belongs with the removal of the adapter; formatting the week ranges of the Timeline with formatRange() and reading ampm from the locale, which change behavior and were applied afterwards.
  • Three review rounds with Codex and the base-ui-review skill:

    • Round 1 found that a rg region override, like en-US-u-rg-gbzzzz, started the weeks on Monday but kept the first week of the United States. The region now comes from the extension.
    • Round 2 found the same for the ca-iso8601 calendar extension, which now uses the ISO 8601 first week.
    • Round 3 approved without findings.
  • A review of the PR found labels that Intl writes differently from what the views assume, and the Monday fallback of Firefox. The fixes are in "Apply the review of the Intl formatting": the day of the month without its literal, the hour cycle and the padding of the times, the order of the weekday labels, the first day of the region without getWeekInfo(), the check of dateLocale, the digits of the week numbers, the formatters created per format, and the tests and docs it listed. Not applied: reading weekStartsOn: 0 as Sunday, which would keep the old value working (the changelog says to migrate it instead); the digits of the counts of the locale texts, like hiddenEvents(count), which take numbers in the public locale text types.

  • The follow-up review found that the fallback without getWeekInfo() kept the first day of the region for the iso8601 calendar. It now follows the order of ICU, which en-US-u-ca-iso8601, …-fw-sun and …-rg-egzzzz test. checkDateLocale() also checks each locale once.

  • TypeScript, ESLint and Prettier pass.

  • I have followed (at least) the PR section of the contributing guide.

🤖 Generated with Claude Code

@flaviendelangle flaviendelangle added breaking change Introduces changes that are not backward compatible. scope: scheduler Changes related to the scheduler. type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature. labels Oct 28, 2026
@code-infra-dashboard

code-infra-dashboard Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Deploy preview

Bundle size

Bundle Parsed size Gzip size
@mui/x-data-grid 0B(0.00%) 0B(0.00%)
@mui/x-data-grid-pro 0B(0.00%) 0B(0.00%)
@mui/x-data-grid-premium 0B(0.00%) 0B(0.00%)
@mui/x-charts 0B(0.00%) 0B(0.00%)
@mui/x-charts-pro 0B(0.00%) 0B(0.00%)
@mui/x-charts-premium 0B(0.00%) 0B(0.00%)
@mui/x-date-pickers 0B(0.00%) 0B(0.00%)
@mui/x-date-pickers-pro 0B(0.00%) 0B(0.00%)
@mui/x-tree-view 0B(0.00%) 0B(0.00%)
@mui/x-tree-view-pro 0B(0.00%) 0B(0.00%)
@mui/x-scheduler 🔺+931B(+0.22%) 🔺+1.17KB(+0.99%)
@mui/x-scheduler-premium 🔺+469B(+0.08%) 🔺+577B(+0.35%)
@mui/x-chat 0B(0.00%) 0B(0.00%)
@mui/x-license 0B(0.00%) 0B(0.00%)

Details of bundle changes


Check out the code infra dashboard for more information about this PR.

flaviendelangle and others added 7 commits October 28, 2026 10:59
…SO 8601

- `dateLocale` takes a locale string, like `Intl.DateTimeFormat`, instead of a `date-fns` locale object, and so does `createDateLocaleTheme()`.
- The labels are formatted with `Intl.DateTimeFormat` through `formatPlainDate()` and `formatTime()`, instead of the formats of the adapter.
- `weekStartsOn` goes from 1 (Monday) to 7 (Sunday), and defaults to the first day of the week of `Intl.Locale.prototype.getWeekInfo()`.
- The week numbers follow the first week of the year of the region of the locale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `formatTime()` takes minutes since midnight and caches its labels per locale, like the hour labels before.
- One `weekInfo` selector gives `getWeekNumber()` the first day of the week and the minimal days of the first week.
- The text levels of the Timeline header name a format instead of a function.
- `formatProcessedDate()` formats the date of a processed date with any format, and `formatWeekdayAndDayOfMonth()` builds the labels of the day headers.
- The agenda formats its month and year together, in the order of the locale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e locale

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the locale

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…anges with Intl

- `ampm` defaults to the hour cycle of the `dateLocale`, read from `Intl.DateTimeFormat`, instead of `true`.
- The week labels of the Timeline use `Intl.DateTimeFormat#formatRange()` instead of a hard-coded separator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`formatPlainDate(locale, plainDate, format)` read its options from a table of every format, so each bundle kept all of them. Each format is now its own function, such as `formatMonthShort(locale, plainDate)`, with its own options, and the bundles keep only the ones they call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The formatters take a `Date` rather than its timestamp: the `Intl` of a fake clock, like the one of the screenshot tests, formats the current date instead of a timestamp of `0`, so midnight showed the current time.
- Format the preset selector of the Timeline with Prettier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@rita-codes rita-codes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR review

Nothing merge-blocking. The week-number math was brute-forced against date-fns getWeek() for every date from 1995 to 2035, every first day and every minimal-days value (733,775 cases, no mismatch), the region table of the first week matches the CLDR data of Node's ICU for all 676 two-letter regions, every PlainDate handed to a formatter was traced to the right timezone, and the full scheduler jsdom suite (4,009 tests), TypeScript, ESLint and Prettier pass. The Intl path is also 2–3× cheaper per label than the adapter it replaces, with no change in the touched benches. What remains is a handful of labels that Intl writes differently from the bare numbers and times the views assume, in Danish, Czech, Norwegian, Japanese, Chinese and Korean mostly, plus the Firefox default and a few untested behavior changes. All verified by running Intl in Node 22 (ICU 78) and the pinned Chromium.

Bugs (7)

1. 🟡 formatDayOfMonth() keeps the day suffix of the locale, so the grids read 13. or 13日

Location: packages/x-scheduler-internals/src/internals/utils/date-locale.ts:155

const DAY_OF_MONTH: Intl.DateTimeFormatOptions = { day: 'numeric' };

{ day: 'numeric' } renders the CLDR pattern of a standalone day, which is a bare number in English, French, German or Arabic but carries a literal in several locales. The adapter's dayOfMonth (d) was a bare number everywhere. Every month cell, mini-calendar button, week-view header number, agenda day number and Timeline dayAndMonth day cell goes through this function.

locale formatDayOfMonth formatToParts(...).day
cs-CZ, da-DK, nb-NO, sk-SK 13. 13
ja-JP, zh-CN 13日 13
ko-KR 13일 13
ar-EG ١٣ ١٣

Failure scenario: <EventCalendar dateLocale="da-DK" /> shows 1. 2. … 31. in every month cell and mini-calendar button, and 13. under søn. in the week header; with ja-JP the mini calendar reads 1日 … 31日.

Fix: Keep the digits of the locale and drop the literal: getFormatter(locale, DAY_OF_MONTH).formatToParts(date).find((part) => part.type === 'day')!.value. (formatYear() has the same shape — 2025年, 2025년, 2025. in hu-HU — but a standalone year with its suffix is arguably right in the Timeline year row.)

2. 🟡 The time options pin the hour cycle and the hour width, which Intl resolves per locale

Location: packages/x-scheduler-internals/src/internals/utils/date-locale.ts:250

const TIME_12H: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: '2-digit', hourCycle: 'h12' };
const TIME_24H: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: '2-digit', hourCycle: 'h23' };

Two issues in the same two lines:

  • hourCycle: 'h12' overrides the locales whose 12-hour clock is h11. Japanese writes the half hour after midnight 午前0:30; with h12 it becomes 午前12:30. hour12: true lets CLDR pick h11 or h12 and gives the same output as today in every other probed locale (en, ko, zh, ar, hi, fr, es-MX, en-GB, de).
  • hour: 'numeric' with h23 pads the hour in some locales and not in others: 07:30 in en-US, hi-IN, ko-KR and ar-EG (٠٧:٣٠), 7:30 in fr-FR, de-DE, es-ES, it-IT, nl-NL, pt-BR, ja-JP, zh-CN, ru-RU, pl-PL, sv-SE, en-GB. master used the adapter's hours24h (H), so it showed 7:30 everywhere; the PR body's "07:30 instead of 7:30 in 24-hour format" only holds for the first group. hour: '2-digit' gives 07:30 in all of them, which is also the 24-hour convention of fr/de/es, and the time axis keeps one label width.

Failure scenario: dateLocale="ja-JP" with the 12-hour preference labels 00:30 as 午前12:30; dateLocale="fr-FR" shows a time axis of 7:00, 8:00, … 13:00 while an en-US one shows 07:00.

Fix: TIME_12H = { hour: 'numeric', minute: '2-digit', hour12: true } and TIME_24H = { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }; update date-locale.test.ts:147 ('0:00' → '00:00'), StandaloneWeekView.test.tsx:116 (7:30 à 8:30 → 07:30 à 08:30) and the changelog bullet.

3. 🟡 formatWeekdayAndDayOfMonth() concatenates the two parts in English order

Location: packages/x-scheduler/src/internals/utils/date-utils.ts:13

export function formatWeekdayAndDayOfMonth(locale: string, plainDate: PlainDate): string {
  return `${formatWeekday(locale, plainDate)} ${formatDayOfMonth(locale, plainDate)}`;
}

It builds the aria-label of the agenda day headers (AgendaView.tsx:287) and of the week-view column headers (DayTimeGrid.tsx:484). It's the last hand-assembled label of the PR, and its order and punctuation are wrong outside Western European locales:

locale concatenation { weekday: 'long', month: 'long', day: 'numeric' }
ja-JP 日曜日 13日 7月13日日曜日
zh-CN 星期日 13日 7月13日星期日
ko-KR 일요일 13일 7월 13일 일요일
de-DE Sonntag 13 Sonntag, 13. Juli
he-IL יום ראשון 13 יום ראשון, 13 ביולי
ar-EG الأحد ١٣ الأحد، ١٣ يوليو

Failure scenario: A Japanese screen-reader user hears the weekday before the day number, the reverse of how the date is spoken, on every column of the week view.

Fix: One Intl options object in date-locale.ts, { weekday: 'long', month: 'long', day: 'numeric' } (or reuse formatFullDate() as the mini calendar does for its day cells), and delete the helper. Tests pinning the old label: EventCalendar.test.tsx:227 ('Friday 30') and WeekView.test.tsx:277 ('Sunday 4').

4. 🟡 The Monday fallback of getLocaleWeekInfo() is what every supported Firefox gets

Location: packages/x-scheduler-internals/src/internals/utils/date-locale.ts:59

// Like the ISO 8601 weeks, for the browsers that know neither.
firstDay: (intlWeekInfo?.firstDay ?? 1) as WeekStartsOn,

Intl.Locale.prototype.getWeekInfo() landed in Firefox 153 (July 2026) and Firefox never shipped the weekInfo accessor. The repo's .browserslistrc supports Firefox 121–148, so in every supported Firefox the default en-US calendar starts its weeks on Monday, while Chrome, Safari and Node start them on Sunday. With SSR, Node renders a Sunday-first grid and the Firefox client hydrates a Monday-first one: the whole month grid mismatches, not just a text node. The docs sentence "the browsers that don't support it start the weeks on Monday" describes it, but it's the default locale in a supported browser.

Failure scenario: A US user on Firefox 148 opens the Event Calendar with no preferences and gets Monday-first weeks and no checked "Start week on" radio; the same page in Chrome starts on Sunday.

Fix: The PR already ships a CLDR region table for minimalDays; the first day needs one of the same size when neither API exists: 56 regions start on Sunday (AG AS BD BR BS BT BW BZ CA CO DM DO ET GT GU HK HN ID IL IN IS JM JP KE KH KR LA MH MM MO MT MX MZ NI NP PA PE PH PK PR PT PY SA SG SV TH TT TW UM US VE VI WS YE ZA ZW), 14 on Saturday (AF BH DJ DZ EG IQ IR JO KW LY OM QA SD SY), MV on Friday, the rest on Monday (CLDR 48, same source as the existing table). Keep getWeekInfo() first, and read the fw extension from the tag in the fallback like rg, since de-DE-u-fw-sun gives 7 with the API and 1 without it today.

5. 🟡 dateLocale is never validated: a bad tag crashes with a bare RangeError, an unknown one silently mixes US formats with Monday weeks

Location: packages/x-scheduler-internals/src/internals/utils/SchedulerStore/SchedulerStore.ts:316

dateLocale: parameters.dateLocale ?? DEFAULT_DATE_LOCALE,

The string is used as is. dateLocale="en_US" (the underscore form most backends hand out) throws RangeError: Incorrect locale information provided from new Intl.Locale() inside the weekInfo selector during render, with nothing pointing at the prop. dateLocale="english", "xx-XX" or "en-EN" are valid tags ICU doesn't know: Intl.DateTimeFormat.supportedLocalesOf() returns [], the labels fall back to en-US (Friday, January 1, 2027, 1:30 PM), but getWeekInfo() falls back to CLDR 001, so the weeks start on Monday with US formats and nothing warns.

Failure scenario: <EventCalendar dateLocale={user.locale} /> with a backend locale "en_US" unmounts the calendar in development with a message that doesn't mention dateLocale; "english" renders a plausible but mixed calendar.

Fix: A dev-only check where the parameter enters the state: Intl.getCanonicalLocales(dateLocale) in a try/catch and Intl.DateTimeFormat.supportedLocalesOf(dateLocale).length === 0, each with a Scheduler-prefixed message (MUI X Scheduler: dateLocale "en_US" is not a valid BCP 47 tag, use "en-US"), once per value. Storing the canonical tag would also key the four caches by one string per locale.

6. 🟡 Persisted weekStartsOn: 0 crashes in development and silently works in production

Location: packages/x-scheduler-internals/src/scheduler-selectors/schedulerPreferenceSelectors.ts:22

return weekStartsOn === undefined ? localeWeekInfo : { ...localeWeekInfo, firstDay: weekStartsOn };

The documented preferences + onPreferencesChange pattern stores the preferences, so every app that did so with the previous alpha has weekStartsOn: 0 in localStorage or a database. After upgrading, checkWeekStartsOn() throws error 278 from a view selector in development (the calendar unmounts, and the message doesn't say that 0 used to be Sunday), while in production the modulo arithmetic of getStartOfWeek() accepts 0 as Sunday and only the "Start week on" radio of the preferences menu stays unchecked (PreferencesMenuItems.tsx:253 compares with 7). The changelog shows the prop literal but not the persisted values.

Failure scenario: An app persisting preferences upgrades: it works in production, and every developer's machine throws on the first render.

Fix: Normalize in weekInfoSelector: firstDay: weekStartsOn === 0 ? 7 : weekStartsOn with a one-time dev warning ("weekStartsOn: 0 is deprecated, use 7 for Sunday"), keeping error 278 for the other invalid values; and a changelog line about migrating persisted preferences.

7. 🟡 Locales with native digits now mix two numbering systems in one view

Location: packages/x-scheduler/src/event-calendar/header-toolbar/HeaderToolbar.tsx:164

{`${localeText.week} ${weekNumber}`}

The dates now come out in the numbering system of the locale, but every number the Scheduler builds itself stays a JS number in ASCII digits: the week badge here and in MonthViewWeekRow.tsx:76, hiddenEvents(count) in MonthViewCell.tsx:283, and recurrenceMonthlyPresetLabel(startPlainDate.day) / recurrenceMonthlyDayOfMonthLabel(dayOfMonth) in event-dialog/utils.ts:316 and RecurrenceTab.tsx:445. master was consistently ASCII, since date-fns formats d as 13 in arSA and faIR too (verified).

Failure scenario: dateLocale="ar-EG" shows month cells ١٣, times ١:٣٠ م and agenda numbers ١٣ next to Week 29, 3 more.. and Repeats monthly on day 13. Same for fa-IR, bn-BD, mr-IN, ne-NP, my-MM.

Fix: A cached formatNumber(locale, value) on new Intl.NumberFormat(locale, { useGrouping: false }) in date-locale.ts (٢٩ / ۲۹ / ২৯, and 29 for en/de/hi, verified) for the week number and the hidden count; the two monthly labels can take formatDayOfMonth(locale, plainDate) once Bugs 1 strips the literal.

Tests (5)

1. 🟡 The headline week-number change has no test

Location: packages/x-scheduler/src/event-calendar/header-toolbar/HeaderToolbar.test.tsx:259

The PR body's example is "with dateLocale="en-US" and weekStartsOn: 1, Friday, January 1st, 2027 is in week 1 instead of week 53". The it.each only varies dateLocale, and date-utils.test.ts:73 covers { firstDay: 1, minimalDays: 4 }, { 7, 1 } and { 5, 4 }, never minimalDays: 1 with a non-Sunday start, which is what weekInfoSelector produces for every weekStartsOn override in en-US.

Failure scenario: A regression that rebuilds the ISO rule when weekStartsOn is set (the previous behavior) passes CI.

Fix: In date-utils.test.ts, { firstDay: 1, minimalDays: 1 }: 2026-12-27 → 52, 2026-12-28 → 1, 2027-01-01 → 1; { firstDay: 6, minimalDays: 1 }: 2026-12-25 → 52, 2026-12-26 → 1 (verified). In the it.each, a row { dateLocale: 'en-US', weekStartsOn: 1, expected: 'Week 1' }.

2. 🟡 The preferences menu checking the locale defaults is untested

Location: packages/x-scheduler/src/event-calendar/header-toolbar/preferences-menu/PreferencesMenu.test.tsx:213

The PR body lists "the preferences menu checks the first day of the week and the time format of the locale when weekStartsOn or ampm isn't set" as a behavior change. The only aria-checked test sets weekStartsOn: 1 explicitly, and the 12-hour / 24-hour radios have no checked-state test at all.

Failure scenario: Reverting PreferencesMenuItems.tsx:253 to preferences.weekStartsOn === value leaves no radio checked by default and every test green.

Fix: Two tests without preferences: en-US → the Sunday and 12-hour radios are aria-checked="true"; dateLocale="fr-FR" → Monday and 24-hour.

3. 🟡 The month label in the display timezone is untested

Location: packages/x-scheduler/src/event-calendar/header-toolbar/HeaderToolbar.test.tsx:242

The toolbar, the side panel and the mini calendar now format visiblePlainDate (display timezone) instead of the visibleDate value; the PR body lists it as a behavior change. No test renders with a display timezone that moves the month, and the agenda's new July 2025 (was July, 2025) is asserted nowhere.

Failure scenario: visibleDate at 2025-07-31T23:00:00Z with displayTimezone="Asia/Tokyo" must read August 2025; the old code read July 2025 and nothing would catch it coming back.

Fix: One toolbar test with that visibleDate and timezone, and an agenda assertion on the month-and-year label.

4. 🟡 "Should format the date itself, whatever its timezone" can't fail

Location: packages/x-scheduler-internals/src/internals/utils/date-locale.test.ts:91

The suite runs under TZ=UTC and the test doesn't change the system timezone, so new Date(dayNumber * MS_PER_DAY) formats the same with or without timeZone: 'UTC' in getFormatter(). The createLabelDate test it replaces used setSystemTimezone('America/Los_Angeles').

Failure scenario: Dropping timeZone: 'UTC' from getFormatter() passes this test (the component-level tests under setSystemTimezone still catch it, so not blocking).

Fix: it.skipIf(!isJSDOM) with setSystemTimezone('Pacific/Apia') before the assertion; the file passes under TZ=America/Los_Angeles already.

5. 🟡 Three tests depend on locales nobody else has cached

Location: packages/x-scheduler-internals/src/internals/utils/date-locale.test.ts:70

vitest.shared.mts:117 sets isolate: false, so the module caches of date-locale.ts are shared by every test file in a worker. The fallback test (:58, en-CA / de-AT), the "once" test (:71, vi.spyOn(Intl, 'Locale') expecting one call for de-DE) and the fake-timers test (:159, en-CA) all need their locale to be uncached. They pass today because no other scheduler test uses those tags.

Failure scenario: The first component test that renders with dateLocale="de-DE" (the deDE locale text is already used in StandaloneWeekView.test.tsx) makes :75 fail with "expected 0 to equal 1" depending on file order.

Fix: Private-use tags unique to each test ('de-DE-x-once', 'de-AT-x-fallback', 'en-CA-x-faked'): Intl ignores -x- subtags and getLocaleWeekInfo() already strips them, so the behavior is identical and the cache key is unique. Or export a resetDateLocaleCaches() test hook.

ℹ️ Smaller coverage notes

  • schedulerPreferenceSelectors.weekInfo has no test (en-US → { 7, 1 }, fr-FR → { 1, 4 }, en-US + weekStartsOn: 1 → { 1, 1 }), and no view test asserts a locale-driven first column (dateLocale="fr-FR", no preference, first header matches /lundi/).
  • The yearly recurrence label (formatMonthAndDayOfMonth: "July 3" / "3 juillet") is only matched by /repeats annually/i; one unit test of getRecurrenceLabel() in en-US and fr-FR pins the new order.
  • No test shows a non-Latin numbering system passing through the grids; formatDayOfMonth('ar-EG', …) → '١٣' and formatTime('ar-EG', 785, true) → '١:٠٥ م' are stable across Node and Chromium.

Simplifications (2)

1. ℹ️ The formatter cache is a Map of Maps keyed by the identity of the options object

Location: packages/x-scheduler-internals/src/internals/utils/date-locale.ts:87

It works because every caller passes a module constant; an inline options object would leak a formatter per call. A createDateFormat(options) factory returning (locale, plainDate) => string with its own Map<locale, DateTimeFormat> replaces the twelve constant-plus-function pairs with one line each (export const formatWeekday = /* @__PURE__ */ createDateFormat({ weekday: 'long' })), about 40 lines less and one cache level; the /* @__PURE__ */ annotation the codebase already uses in base-temporal/ keeps the tree-shaking the PR body cares about. formatTime() and the range formatter need the shared formatter, so the factory has to expose it.

2. ℹ️ Two small ones

  • getLocaleAmpm() (date-locale.ts:81): resolvedOptions().hour12 is always a boolean when hour is requested (probed 60 locales, including the hc-h11/h12/h23 extensions), so ?? false is dead; a reader assumes a real case exists.
  • CalendarGridHeaderCell.tsx:116 computes formatWeekday() for the aria-label even when the caller passes one, since elementProps wins in the merge; elementProps['aria-label'] ?? formatWeekday(…) skips it.

Docs (4)

1. 🟡 The accessibility page still has the date-fns ordinal

Location: docs/data/scheduler/accessibility/accessibility.md:72

for example `"Running, 7:30 AM to 8:30 AM, Monday, May 26th, 2025, Resource: Sport, Recurring"`

formatFullDate('en-US', …) gives Monday, May 26, 2025; the PR updated the same example in event-accessible-name.ts:27 but not here. Line 172 says the popover header shows "the full formatted date (for example, "Monday, May 26")" while MoreEventsPopover.tsx:207 renders Mon, May 26 (pre-existing, but the PR touches the component).

Fix: May 26th, 2025 → May 26, 2025; "Monday, May 26" → "Mon, May 26" and drop "full".

2. 🟡 Two test titles still say weekStartsOn is 0

Location: packages/x-scheduler/src/week-view/tests/WeekView.test.tsx:423

'shows Sunday as the first column when weekStartsOn is 0' and MiniCalendar.test.tsx:279 'should show Sunday as the first weekday header when weekStartsOn is 0' both pass weekStartsOn: 7 now.

Fix: is 0 → is 7 in both.

3. 🟡 The dateLocale JSDoc omits the clock it now controls

Location: packages/x-scheduler/src/event-calendar/EventCalendar.tsx:131

 * This affects day names, month names, the first day of the week, week numbers and the other
 * locale-dependent formatting.

The 12-hour or 24-hour default is the most visible new effect of the locale and the one that surprises first (fr-FR switches the whole calendar to 24-hour). Same sentence in SchedulerStore.types.ts:435, EventTimelinePremium.tsx and the standalone views, and in the generated API JSON.

Fix: "…the first day of the week, the week numbers, the 12-hour or 24-hour clock and the other locale-dependent formatting." then pnpm proptypes and pnpm docs:api.

4. ℹ️ Smaller doc notes

  • date-locale.ts:30: the region table says "from the week data of CLDR" without a version; "minDays=4 in supplemental/weekData.xml of CLDR 48 (ICU 78)" tells the next reader what to re-check. The Monday fallback comment (:58) could name who hits it (Firefox < 153).
  • localization.md:97 (both products): the code honors and tests -u-fw-mon, -u-rg-gbzzzz, -u-ca-iso8601 and -u-hc-h23, and they are the only way to get ISO week numbers in en-US or a 12-hour fr-FR without the preferences; one sentence with "en-US-u-ca-iso8601" as the example would surface it.
  • The PR body's changelog bullet "07:30 instead of 7:30 in 24-hour format" is only true for some locales until Bugs 2 lands; the /simplify "Skipped" bullet still lists formatRange() for the week ranges and ampm from the locale, which the PR applies; CalendarGrid.HeaderCell dropping ariaLabelFormat and taking a SchedulerProcessedDay is listed under "Leftovers" though it changes an exported component of the internals package.
  • Follow-up idea rather than a nit: now that dateLocale is a string, the locale files (frFR, heIL, …) could carry defaultProps.dateLocale, which turns "French buttons, English dates" from a documented pitfall into a non-issue.

Verdict

Approve after nits — the day numbers, the time options, the hand-built weekday label and the ASCII counters write locale literals, English order or mixed digits in Danish, Czech, Norwegian, Japanese, Chinese, Korean and Arabic, and the default week start differs between Firefox and the other supported browsers.


🤖 Review generated with Claude Code

- Format the day of the month without the literal of the locale, like the
  `13.` of Danish or the `13日` of Japanese.
- Let the locale pick the 12-hour cycle (`h11` in Japanese), and pad the hour
  of the 24-hour times in every locale.
- Label the week view columns and the agenda days with the weekday, the month
  and the day in the order of the locale.
- Start the weeks on the first day of the region and of the `fw` extension in
  the browsers without `getWeekInfo()`, like Firefox.
- Warn in development when `dateLocale` is invalid or unsupported.
- Write the week numbers in the digits of the locale.
- Create the formatters per format instead of per options object.
- Test the week numbers with another first day, the defaults of the
  preferences menu, the month in the display timezone, the weeks of the
  locale, and the formats in a system timezone; isolate the tests that need
  an uncached locale.
- Update the docs: the accessible names, the `dateLocale` JSDoc and the
  extensions of the locale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@flaviendelangle

Copy link
Copy Markdown
Member Author

Thanks for the review. The fixes are in 0b82385, "Apply the review of the Intl formatting". The PR description is updated as well.

Bugs

  1. Day of the month: applied. formatDayOfMonth() keeps only the day part of formatToParts(), so it renders 13 in Danish, Czech, Japanese and Korean, and ١٣ in Arabic (Egypt). The labels are cached per locale and per day, like the time labels. formatYear() keeps its suffix, as you suggested.
  2. Time options: applied. TIME_12H uses hour12: true, so Japanese writes 午前0:30. TIME_24H uses hour: '2-digit', so 24-hour times read 07:30 in every locale. I updated the tests you listed and the changelog bullet.
  3. Weekday and day of the month: applied. formatWeekdayMonthAndDayOfMonth() ({ weekday: 'long', month: 'long', day: 'numeric' }) labels the week view columns and the agenda days, and formatWeekdayAndDayOfMonth() is deleted. The labels read Sunday, May 4, and the tests that pinned Friday 30 / Sunday 4 are updated.
  4. Firefox fallback: applied. Without getWeekInfo() or weekInfo, the first day comes from the fw extension, then from a table of the CLDR 48 regions that don't start on Monday. I checked your lists against Node's ICU 78: they match for every canonical region (ICU's only extras are deprecated codes like BU and YD). The rg extension changes the region here too. The comments name CLDR 48 and Firefox.
  5. dateLocale validation: applied, as dev warnings. checkDateLocale() runs where the parameter enters the state. It warns when Intl.getCanonicalLocales() throws ("en_US") or when Intl.DateTimeFormat.supportedLocalesOf() returns nothing ("xx-XX"). The message names dateLocale and suggests a valid tag. Not applied: storing the canonical tag, since the caches work as they are.
  6. Persisted weekStartsOn: 0: not applied in code. Reading 0 as Sunday would keep the old value working. Since the Scheduler is in beta, the changelog now says to migrate stored preferences instead, and notes that weekStartsOn: 0 throws error 278 in development.
  7. Native digits: partly applied. formatWeekNumber() (a cached Intl.NumberFormat) writes the week numbers of the header toolbar, the agenda and the month view rows. Not applied: hiddenEvents(count), recurrenceMonthlyPresetLabel() and recurrenceMonthlyDayOfMonthLabel(), which take numbers in the public locale text types, so formatting their argument would change those types.

Tests

  1. Applied. getWeekNumber() is tested with { firstDay: 1, minimalDays: 1 } and { firstDay: 6, minimalDays: 1 }, with the dates you gave. The header toolbar is tested with en-US and weekStartsOn: 1 (Week 1).
  2. Applied. Two preferences menu tests run without preferences: en-US checks Sunday and the 12-hour clock, fr-FR checks Monday and the 24-hour clock.
  3. Applied. A header toolbar test renders 2025-07-31T23:00:00Z in Asia/Tokyo and expects August 2025, and an agenda test expects July 2025.
  4. Applied. The test is it.skipIf(!isJSDOM) with setSystemTimezone('Pacific/Apia').
  5. Applied. The fallback, "once" and fake-timers tests use private use tags of their own (-x-fallback, -x-once, -x-faked).
  • ℹ️ coverage notes: applied. There are new tests for schedulerPreferenceSelectors.weekInfo (en-US, fr-FR, en-US with weekStartsOn: 1), for a week view in fr-FR starting on lundi, for getRecurrenceLabel() yearly in en-US and fr-FR, and for the Arabic (Egypt) digits of formatDayOfMonth(), formatTime(), formatWeekNumber() and the header toolbar week badge.

Simplifications

  1. Applied. createDateFormat(options) returns the format function, and createGetFormatter(options) gives the cached formatter to formatTime() and the range. The Map of Maps keyed by options identity is gone. The annotation is /* #__PURE__ */, because the JSDoc ESLint rule rejects @__PURE__ outside the BaseTemporal copy. An esbuild bundle that imports only formatWeekday keeps none of the other formats.
  2. CalendarGridHeaderCell: applied. It now uses elementProps['aria-label'] ?? formatWeekday(…). getLocaleAmpm(): not applied. TypeScript types resolvedOptions().hour12 as boolean | undefined, so the fallback stays.

Docs

  1. Applied. The examples now read May 26, 2025 and "Mon, May 26", and "full" is dropped.

  2. Applied. Both test titles now say is 7.

  3. Applied. The dateLocale JSDoc mentions the 12-hour or 24-hour clock in every component and in the store types. pnpm proptypes and pnpm docs:api were run.

  4. Applied.

    • The region tables name CLDR 48 / ICU 78.
    • Both localization pages explain the fallback and give "en-US-u-ca-iso8601", "en-US-u-fw-mon" and "fr-FR-u-hc-h12" as examples.
    • In the PR description:
      • the changelog bullet about 07:30 now holds for every locale;
      • the /simplify "Skipped" bullet says formatRange() and ampm were applied afterwards;
      • CalendarGrid.HeaderCell has its own bullet.

    Not applied: the follow-up idea of defaultProps.dateLocale in the locale files. It would change what importing a locale file does, so it fits better in its own PR.

The scheduler jsdom and browser suites, TypeScript, ESLint, Prettier and Vale pass. A few jsdom tests time out under the load of the full run and pass on their own. The bundles grow by about 400 B gzip over the previous head, mostly from the region table and the formatter factories.

🤖 Comment generated with Claude

@rita-codes rita-codes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR review (follow-up on 0b82385)

I re-ran the review on the fixes. Everything applied checks out: the scheduler jsdom suite (2988 tests), tsc, ESLint and Prettier pass on the new head, and I probed Intl in Node (ICU 78) for 36 locales. The new region tables match ICU's weekInfo for all 649 canonical two-letter regions, ICU honors both fw and rg in weekInfo, so the Firefox fallback agrees with Chrome and Safari on those tags, hour: '2-digit' pads to 07:30 in every locale, hour12: true gives 午前0:30 in Japanese, the day part of formatToParts() is the bare number in cs/da/nb/ja/zh/ko and native digits in ar/fa/bn, and { weekday, month, day } orders correctly in all 36 locales (Sonntag, 13. Juli, 7月13日日曜日, 13 Temmuz Pazar, الأحد، ١٣ يوليو). One gap left in the fallback, non-blocking.

Bugs (1)

1. 🟡 The Firefox fallback keeps the region's first day for -u-ca-iso8601

Location: packages/x-scheduler-internals/src/internals/utils/date-locale.ts:130

firstDay: (intlWeekInfo?.firstDay ??
  (FIRST_DAY_EXTENSION_VALUES.indexOf(getUnicodeExtension(tag, 'fw') ?? '') + 1 ||
    getRegionFirstDay(region))) as WeekStartsOn,

ICU forces Monday for the iso8601 calendar unless fw is set (new Intl.Locale('en-US-u-ca-iso8601').weekInfo → { firstDay: 1, minimalDays: 4 }, and …-fw-sun → firstDay: 7). The fallback only applies iso8601 to minimalDays, so without getWeekInfo() it returns { firstDay: 7, minimalDays: 4 } for en-US-u-ca-iso8601 (and for ja-JP-u-ca-iso8601).

Failure scenario: the localization page now recommends "en-US-u-ca-iso8601" to number the weeks like ISO 8601. In Firefox ≤ 152 the week view starts on Sunday with minimalDays: 4, which is neither ISO numbering nor what Chrome/Safari render for the same tag, so an SSR page hydrated in Firefox shifts the whole grid by a day.

Fix: apply the calendar before the region, after fw to keep ICU's precedence:

FIRST_DAY_EXTENSION_VALUES.indexOf(getUnicodeExtension(tag, 'fw') ?? '') + 1 ||
  (intlLocale.calendar === 'iso8601' ? 1 : getRegionFirstDay(region))

and add to the fallback test: expect(getLocaleWeekInfo('en-US-u-ca-iso8601-x-fallback')).to.deep.equal({ firstDay: 1, minimalDays: 4 }).

Tests (0)

No findings.

Simplifications (1)

1. ℹ️ checkDateLocale() runs two Intl lookups on every parameters update in development

Location: packages/x-scheduler-internals/src/internals/utils/SchedulerStore/SchedulerStore.ts:315

deriveStateFromParameters() is called from updateStateFromParameters(), which useEventCalendar runs after every render, so Intl.getCanonicalLocales() and supportedLocalesOf() run per render in dev (production is untouched). A Set of checked tags next to the other lookups makes it one-shot, like warnOnce already is for the message. Optional.

Docs (0)

No findings. The fw / hc-h12 examples of the localization pages are verified (fr-FR-u-hc-h12 resolves hour12: true); the ca-iso8601 example holds once Bug 1 is fixed.

The items left out are fine with me: documenting the migration of a stored weekStartsOn: 0 instead of reading it, keeping hiddenEvents(count) and the monthly recurrence labels on plain numbers because of the public locale text types, and the ?? false of getLocaleAmpm().

Verdict

Approve after nits — only the iso8601 first day of the fallback is left, a one-line fix.


🤖 Review generated with Claude Code

Like `getWeekInfo()`, the fallback reads the `fw` extension, then the `rg`
extension, then the Monday of the `iso8601` calendar, then the region of the
locale. `checkDateLocale()` checks a locale once, as the store checks its
parameters on every render.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@flaviendelangle
flaviendelangle marked this pull request as ready for review October 28, 2026 14:04
@flaviendelangle
flaviendelangle merged commit 7ae67cf into mui:master Oct 28, 2026
23 checks passed
@flaviendelangle
flaviendelangle deleted the scheduler-intl-date-locale branch October 28, 2026 14:06
@rita-codes rita-codes mentioned this pull request Oct 29, 2026
13 tasks done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking change Introduces changes that are not backward compatible. scope: scheduler Changes related to the scheduler. type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants