Repository navigation
[scheduler] Expand recurring events on plain dates - #23943
flaviendelangle wants to merge 22 commits into
Conversation
Expands 200 recurring series over a week, a month and a year, in the system timezone and across timezones, plus long-running COUNT series. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bring the principles of BaseTemporal (mui/base-ui-plus#336) to the expansion of recurring events: the RRULE math runs on immutable plain dates backed by day numbers, and adapter values are only translated at the entry and exit of the expansion. This also fixes timezone bugs of the adapter-based implementation, following RFC 5545: BYDAY codes shifted by one day on systems west of UTC, DST resolution depending on the system timezone, days dropped or repeated after DST transitions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Search monthly and yearly matches over a 400-year Gregorian cycle, so COUNT and long-interval rules don't stop before a match decades away. - Format plain-date keys from UTC values, as a system timezone can skip a local midnight. - Keep rejecting several byMonthDay values when recomputing COUNT. - Test the same-period COUNT boundaries and the expansion under non-UTC system timezones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…conds - Throw the INTERVAL error for intervals that aren't finite integers, in the RRULE parser and in the engine, instead of looping forever on an infinite interval. - Read the full system offset when `getTimezoneOffset()` drops the seconds of a local mean time offset. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`Date.UTC()` remaps the years 0 to 99 to the 1900s; build the local timestamp from the day number instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Truncate the fields of `PlainDate.from()` like Temporal, so a fractional byMonthDay picks the same day as master's `setDate()` instead of creating a fractional day number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…et sign - Don't create an occurrence, nor count one toward COUNT, on a day the data timezone skips, like Pacific/Apia on December 30th, 2011. - Read processed dates through the adapter for offsets between -01:00 and 00:00, whose sign `TZDate` flips, so they match their value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sets - Detect the days a timezone skips with `getSkippedDays()`, checked by month so that a skipped and a repeated day years apart don't cancel out, and share it between the expansion and `getRemainingOccurrences`. - Translate every expansion boundary with the offsets of the adapter, as `TZDate` reads the offsets between -01:00 and 00:00 as positive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Remove `PlainDate.equals()`, `toJSON()` and the `years` duration, which nothing uses, and the cache of parsed offsets, which the cache of offsets per day makes redundant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Deploy previewBundle size
Check out the code infra dashboard for more information about this PR. |
Name the plain dates it returns `plainDate`, or `xxxPlainDate` when there are several, and read their fields without destructuring. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Updates and deletes occurrences of long daily, weekly, monthly and yearly series, which check the occurrences before and after the edit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
String and object rules go through the same validation, which used to run in the engine during each expansion: COUNT with UNTIL, ordinal BYDAY in weekly rules, BYDAY with BYMONTHDAY and several BYDAY values in monthly rules, and selectors in yearly rules. These rules now throw when the event is processed rather than when it renders. Object rules keep their leniency: an INTERVAL below 1 counts as 1 and a fractional one is truncated, as the dialog lets users type 1.5. Only a non-finite INTERVAL throws. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`RecurringSeries` owns the days of a series in its data timezone: the days of its rule, UNTIL, COUNT, EXDATE and the days the timezone skips. The expansion, the COUNT of a split series and the checks of update and delete ask it, instead of each rebuilding these rules. The checks no longer expand the series into occurrences: deleting an occurrence goes from 53 ms to 0.02 ms on a long series. - The engine trusts the rules validated by `parseRRule()`. - A string rule on "this and following" no longer turns into an object. - Excluding the first occurrence of a series whose next one is years away, like February 29th, no longer deletes the series. - Splitting a monthly rule with several BYMONTHDAY values uses the first one, like the expansion, instead of throwing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Supports the overflow option and the years of a duration, and throws the errors Temporal throws, in the same order: RangeError for a field that isn't finite, a month or a day below 1, a fractional duration unit, units of both signs, an invalid overflow or a rejected date; TypeError for a missing field, an empty duration or no field to replace. toString() uses the extended years of Temporal. The error paths are kept out of the hot functions, so the recurring-event benchmarks stay within noise. A byMonthDay below 1 still targets the 1st. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…aseTemporal BaseTemporal throws an Error where Temporal throws a RangeError, as the error minifier only handles Error and TypeError. PlainDate follows it, so production builds minify its messages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sion app `@date-fns/tz` builds the timezone-aware methods of `TZDate` from the methods of `Date.prototype` when it loads. The fake clock replaced `Date` first, so `TZDate` read every date in the timezone of the machine, UTC in CI, and the Scheduler timezone demos rendered wrong screenshots. They now match the deployed docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…HDAY checks - Stop before counting the occurrences before the first one in range, or reading the timezone of a later one, when it is past the visible range. A rule every 10,000 years took 700 ms, and one every 300,000 years threw an invalid time value. - Reject several BYMONTHDAY values with COUNT in `parseRRule()`, with the message master threw when counting. - Compare a fractional BYMONTHDAY with the length of the month before truncating it, as on master. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ccurrences only COUNT looked up the days the timezone skips from DTSTART to the visible range before counting, so 1,000 series with COUNT=10 that ended years ago took 140 ms to expand to nothing. The series now counts the days of its rule up to COUNT first, then looks for skipped days among them, and recounts only when the timezone skips one: 5 to 10 ms. Counting moves from `RuleDays` to `RecurringSeries`, which knows the timezone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
COUNT=1.5 counts 2 occurrences on master, and in the day-by-day count. The closed form of daily rules used it as is, so an expired series asked `PlainDate.add()` for half a day and threw. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| The Scheduler uses the event's `timezone` to interpret and expand | ||
| the recurrence pattern correctly across DST boundaries. | ||
|
|
||
| When a DST transition skips the local time of an occurrence, the occurrence moves forward by the length of the gap: a daily event at 02:30 in `Europe/Paris` happens at 03:30 on the day clocks spring forward. |
There was a problem hiding this comment.
@rita-codes not sure how you want to structure this doc page
I feel like it's listing a lot of things that people won't ready 😆
- `createAdapterDate()` adds the milliseconds to an epoch value cached per adapter, instead of formatting and parsing an ISO string. It creates the same values in every timezone Intl supports. - The weekly `getNext()` moves to the next day of the rule, or to the first one `interval` weeks later, without looking for the start of the week. - `getSkippedDays()` computes the start of each 31-day period once. The expansion takes about 40% less time in the system timezone and 15 to 20% less from New York to Paris. `event-calendar-premium` grows by 42 B gzipped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the display timezone is the data timezone, the display start and end of most occurrences are the instants of their data start and end. The expansion now copies these processed dates instead of computing them again. The copies share the `value` of the data dates, like the occurrences of master in the system timezone. The year expansion takes 23% less time in the system timezone, and 40% less with both timezones in New York. `event-calendar-premium` grows by 22 B gzipped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`base-temporal` now holds `PlainDate.ts`, `iso.ts`, `calendar.ts` and `mergeDateFields.ts` from mui/base-ui-plus#336 at 634c9c7e9, unchanged, instead of a reduced `PlainDate` ported from it. Changes to these files go to Base UI Plus first, and are copied from there. - The helpers between adapter values, instants and plain dates move to `plain-date-utils`. - ESLint lets the copy keep the code style of Base UI Plus. - BaseTemporal dates end on December 31st, 9999. `RecurringSeries` ends a series there, instead of throwing when a large interval jumps past it. - `RecurringSeries` builds the next day of a daily or weekly rule from its day number, like the BaseTemporal components, as `add()` validates its duration on every call. - The 25 errors of the copy replace the 7 errors of the reduced `PlainDate` in the error codes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rita-codes
left a comment
There was a problem hiding this comment.
PR review
There is one blocking regression. When an event is defined in wall-clock time inside the hour that a DST change repeats, the expansion and processEvent now resolve that time differently, unless the system is in the event's timezone. As a result, editing only the title of such an occurrence saves it with zero or negative duration. Everything else is non-blocking: the plain-date infrastructure matched master's processDate on 20,000 random dates per timezone across 21 timezones, and the offset cache passed about 360,000 checks covering every DST transition, with 0 mismatches in both. Beyond that, I'd like to align rule validation with RFC 5545 (here or in a follow-up), and keep the Base UI Plus errors out of MUI X's public error registry.
Bugs (6)
1. 🔴 Editing an occurrence in a repeated DST hour collapses its duration
Location: packages/x-scheduler-internals-premium/src/internals/utils/recurring-events/getRecurringEventOccurrencesForVisibleDays.ts:63
const startMs = resolve(day, series.start.msInDay, dataTimezone);processEvent (unchanged) still resolves the event's wall-time start/end with TZDate. Outside the event's timezone, TZDate picks the second instant of a repeated time. The expansion now picks the first instant, as RFC 5545 §3.3.5 says. So the first occurrence starts 1h before event.dataTimezone.start.
The edit flow then mixes the two values:
FormContent.tsx:500passesoccurrence.dataTimezone.start.valueand only sendsstart/endif they were touched.- The store forwards that to
updateRecurringEvent. getOccurrenceEndadds the duration taken from the processed event's instants.
Failure scenario: Event start: '2025-10-26T02:30:00', end: '03:30', timezone: 'Europe/Paris', rrule: DAILY;COUNT=3, on a UTC or New York system. You edit only the title of the first occurrence, with "only this" or "this and following":
- master saves
02:30 → 03:30(60 min). - The PR saves
02:30 → 02:30(0 min). - With a 30-min event, master saves
02:30 → 03:00and the PR saves02:30 → 02:00, soendcomes beforestart.
A Paris system is unaffected.
There's also a related description issue (ℹ️). Behavior change #2 in the PR description gives the example "a daily event at 02:30 in Paris started at the second 02:30". That case is the DTSTART itself, and processEvent still resolves it with TZDate. Likewise, a DTSTART inside a gap ('2025-03-30T02:30:00' Paris) still moves the whole series to 03:30, which is pre-existing.
Fix: Resolve the event's own start/end in processEvent with the same toEpochMs rule, so the event, its first occurrence and the serialized wall time agree. Add a test of a title-only edit in the repeated hour on a non-local system.
2. 🟡 INTERVAL and COUNT should follow RFC 5545
Location: packages/x-scheduler-internals-premium/src/internals/utils/recurring-events/rRuleString.ts:237
if (!Number.isInteger(interval) || interval < 1) {This PR makes string INTERVAL stricter, but the other paths stay loose, so the same value is treated differently depending on the form. RFC 5545 defines both parts as 1*DIGIT, and INTERVAL as a positive integer.
| Input | This PR | RFC 5545 |
|---|---|---|
INTERVAL=1.5 |
throws | throws ✅ |
COUNT=1.5 |
2 occurrences | throws |
COUNT=+2, INTERVAL=+2, COUNT=1e1 |
accepted | throws |
{ interval: 1.5 }, 0, -3 |
becomes 1 | throws |
{ count: 1.5 } |
2 occurrences | throws |
{ count: 0 }, -3, NaN |
0 occurrences, no error | throws |
{ count: 0, until } |
0 occurrences; the COUNT + UNTIL check at L94 skips it since count > 0 is false |
throws |
Most of this already happens on master. What the PR changes is that INTERVAL=1.5 used to render and now throws, while the PR description says "Finite intervals behave as on master". Since parseRRule now centralizes validation and we're in beta, I'd make it follow the RFC:
- One rule for
INTERVALandCOUNT, in both forms. In strings, the value matches/^\d+$/and is ≥ 1. In objects,Number.isInteger(value) && value >= 1. Anything else throws. - Remove the
Math.max(1, Math.trunc(...))on the objectintervaland theMath.ceiloncount(RecurringSeries.ts:360), along with the "as on master" tests that pin them.
If you'd rather keep this PR focused, tell me and I'll open an issue to do it separately. In that case, please update the PR description so it doesn't claim intervals behave as on master.
3. 🟡 A byMonthDay ≤ 0 in a rule object silently becomes the 1st
Location: packages/x-scheduler-internals-premium/src/internals/utils/recurring-events/RecurringSeries.ts:169
// A rule object can have a byMonthDay below 1, which targets the 1st. …
this.monthlyTargetDay = rule.byMonthDay?.length
? Math.max(1, rule.byMonthDay[0])
: seriesStart.day;{ freq: 'MONTHLY', byMonthDay: [-1] } produces occurrences on Feb 1, Mar 1, and so on. In RFC 5545, -1 is the last day of the month, so the user gets wrong dates with no error. The string form already rejects BYMONTHDAY=-1, and the UI never produces a negative value, since the preset uses the start date's day.
I wouldn't add support for negative values here, just validate both forms the same way:
- Validate the object's
byMonthDayinvalidateRulewith the existing string rule and message ("BYMONTHDAY values must be integers between 1 and 31"), so no new error code is needed. - Remove the
Math.max(1, …)clamp and its comment. - Use
Number.isIntegerinstead ofNumber.isNaNin the string check (rRuleString.ts:250). TodayBYMONTHDAY=1.5passes even though the message says "integers", and the "truncates a fractional byMonthDay" test pins that.
If this fits better in the follow-up issue from point 2, tell me and I'll add it there.
Related question, not for this PR: today any invalid rule throws from processEvent during buildEventsState, so one bad event (for example from a backend) unmounts the whole EventCalendar in production. Making validation stricter per the RFC will turn more of today's "silently wrong" rules into crashes. Would you keep throwing, or warn once and skip that event (or render it without recurrence)? Happy to track it in the same follow-up issue.
4. ℹ️ A few places don't check the year 9999 limit
Location: packages/x-scheduler-internals-premium/src/internals/utils/recurring-events/RecurringSeries.ts:74, :79, :250
getNthWeekdayOfMonth and findFirstInWeek call PlainDate.add() without the LAST_SUPPORTED_DAY check that addDays/addMonths have, so MONTHLY;BYDAY=5SU in December 9999 throws instead of ending the series as #7 says. Very unlikely in practice. Feel free to ignore it, but if it's easy, reusing the same check there would make it consistent.
5. ℹ️ A recurring all-day occurrence ends 1h later than a non-recurring all-day event
Location: packages/x-scheduler-internals-premium/src/internals/utils/recurring-events/getRecurringEventOccurrencesForVisibleDays.ts:72-77
The occurrence takes the later 23:59:59.999 (deliberate, with a test in Beirut). processEvent and auto-scheduling.ts still use adapter.endOfDay, which gives the earlier one. The PR's value is the correct one, but the two paths now disagree.
Failure scenario: Santiago on 2024-04-06:
- A non-recurring all-day event ends at
2024-04-07T02:59:59.999Z. - The same day's occurrence ends at
2024-04-07T03:59:59.999Z.
Nothing renders differently, but sorting ties (sortEventOccurrences.ts:13) and overlap detection within that hour behave differently.
Fix: Align normalizeAllDayBounds to the later instant. This could go with the fix for point 1, since both bring processEvent in line with the new rules.
6. ℹ️ Some invalid rules now fail at mount
{ freq: 'MONTHLY', byMonthDay: [1, 15], count: 3 } used to render its first month on master and throw once you navigated further; it now throws on first render. An invalid rule on an event whose resource is hidden now throws too. Fail-fast seems better to me, but it's worth listing in the PR description. It also ties into the question from point 3 about crashing the calendar vs skipping the event.
Tests (4)
1. 🟡 The system-timezone matrix doesn't cover the claims it backs
Location: packages/x-scheduler-internals-premium/src/internals/utils/recurring-events/getRecurringEventOccurrencesForVisibleDays.test.ts:1036
Several of these tests already pass on master:
- "should move an occurrence on a local time skipped by the transition forward by the gap" passes on master under UTC, New York and Lord Howe.
- "should start an occurrence on the first instant of a local time repeated by the transition" passes on master under Lord Howe.
- "should end an all-day occurrence on the last instant of a day whose last hour is repeated" only fails on master under Lord Howe, and browser mode only runs the UTC iteration.
No test reproduces two cases from the PR description: "02:00 London → 02:30 on a Lord Howe system", and the expansion going backwards or looping.
Failure scenario: A regression in those paths would slip through. A title-only edit in the repeated hour (point 1 of Bugs) is also missing.
2. 🟡 Several new paths have no test, or a test that doesn't pin them
Location: plain-date-utils/timeZone.test.ts:32, plain-date-utils/adapter.test.ts:46, getRecurringEventOccurrencesForVisibleDays.ts:57, getRecurringEventOccurrencesForVisibleDays.test.ts:664
Failure scenario: Each of these could regress silently:
- The
{ ...dataDate }copies that sharevaluehave no test; their equivalence relies on an uncommitted harness. - Under CI's TZ=UTC, "reads the system offset for the default timezone" compares the implementation's own formula with itself.
- The
createProcessedDatevsprocessDatetest never runs'default'with a non-UTC system timezone. - "stops at the visible range…" only asserts
[]; its two YEARLY cases already pass on master. - There's no direct test of the year-9999 end (#7).
- There's no split/edit test across Apia 2011-12-30 (#5).
3. ℹ️ BYMONTH tests now pin a rule the engine ignores
Location: packages/x-scheduler-internals-premium/src/internals/utils/recurring-events/rRuleString.test.ts:61, :128
They switched from FREQ=YEARLY;BYMONTH=… to FREQ=MONTHLY;BYMONTH=…. BYMONTH is silently ignored outside YEARLY, on master too: MONTHLY;BYMONTH=1,3 repeats every month. So the sorting tests now use, as a valid example, a rule that doesn't do what it says. Could they test the sorting on a supported rule, or test the sort helper directly? "should sort BYDAY with ordinals correctly" was also deleted, while that code is still reachable. Rejecting BYMONTH where it isn't supported can go in the follow-up issue from point 2 of Bugs.
4. ℹ️ Repo conventions
Location: RecurringSeries.test.ts, plain-date-utils/adapter.test.ts, plain-date-utils/timeZone.test.ts
- None of the
it(...)descriptions in these files start with "should" (0 of 40, 0 of 4 and 0 of 7). - Some test names say "as on master", which won't make sense once merged.
- Several new
RecurringSeriestests use'…T23:59:59Z'with'default', so they fail under Lord Howe and Kolkata. Using an explicit timezone such as'UTC'would fix it.
Simplifications (2)
1. 🟡 RecurringSeries re-implements helpers that the copied PlainDate.ts already exports
Location: packages/x-scheduler-internals-premium/src/internals/utils/recurring-events/RecurringSeries.ts:90, :109, :211, :239
These local versions give the same results as helpers that PlainDate.ts already exports. I checked about 304,000 dates across the supported range and found no mismatch:
| Local | Exported |
|---|---|
getStartOfRRuleWeek → date.add({ days: 1 - date.dayOfWeek }) |
getStartOfWeek(dayNumber, 1) |
with({ day: 1 }) |
getStartOfMonth(dayNumber) |
monthStart.add({ months }) in the local addMonths |
addMonths(dayNumber, months) |
The local ones go through the validated API, so the monthly and yearly loops are slower:
| Operation | Validated API | Exported helper |
|---|---|---|
| Add one month | add({ months: 1 }) 233 ns |
addMonths 141 ns |
| Start of month | with({ day: 1 }) 287 ns |
getStartOfMonth 126 ns |
That's inconsistent with the PR description's reason for stepping DAILY/WEEKLY by day numbers.
Fix: Wrap the exported helpers with createPlainDate(…, ISO_CALENDAR_ID), as addDays already does. One thing to keep: the exported helpers don't validate, and createPlainDate accepts any day number. So the local addMonths has to keep its year > LAST_SUPPORTED_YEAR → null check; without it, stepping past 9999 would silently create an invalid date instead of ending the series.
2. ℹ️ API surface with no consumer
Location: plain-date-utils/timeZone.ts:124, :146, :182; plain-date-utils/index.ts; RecurringSeries.ts:410-432
- Every production call passes
getAdapterTimezoneOffsetMsasgetOffsetMs; only tests use the default. Making it the default, or exporting one function that already uses it, would remove the small wrappers at each call site. getTimezoneOffsetMs,createAdapterDateandTimezoneOffsetGetterare exported from the barrel with no consumer outside the module.hasOccurrenceandhasOccurrenceBeforeare nearly the same. They differ only in the upper bound and the early return; one private helper takingto: PlainDate | nullwould do.
Docs (4)
1. 🟡 25 "Base UI Plus:" errors in MUI X's public registry, 7 with a placeholder URL
Location: docs/public/static/error-codes.json:307-331
"See https://asdfghjk.base-ui.com/react/utils/base-temporal#calendars"They break the AGENTS.md prefix rule, and point to APIs MUI X doesn't expose (registerCalendar(), @base-ui/plus/calendar-systems). Most of them can't fire from the Scheduler. Since the copied files must stay identical to Base UI Plus, editing them here isn't an option, and every re-copy that rewords a message would add new codes.
Fix: Exclude base-temporal/ from the minifier and from extract-error-codes, and fix the domain upstream.
2. 🟡 Comments that say "as on master" / "as before"
Location: RecurringSeries.ts:168, :359; rRuleString.ts:119
// A fractional COUNT counts one more occurrence, as on master.They mean nothing after the merge. They should describe the behavior itself, for example "Without COUNT, only the first value is used." If points 2 and 3 of Bugs land, the first two go away with the code they describe.
3. 🟡 No release note for BYDAY shifting by a day west of UTC
Location: PR title and description
The PR description itself says it "deserves a line in the release notes", but the title reads like a refactor. Rules saved from the event dialog west of UTC will now show one day later. Rename the PR, or flag it to whoever writes the release. Changes #2 and #5 could share that line.
4. ℹ️ Minor stale text
Location: rRuleString.ts:84-86, docs/data/scheduler/recurring-events/recurring-events.md:55, updateRecurringEvent.test.ts:510
- The INTERVAL error says "must be a positive integer", but the object form coerces values and only rejects non-finite ones. Point 2 of Bugs would make the message accurate for both forms.
recurring-events.mddoesn't say thatINTERVAL(andCOUNT, after point 2) must be a positive integer.updateRecurringEvent.test.ts:510still mentionsmondayWeekDayNumber=2, which the PR removed.
Verdict
Request changes: editing an occurrence in the repeated DST hour saves it with zero or negative duration on systems outside the event's timezone.
🤖 Review generated with Claude Code
This PR brings BaseTemporal, from mui/base-ui-plus#336, to one area of the Scheduler: the expansion of recurring events. The RRULE math now runs on its
PlainDate, an immutable date backed by a day number, instead of adapter dates. The expansion translates the adapter values of an event to plain dates in its data timezone once, and translates each occurrence back to adapter values, so the rest of the Scheduler and the public API don't change. Moving the rest of the Scheduler can be a second step.Why this area
I ranked the date-heavy algorithms by how much date work they do, how many timezone and DST workarounds they carry, and how narrow their inputs and outputs are.
TZDatecall formats with Intl several times, and COUNT replays the series from DTSTARTstartOfDaycan move to 01:00, four COUNT helpers that each re-implement the stepping of a frequencygetDayListandisWeekendisWeekendparses two dates and formats three for each daystartOfDay" fix for midnight transitionsstartOfDayandendOfDayper occurrenceprocessEventThe expansion came first on every column, so this PR migrates it alone.
processEventis where a second step would remove the adapter.Changes
x-scheduler-internalsgets two internal modules:base-temporalis a copy ofPlainDate.ts,iso.ts,calendar.tsandmergeDateFields.tsfrom mui/base-ui-plus#336 at mui/base-ui-plus@634c9c7e9. The files stay identical to Base UI Plus: changes go there first and are copied back. ESLint lets them keep the code style of Base UI Plus, and their errors start with "Base UI Plus:".plain-date-utilsconverts between instants and plain dates with a wall-clock time. Offsets come from a cachedIntl.DateTimeFormat, and the module caches the offset of each UTC day without a transition. Its bridge with the adapter creates the same values, keys and processed dates asprocessDate().x-scheduler-internals-premium:RecurringSeriesholds the recurrence of a series in its timezone: the days its rule matches, with UNTIL, COUNT, EXDATE and the days the timezone skips. The expansion, the edits and the deletions ask it for the days of the occurrences, whether an occurrence remains, and how many come before a day. It replacesgetRemaining{Daily,Weekly,Monthly,Yearly}Occurrences,nthWeekdayOfMonth,dayInWeek,startOfRRuleWeek,getWeekDayNumberFromCodeandgetEventDurationInDays.RecurringSeriesmoves daily and weekly rules forward by day numbers, like the BaseTemporal components, asPlainDate.add()validates its duration on every call.parseRRule()validates rule objects like RRULE strings, once, with the same messages, soRecurringSeriesonly receives valid rules.valueof the data dates, like the occurrences of master in the system timezone.Behavior changes
The adapter-based code had timezone bugs. RFC 5545 decides the new behavior.
BYDAY=TUoccurred on Mondays, and the event dialog saved a Tuesday event asBYDAY=WE. The Monday reference came fromadapter.date('2025-01-06T00:00:00Z', 'utc'), which reads the local fields of the string, and those are Sunday 19:00 in New York. Rules that the event dialog saved on such systems now show one day later, which deserves a line in the release notes.TZDateresolves a local time that a DST transition skips or repeats differently depending on the timezone of the system. A daily event at 02:30 in Paris started at the second 02:30 of a fall-back day on a UTC system, and at the first one on a Paris system. An event at 02:00 in London started at 02:30 on a system in Lord Howe. When the system timezone had its own transition, the expansion could even go backwards or loop forever. Occurrences now follow RFC 5545 §3.3.5: a skipped time moves forward by the length of the gap, and a repeated time refers to its first instant. The timezone docs now describe both rules.INTERVAL=1e999used to crash later with a date-fnsRangeError. Finite intervals behave as on master: below 1 they count as 1, and a rule object keeps the integer part of a fractional one.Benchmarks
Mean times of
pnpm bench:jsdominpackages/x-scheduler-internals-premiumwithTZ=UTC. Master and this PR ran in turn, 5 times each, and each column is the median of their runs.The expansion of 200 recurring series of every frequency, with COUNT, UNTIL, exception dates and all-day series, then of 3 long series with COUNT:
Across timezones, most of the remaining time builds the
TZDatevalues that the rest of the Scheduler still reads. The COUNT rows run 10 iterations of under 1 ms, so their means can move by 50% between runs.Editing 4 long series in New York, daily, weekly, monthly and yearly, at an occurrence years after their start, 20 times:
On master, the edits walk the series with the adapter: deleting one occurrence checks that another one remains, and splitting the series counts the occurrences before the edited one.
Bundle size
Measured from source with Vite, minified, with the error minifier of the production build, gzip level 9, and React and Material UI external.
event-calendarevent-calendar-premiumevent-timeline-premiumOn their own, the premium recurring-events code goes from 21.3 kB to 18.7 kB minified, and
base-temporalandplain-date-utilsadd 14.7 kB, 5.1 kB gzipped. Copying BaseTemporal as is costs 2.5 kB gzipped more than aPlainDatereduced to what the Scheduler uses: calendars, eras, ISO strings and the input checks of Temporal. The date-fns adapter weighs 14.7 kB and stays, since the rest of the Scheduler uses it, so the bundle only shrinks once the second step removes it. The community package doesn't bundle the plain-date code.Adapter usage
To see how far the Scheduler is from dropping the adapter, I counted the references to each adapter method and property in the production source of the four Scheduler packages, without tests and benchmarks. These are call sites in the code, not calls at runtime.
The Scheduler references the adapter 506 times with this PR, against 629 on master, and uses 52 of its methods and properties instead of 55.
setDate,setMonthandgetDaysInMonthare gone. In the recurring-events folder, the count drops from 160 to 31. The expansion itself only reads the timestamps of its inputs withgetTime, and the bridge with plain dates adds 6 calls.x-schedulerx-scheduler-premiumx-scheduler-internalsx-scheduler-internals-premiumThe remaining references fall into three kinds of work, which need different replacements:
date,now,setTimezone,getTimezone,toJsDate,isValid,getTimetimeZone.tsformat,formatByString,formats,escapedCharacters,getCurrentLocaleCodeIntl.DateTimeFormat, like BaseTemporalThe files with the most references are
x-scheduler-internals/.../date-utils.tswith 38, the timeline presets inpreset-utils.tsxwith 35, the timeline header initerate.tswith 31, the event dialog utils and the auto-scheduling with 24 each, and the lazy loading plugin with 18. These six files hold a third of the calendar math, 107 of 319 references, and the 79 formatting references are spread over 20 files.References per method and property
getTimestartOfDayaddDaysformatisBeforeisSameDayendOfDayformatByStringformatssetTimezoneaddMillisecondsisAfteraddMinutesdatedifferenceInDaysnowsetHoursstartOfMonthgetDayOfWeekgetDateisEqualaddMonthsescapedCharactersgetMinutesgetTimezoneaddWeeksisValidgetHoursstartOfYearaddYearsisWithinRangeaddHoursgetMillisecondssetMinutesstartOfWeekendOfMonthendOfWeekgetSecondsisSameMonthsetMillisecondssetSecondstoJsDategetYeargetMonthdifferenceInMonthsdifferenceInWeeksdifferenceInYearsdifferenceInHoursendOfYeargetCurrentLocaleCodegetWeekNumberstartOfHourgetDaysInMonthsetDatesetMonthArgos diffs
The 3 Argos diffs come from a fix in the regression app, not from the Scheduler.
The app installed its fake clock before
@date-fns/tzloaded.TZDatebuilds its timezone-aware methods from the ones it finds onDate.prototypewhen it loads, and the fakeDatehas none, so it read every date in UTC. The master screenshots of the timezone demos show the wrong times, and on this PR "LA Weekly Afternoon" landed in the all-day row.test/regressions/index.tsxnow loads the Scheduler adapter before the fake clock.TimezoneRecurringEvents,TimezoneInstantBasedandTimezoneDatasetWallTimenow match the deployed docs.Validation
pnpm test:unit --project "x-scheduler*" --run: 3,370 tests passed. The DST and BYDAY tests also run with the system timezone set to New York and Lord Howe, and the new regression tests fail on master.TZDateresolves them correctly.PlainDatereturns what the Temporal polyfill returns, or throws.createAdapterDate(), in every timezone Intl supports and under 9 system timezones. Adding the milliseconds to an epoch value gives the same values as the ISO string it parsed before.pnpm extract-error-codesadds the 25 errors of the BaseTemporal copy.The offset cache went to BaseTemporal in mui/base-ui-plus#336, where it makes
ZonedDateTime.from()andwith()about twice as fast. So didPlainDate.add()andwith(), which follow the native Temporal of Chrome and Firefox in every calendar. For this copy, mui/base-ui-plus#336 also made creating aPlainDateandadd()34% to 40% faster.🤖 Generated with Claude Code