Skip to content

[scheduler] Expand recurring events on plain dates - #23943

Open
flaviendelangle wants to merge 22 commits into
mui:masterfrom
flaviendelangle:scheduler-plain-date-recurrence
Open

flaviendelangle wants to merge 22 commits into
mui:masterfrom
flaviendelangle:scheduler-plain-date-recurrence

Conversation

@flaviendelangle

@flaviendelangle flaviendelangle commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

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.

Algorithm Date work Workarounds Inputs and outputs
Recurring event expansion 20 to 40 adapter calls per occurrence, each TZDate call formats with Intl several times, and COUNT replays the series from DTSTART A Monday reference to undo the locale's weekday numbering, a Monday-based week rebuilt from it, a day cursor that startOfDay can move to 01:00, four COUNT helpers that each re-implement the stepping of a frequency One event and a range in, occurrences out
getDayList and isWeekend Few days, but isWeekend parses two dates and formats three for each day A "+12 hours, then startOfDay" fix for midnight transitions Narrow
Grouping occurrences by day startOfDay and endOfDay per occurrence None Reads processed dates
Timeline axis and header Day differences per occurrence and per cell Comments about wall-clock versus elapsed time on DST days Mixed with layout
processEvent The most work per event Few Its output feeds every component

The expansion came first on every column, so this PR migrates it alone. processEvent is where a second step would remove the adapter.

Changes

  • x-scheduler-internals gets two internal modules:
    • base-temporal is a copy of PlainDate.ts, iso.ts, calendar.ts and mergeDateFields.ts from 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-utils converts between instants and plain dates with a wall-clock time. Offsets come from a cached Intl.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 as processDate().
  • x-scheduler-internals-premium:
    • RecurringSeries holds 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 replaces getRemaining{Daily,Weekly,Monthly,Yearly}Occurrences, nthWeekdayOfMonth, dayInWeek, startOfRRuleWeek, getWeekDayNumberFromCode and getEventDurationInDays.
    • RecurringSeries moves daily and weekly rules forward by day numbers, like the BaseTemporal components, as PlainDate.add() validates its duration on every call.
    • parseRRule() validates rule objects like RRULE strings, once, with the same messages, so RecurringSeries only receives valid rules.
    • When the display timezone is the data timezone, the display dates of an occurrence at the same instants as its data dates are copies of them. The copies share the value of the data dates, like the occurrences of master in the system timezone.
  • Benchmarks of the expansion and of the edits. Both also run on master.

Behavior changes

The adapter-based code had timezone bugs. RFC 5545 decides the new behavior.

  1. On systems west of UTC, BYDAY codes were shifted by one day. BYDAY=TU occurred on Mondays, and the event dialog saved a Tuesday event as BYDAY=WE. The Monday reference came from adapter.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.
  2. TZDate resolves 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.
  3. After a DST transition at midnight, in Havana, Santiago, Asunción or Beirut, the day cursor drifted to 01:00 and the last visible day lost its occurrence.
  4. Monthly and yearly searches stopped after 12 months or 8 years, which ended series whose next match is decades away, like the 5th Tuesday of February every 12 months. They now search a whole 400-year Gregorian cycle.
  5. The monthly and yearly COUNT helpers counted DTSTART for a date before it in the same month or year. Days that a timezone skips, like December 30th, 2011 in Pacific/Apia, still have no occurrence, and no longer count toward COUNT when splitting a series.
  6. An interval that isn't a finite number throws the existing INTERVAL error, in a string or a rule object. INTERVAL=1e999 used to crash later with a date-fns RangeError. Finite intervals behave as on master: below 1 they count as 1, and a rule object keeps the integer part of a fractional one.
  7. BaseTemporal dates end on December 31st, 9999, and so do series. A rule whose interval jumps past that day has no more occurrences.

Benchmarks

Mean times of pnpm bench:jsdom in packages/x-scheduler-internals-premium with TZ=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:

Scenario master This PR Speedup
System timezone, week 11.5 ms 4.52 ms 2.5×
System timezone, month 30.5 ms 10.6 ms 2.9×
System timezone, year 252 ms 72.9 ms 3.5×
New York data, Paris display, week 442 ms 9.99 ms 44×
New York data, Paris display, month 758 ms 42.3 ms 18×
New York data, Paris display, year 4.57 s 0.400 s 11×
COUNT series 10 years after DTSTART, week 54.2 ms 0.551 ms 98×
COUNT series 10 years after DTSTART, month 60.1 ms 1.30 ms 46×

Across timezones, most of the remaining time builds the TZDate values 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:

Edit master This PR Speedup
Update this and following 4.90 s 11.1 ms 440×
Delete only this 9.54 s 1.45 ms 6,600×
Delete this and following 4.60 s 3.10 ms 1,500×

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.

Entry master This PR Difference
event-calendar 131.06 kB 131.06 kB 0
event-calendar-premium 149.18 kB 153.64 kB +4.46 kB
event-timeline-premium 141.03 kB 145.57 kB +4.54 kB

On their own, the premium recurring-events code goes from 21.3 kB to 18.7 kB minified, and base-temporal and plain-date-utils add 14.7 kB, 5.1 kB gzipped. Copying BaseTemporal as is costs 2.5 kB gzipped more than a PlainDate reduced 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, setMonth and getDaysInMonth are gone. In the recurring-events folder, the count drops from 160 to 31. The expansion itself only reads the timestamps of its inputs with getTime, and the bridge with plain dates adds 6 calls.

Package master This PR Files with calls
x-scheduler 114 114 17
x-scheduler-premium 11 11 2
x-scheduler-internals 202 208 32
x-scheduler-internals-premium 302 173 22
Total 629 506 73

The remaining references fall into three kinds of work, which need different replacements:

