Repository navigation
[scheduler] Fix the event dialog moving untouched dates across timezones - #23462
Conversation
The dialog built all-day bounds with the display timezone, so saving an all-day event viewed from another timezone — even a plain rename — shifted its stored day boundaries and stretched it across two data-zone days. computeRange now takes the all-day anchor timezone and the submit path passes the event's data timezone for edits; validation and the placeholder keep the display timezone, where ordering and rendering happen. Creations still anchor to the display timezone (the event has no timezone of its own yet). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDEgka4iDRFrenWeWFhunq
Deploy previewBundle size
Check out the code infra dashboard for more information about this PR. |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDEgka4iDRFrenWeWFhunq
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDEgka4iDRFrenWeWFhunq
The submit read the creation placeholder twice, and the data-timezone lookup inlined the processed-event walk; a `dataTimezone` selector now carries the lookup and the 'default' fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDEgka4iDRFrenWeWFhunq
The form seeds its date fields from the display-timezone bounds, so resending them untouched re-anchors the event to the display zone: viewed from another timezone, an all-day event moved to a different day on a mere rename. Anchoring the submit in the data timezone (previous approach) could not fix this — it faithfully persisted the display day the form was seeded with. Instead, leave start/end out of the update when none of the range fields is dirty. This also keeps an untouched range out of the pattern-based recurring update, where a display-zone start could shift a weekly series' BYDAY. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDEgka4iDRFrenWeWFhunq
…e2e pin A time typed while all-day was toggled off stayed dirty after toggling it back on, re-arming the range resend and reintroducing the cross-timezone day shift. Only the keys the submitted range actually reads count now. Also: RANGE_FORM_KEYS moves next to computeRange as the single copy of the list (the placeholder keys derive from it), getDirtyValues drops its now-unused excludeKeys parameter, the recurring rename test completes the scope flow on a cross-timezone weekly series, the touch-resize test pins the seeded end time, and a test pins the whole-displayed-range semantics of a partial edit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDEgka4iDRFrenWeWFhunq
The recurring scope paths (update, delete, drag) identified the occurrence by its display-timezone start. For an all-day occurrence viewed from another timezone those bounds are day-normalized to a different instant, so the exception date and the detached event landed on the wrong data-zone day. The occurrence's own data-timezone start is the identity the expansion keys on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDEgka4iDRFrenWeWFhunq
The data-timezone bounds are now the occurrence identity for recurring scope operations, so every holder must maintain them: - setEditingOccurrenceTimes and repointEditingOccurrence sync dataTimezone alongside the display bounds, so a follow-up edit or delete of the armed occurrence targets the post-resize/post-move occurrence. - A rename-only scope change (no start/end in the changes) still re-points the armed occurrence onto the moved-to event, keeping its current bounds. - The grid event primitives take dataStart/dataEnd so drag data carries the real occurrence identity instead of one fabricated from the display segment bounds. - The 'dataTimezone' in occurrence checks share an isEventOccurrence guard, the RangeFormKey type derives from RANGE_FORM_KEYS, and the occurrenceStart parameters document the data-timezone contract. New coverage: store-level identity-sync and rename-repoint tests, cross-timezone delete pins for the dialog, context menu, and toolbar, a scope-'all' edited-day pin, the all-day-to-timed conversion, and generateOccurrenceFromEvent units. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDEgka4iDRFrenWeWFhunq
The rename fallback fed the display bounds into the data-timezone sync, so a rename-only scope change on a cross-timezone all-day armed occurrence relabeled its identity onto the display day — the repoint keyed off the wrong day and a follow-up delete excluded it. The fallback now threads the occurrence's own data bounds through setEditingOccurrenceTimes and repointEditingOccurrence, which accept explicit data bounds and only fall back to the instant-preserving relabel for genuine time changes. Also: an end-to-end cross-timezone drag test discriminates the dataStart threading through the grid primitives, the scope-'all' pin asserts the end day and the BYDAY, the exDate asserts parse through the adapter, the wrappers compute the data bounds once, and the scope-sync comments say 'change' instead of 'resize' now that renames flow through too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDEgka4iDRFrenWeWFhunq
|
This pull request has conflicts, please resolve those before we can evaluate the pull request. |
…-data-timezone # Conflicts: # packages/x-scheduler/src/internals/components/event-dialog/EventDialog.test.tsx
…he pattern math Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CC6LfHbZXD549R8qtmQojJ
…-'all' update Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CC6LfHbZXD549R8qtmQojJ
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CC6LfHbZXD549R8qtmQojJ
…elete Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CC6LfHbZXD549R8qtmQojJ
…ilder Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CC6LfHbZXD549R8qtmQojJ
…e drag, and the pending-validation untouched range Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CC6LfHbZXD549R8qtmQojJ
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CC6LfHbZXD549R8qtmQojJ
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CC6LfHbZXD549R8qtmQojJ
flaviendelangle
left a comment
There was a problem hiding this comment.
PR review
One merge-blocking bug confirmed. The existing targeted suite passed with 582 tests and 9 skipped; an added dialog reproduction fails.
Bugs (1)
1. 🔴 Preserve untouched data-timezone bounds after resizing
Location: SchedulerStore.ts:1120
start: processDate(
adapter.setTimezone(start, occurrence.dataTimezone.timezone),
adapter,
),useDropTarget omits unchanged bounds from the update, but subsequently passes both display bounds to setEditingOccurrenceTimes. This new code overwrites the untouched data-timezone start with the normalized display-day start.
Failure scenario: Select a non-recurring all-day event on July 4 in UTC, displayed in New York, and resize only its end. The stored start remains July 4, but the selected occurrence’s start becomes July 3 at 04:00 UTC. Open the editor and add weekly recurrence: the confirmed dialog reproduction saves byDay: ['TH'] instead of ['FR'].
Fix: Preserve the original data-timezone value for each unchanged bound when refreshing the selected occurrence. Convert only committed changed bounds, including any scheduling adjustments. Add an end-only, cross-timezone all-day resize regression test.
Tests (0)
No findings.
Simplifications (0)
No findings.
Docs (0)
No findings.
Verdict
Request changes — an end-only resize can cause subsequent recurrence editing to save the wrong weekday.
🤖 Review generated with Codex
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Fixed in df086e5. |
flaviendelangle
left a comment
There was a problem hiding this comment.
PR review
Merge-blocking findings: two new regressions and two existing bugs left unresolved in the refactored paths. Targeted reproductions confirmed all four; 659 existing tests passed, with 9 skipped.
Bugs (4)
1. 🔴 All-day-to-timed drops can save an inverted range
Location: useDropTarget.ts:237
if (!adapter.isEqual(originalOccurrence.displayTimezone.start.value, start)) {
changes.start = start;
}This comparison ignores changes to allDay. An all-day event's normalized display start can equal the drop target while differing from its stored start.
Failure scenario: An all-day event starts July 4 at midnight UTC and displays from July 3 at midnight in New York. Dragging it into the July 3 timed grid at midnight omits start, sets allDay: false, and saves an end of 01:00. Its retained start is 20:00 that evening, after its end.
Fix: Submit both placeholder bounds whenever the drop changes allDay.
2. 🔴 Partial date submission can invert an event during DST fall-back
Location: FormContent.tsx:486
...(submitStart ? { start } : {}),
...(submitEnd ? { end } : {}),Validation reconstructs both dates from the form, but submission now preserves untouched stored bounds. During a repeated hour, the reconstructed start can represent a different instant from the preserved start.
Failure scenario: In New York, an event starts at 2025-11-02T06:30Z, the second 01:30, and ends at 02:30. Change only the end to 01:45. Validation accepts the reconstructed first 01:30–01:45 interval, but saving retains 06:30Z and writes an end of 05:45Z.
Fix: Validate the actual merged range, including preserved bounds, before submitting it.
3. 🔴 External date updates are mistaken for pending resizes
Location: FormContent.tsx:407
occurrence.dataTimezone[bound].timestamp !== liveEvent.dataTimezone[bound].timestamp;boundPending treats any difference between the editing snapshot and live event as an unpersisted resize. It does not establish that a local resize occurred.
Failure scenario: Open an event ending at 08:15. The host updates its end to 09:00 while the dialog remains open. Rename the event and save. The dialog resubmits 08:15 and overwrites the newer date.
This is an incomplete fix of the new untouched-bound contract; the previous unconditional submission also overwrote the date.
Fix: Track actual pending local date changes and only resend those. Otherwise retain the live event's untouched bounds.
4. 🔴 Deleting a pending split series crashes on scope confirmation
Location: SchedulerStore.ts:705
liveEvent != null
? liveEvent.dataTimezone.rrule != null
: occurrence.dataTimezone.rrule != nullThe new snapshot fallback opens the recurring scope dialog when the event is absent from the store. Confirmation still calls processedEventRequired at line 743 and throws.
Failure scenario: Resize with “This and following events,” then delete the selected new series before dataSource.persistEvents resolves. Confirming a deletion scope throws because the new series is not loaded yet.
This race existed before the refactor. The new fallback explicitly handles this state but leaves confirmation broken; its test only checks that the dialog opens.
Fix: Defer subsequent mutations until persistence completes, or stage the created event and serialize pending operations.
Tests (0)
No findings.
Simplifications (0)
No findings.
Docs (0)
No findings.
Verdict
Request changes — ordinary edits can persist invalid date ranges, and pending-series deletion can throw.
🤖 Review generated with Codex
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… into scheduler-all-day-data-timezone
|
1 and 2 fixed in 1943ddb. A drop that toggles all-day now resends both bounds, since the stored ones belong to the other representation. The submit checks now run on the range the save writes, so an untouched bound is validated on its stored instant. 3 and 4 are not regressions of this PR. The dialog always resent both dates before, so an external update while the dialog is open was overwritten too, as the title or resource still are. Telling a pending local resize from an external update needs the lazy-loading plugin to track in-flight writes, which it does not do today. Scope confirmation already went through |
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Rethought 3 and 4, both in 0b8c40f after all. 3: the editing state now records the stored model's bounds when a committed change refreshes the snapshot. The dialog resends a bound only while the model still holds what it did at the resize; a bound the host moved since is kept. 4: an action on an event the store does not hold yet (a split still being persisted, or one that left the loaded range) no longer opens the scope dialog nor throws on confirmation. It surfaces a transient error, the same way a scheduling veto does, and the user retries once the write lands. Serializing pending operations stays out of scope. |
|
The not-loaded-event message is hardcoded in English like the scheduling veto; making both translatable needs a small mechanism in the store and |
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
flaviendelangle
left a comment
There was a problem hiding this comment.
PR review
One merge-blocking validation regression confirmed with a targeted test. Existing targeted suites passed: 575 tests, 9 skipped. Browser tests were not run.
Bugs (1)
1. 🔴 Validate untouched bounds against the live event
Location: FormContent.tsx:416–418
const submittedStart = submitStart ? start : occurrence.displayTimezone.start.value;
const submittedEnd = submitEnd ? end : occurrence.displayTimezone.end.value;Untouched bounds are now omitted from the update, preserving the live model’s values. Validation still uses the dialog’s occurrence snapshot, so it can approve a different range from the one saved.
Failure scenario: Open a 10:00–11:00 event. While the dialog remains open, the host changes its end to 10:15. Change the start to 10:30 and save. The reproduced result calls onEventsChange with start 10:30 and end 10:15.
Fix: Resolve omitted bounds from the live event for non-recurring updates, and validate the resulting range. Add a regression test combining a host update with a conflicting date edit.
Tests (0)
No findings.
Simplifications (0)
No findings.
Docs (0)
No findings.
Verdict
Request changes — saving can emit an event whose end precedes its start.
🤖 Review generated with Codex
|
This pull request has conflicts, please resolve those before we can evaluate the pull request. |
…-data-timezone # Conflicts: # packages/x-scheduler-internals/src/internals/utils/event-utils.test.ts # packages/x-scheduler-internals/src/internals/utils/event-utils.ts
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Fixed in 08a4293. The submit checks resolve an untouched bound from the live event on a non-recurring update, which is what the save keeps, so a bound the host moved while the dialog is open is validated against. Regression test with a host move plus a conflicting date edit. |
|
This pull request has conflicts, please resolve those before we can evaluate the pull request. |
…-data-timezone # Conflicts: # packages/x-scheduler/src/internals/components/event-editing/ReadonlyEventDetails.tsx
Editing an event from another timezone breaks it in three ways.
How to reproduce
Take an all-day event stored as
2025-07-04T00:00:00Z→2025-07-04T23:59:59Z(a UTC "July 4th") and show it withdisplayTimezone="America/New_York". New York is behind UTC, so the calendar shows it on July 3rd.2025-07-03T04:00:00Z→2025-07-04T03:59:59Z. It moved one day back, and in UTC it now spans two days.BYDAY=TH(the day shown in New York), but the series lives on Fridays in UTC, so the next occurrences land on Wednesday evenings. The same happens with the monthly presets ("on the 3rd", "first Thursday"), with the "Until" date, and when creating a new event.Why it happens
The same instant is a different day depending on the timezone you ask in.
start/endback even when the user did not touch them. Those days were then read as New York days, which is a different instant from what was stored.What changes
The dialog only sends what the user edited.
startandendare sent only when the user changed a date or time field (the time fields only count while the event is not all-day). An untouched bound keeps its stored value byte for byte. On the recurring path each bound is checked on its own, so changing only the end date no longer moves the series start.allDayresends both bounds; if the display timezone changed while the dialog was open, editing one bound resends both (the range was validated as a pair in the new timezone); with adataSource, a resize made on the calendar and still being persisted is resent, or the next update would rebuild it from the stale model. The selection records the stored model's bounds when a committed change refreshes it, so a resize awaiting persistence is told apart from a bound the host moved while the dialog was open: the host's value is kept.The recurrence rule is read and written in the event's timezone.
UNTILday are built on the bounds the event ends up with, in the event's timezone: the edited bound when the save resends it (the tab and the save share the predicate), the occurrence's own otherwise, and thedefaulttimezone when creating an event. A picked preset re-anchors its weekday or day of month when the start moves; a custom rule is kept as picked. Enabling "Until" pre-fills the end of the event's last day in its own timezone, and the day typed there is read in that timezone too. The rule is then stored as is by edits, recurring scope changes and creation.Intl.DateTimeFormat(timeZoneName: 'longGeneric'), falling back to the identifier when the runtime only has an offset for it (UTC); aliases of the same zone (US/Eastern,America/New_York) count as the same. The weekday token behind the ordinal aria-labels now reads the event's timezone instead of the browser's.displayTimezone.rruleis removed from the processed event and occurrence types; readoccurrence.dataTimezone.rruleinstead (behindisEventOccurrencefor creation drafts).projectRRuleToTimezoneis removed from the recurring-events plugin andapplyDataTimezoneToEventUpdateonly relabels the bounds and the exception dates. NewrecurrenceTimezoneLabelandrecurrenceLabelTimezoneSuffixlocale keys andeventDialogRecurrenceTimezoneLabelclass.Recurring occurrences are identified by their start in the event's timezone.
occurrence.dataTimezone.startnow. A drop only sends the bounds it moved: an untouched bound re-read from its display value can be another day in the event's timezone, which a recurring update would take for a day move. A drop that toggles all-day resends both, since the stored bounds belong to the other representation.dataTimezoneas a prop (getOccurrenceDataTimezone,undefinedfor placeholders) and build the drag occurrence with a shareduseOriginalOccurrencehook, so the drop knows which occurrence it started from.The selected occurrence stays correct after a scope change.
deleteOccurrencestore method: recurring occurrences go to the scope dialog, the rest are deleted right away. It reads the rule from the store. An event the store does not hold yet (a split still being persisted through thedataSource, or one that left the loaded range) is neither deleted nor sent to the scope dialog: a transient error is surfaced, the same way a scheduling veto is, and confirming a scope whose event has gone is dropped with the same error instead of throwing.Tests (all red/green)
dataSource, a bound the host moved while the dialog is open is kept, and an edit conflicting with it is rejected; an untouched start in a repeated DST hour is validated on its stored instant; immediate delete.UNTILpre-filled with the event's last day (untouched, edited timed end, edited all-day end), read back from Tokyo on the stored day, and stored at the end of the typed day in the event's timezone; the timezone label in the tab and in the read-only details, and its absence when both timezones match; unit tests ongetRecurrenceRuleBound,getResentRangeBounds,getWeekdayTokenand the timezone name (localized,default/systemresolved to the system timezone, identifier fallback, aliases).deleteOccurrenceon both branches and with a selection that disagrees with the store; a delete or a scope confirmation on an event the store does not hold is rejected with the transient error.BYMONTHDAY.getEditedRangeBounds,getOccurrenceDataTimezone.Depends on #23467 (exDates keyed in the wrong zone, now merged), which this review surfaced. Merging it also exposed a test-builder issue fixed here:
EventBuildernow serializes its dates (includingtoOccurrence's round-tripped model) as UTC instants, since a date labeled with the event's timezone stringifies with an offset suffix thatresolveEventDatereads as wall time.