Kind master This PR Methods and properties Replacement
Calendar math and comparisons 444 319 40 Plain dates and day numbers
Instants and timezones: date, now, setTimezone, getTimezone, toJsDate, isValid, getTime 106 108 7 Timestamps with the offsets of timeZone.ts
Formatting: format, formatByString, formats, escapedCharacters, getCurrentLocaleCode 79 79 5 Intl.DateTimeFormat, like BaseTemporal

The files with the most references are x-scheduler-internals/.../date-utils.ts with 38, the timeline presets in preset-utils.tsx with 35, the timeline header in iterate.ts with 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
Method or property master This PR Difference
getTime 47 51 +4
startOfDay 64 42 -22
addDays 49 36 -13
format 27 27 0
isBefore 38 26 -12
isSameDay 27 26 -1
endOfDay 29 24 -5
formatByString 24 24 0
formats 21 21 0
setTimezone 21 18 -3
addMilliseconds 16 17 +1
isAfter 22 16 -6
addMinutes 16 16 0
date 16 16 0
differenceInDays 19 15 -4
now 10 10 0
setHours 9 9 0
startOfMonth 14 8 -6
getDayOfWeek 15 7 -8
getDate 10 7 -3
isEqual 7 7 0
addMonths 11 6 -5
escapedCharacters 6 6 0
getMinutes 6 6 0
getTimezone 5 6 +1
addWeeks 9 5 -4
isValid 5 5 0
getHours 4 4 0
startOfYear 9 3 -6
addYears 8 3 -5
isWithinRange 7 3 -4
addHours 3 3 0
getMilliseconds 3 3 0
setMinutes 3 3 0
startOfWeek 5 2 -3
endOfMonth 2 2 0
endOfWeek 2 2 0
getSeconds 2 2 0
isSameMonth 2 2 0
setMilliseconds 2 2 0
setSeconds 2 2 0
toJsDate 2 2 0
getYear 1 2 +1
getMonth 6 1 -5
differenceInMonths 2 1 -1
differenceInWeeks 2 1 -1
differenceInYears 2 1 -1
differenceInHours 1 1 0
endOfYear 1 1 0
getCurrentLocaleCode 1 1 0
getWeekNumber 1 1 0
startOfHour 1 1 0
getDaysInMonth 5 0 -5
setDate 5 0 -5
setMonth 2 0 -2

Argos 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/tz loaded. TZDate builds its timezone-aware methods from the ones it finds on Date.prototype when it loads, and the fake Date has 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.tsx now loads the Scheduler adapter before the fake clock. TimezoneRecurringEvents, TimezoneInstantBased and TimezoneDatasetWallTime now 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.
  • A randomized harness, not committed, checked about 350,000 occurrences across 14 data and display timezones and 5 system timezones. Every instant matches an RFC 5545 oracle, and the days match the master engine wherever TZDate resolves them correctly.
  • BaseTemporal is tested in mui/base-ui-plus#336, where a contract test checks that PlainDate returns what the Temporal polyfill returns, or throws.
  • A third one built 21 million adapter values with 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.
  • A fourth one expanded 4,000 random series under 3 system timezones, with and without the copies of the display dates. The 107,000 occurrences of each run have the same values.
  • Ten review rounds with Codex and the base-ui-review skill. I fixed every finding, and the last round found none.
  • TypeScript, ESLint and Prettier pass. pnpm extract-error-codes adds the 25 errors of the BaseTemporal copy.

The offset cache went to BaseTemporal in mui/base-ui-plus#336, where it makes ZonedDateTime.from() and with() about twice as fast. So did PlainDate.add() and with(), which follow the native Temporal of Chrome and Firefox in every calendar. For this copy, mui/base-ui-plus#336 also made creating a PlainDate and add() 34% to 40% faster.

🤖 Generated with Claude Code

flaviendelangle and others added 9 commits October 20, 2026 14:03
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>
@code-infra-dashboard

code-infra-dashboard Bot commented Oct 20, 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 🔺+32B(+0.01%) 🔺+7B(+0.01%)
@mui/x-scheduler-premium 🔺+7.88KB(+1.35%) 🔺+3.92KB(+2.40%)
@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.

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>
@flaviendelangle flaviendelangle self-assigned this Oct 20, 2026
@flaviendelangle flaviendelangle added performance type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature. scope: scheduler Changes related to the scheduler. labels Oct 20, 2026
flaviendelangle and others added 9 commits October 20, 2026 15:45
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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@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 😆

@flaviendelangle
flaviendelangle marked this pull request as ready for review October 21, 2026 05:35
flaviendelangle and others added 3 commits October 21, 2026 10:58
- `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 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

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:500 passes occurrence.dataTimezone.start.value and only sends start/end if they were touched.
  • The store forwards that to updateRecurringEvent.
  • getOccurrenceEnd adds 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:00 and the PR saves 02:30 → 02:00, so end comes before start.

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 INTERVAL and COUNT, 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 object interval and the Math.ceil on count (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 byMonthDay in validateRule with 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.isInteger instead of Number.isNaN in the string check (rRuleString.ts:250). Today BYMONTHDAY=1.5 passes 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 share value have 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 createProcessedDate vs processDate test 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 RecurringSeries tests 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 getAdapterTimezoneOffsetMs as getOffsetMs; 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, createAdapterDate and TimezoneOffsetGetter are exported from the barrel with no consumer outside the module.
  • hasOccurrence and hasOccurrenceBefore are nearly the same. They differ only in the upper bound and the early return; one private helper taking to: PlainDate | null would 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.md doesn't say that INTERVAL (and COUNT, after point 2) must be a positive integer.
  • updateRecurringEvent.test.ts:510 still mentions mondayWeekDayNumber=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 branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance 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