Skip to content

Commit f9b6dfe

Browse files
fix(plugin-calendar): a month-grid move keeps the wall-clock time across a DST change (objectui#11005) (#11036)
Fixes #11005 Clause-②: no Ruling `5881826705` (letter B), PM claim `5882077455`, branch `claude/issue-11005-month-grid-calendar-days`, dispatched from session `https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec`. The month grid now moves a value by calendar days and keeps its wall-clock time. This holds for every value, for a move and for a drag of the span's end. ## What changes 1. **`CalendarView`, `MonthView.handleDrop`.** The move used to count `deltaMs`, the milliseconds between the grab cell's local midnight and the drop cell's, and add it to the start and the end. It now counts the calendar days between the two cells from their local year, month and day (`calendarDaysBetween`). The difference is taken on the UTC calendar, so it is a whole number with no rounding. It then moves a local copy of the start and of the end with `setDate` (`moveByCalendarDays`), leaving hours, minutes, seconds and milliseconds untouched. The drag of a span's end (`resize-end`) moves the end the same way, and its guards still compare day cells. 2. **`ObjectCalendar`: the objectui#10866 slice-4 day repair is removed** (`toMovedDateValue`, `isStoredDay`, `DAY_MS`). It was measured redundant (see below). The default drop handler writes each field through `toStoredDateValue` again, as it did before that slice: a `date` field as the local day of the value the grid hands back, and a `datetime` field as its instant. 3. **Pins.** A new zone pin, `CalendarView.monthMoveCalendarDays-11005`, sits at the grid. It covers Los Angeles and Shanghai, plus two suite-zone cases, and is registered in the driver's `PINS` (`scripts/__tests__/date-only-zone-pins-10183.test.ts`). In `ObjectCalendar.dstDayMove-10866`: - The four `datetime` controls are renamed from "keeps the grid's instants" to "keeps its wall-clock time". - The first Los Angeles control's end moves from `2026-11-02T17:00:00.000Z` (09:00 PST) to `2026-11-02T18:00:00.000Z` (10:00 PST). This is the ruled instant change. - Its header prose says what changed. Its ten `date` day moves are untouched. 4. **Docs.** The plugin README's month-view drag table and the two month-view bullets in `content/docs/plugins/plugin-calendar.mdx` now say that a move and an end drag keep the time of day (AGENTS.md commandment #2). 5. **Changesets.** `.changeset/11005-month-grid-calendar-days.md` declares `@object-ui/plugin-calendar: patch`. The pending slice-4 changeset is reconciled in the dated-note form (see mechanism assumption 4). ## The ruled zone pins These run under `TZ=America/Los_Angeles`, where the clocks fall back on November 1st and spring forward on March 8th, 2026, and under `TZ=Asia/Shanghai`. Base is `a33cf7e`. | Ruled pin | Where | On base | On head | |:--|:--|:--|:--| | A datetime moved across a DST change keeps its wall-clock time | New pin, four Los Angeles moves | A 10:00 event on Nov 1 moved two days wrote 11:00. One moved back onto Nov 1 wrote 09:00. A 10:00 event on Mar 8 moved two days wrote 09:00. A span whose end crosses Nov 1 wrote its end at 09:00. | 10:00 each | | A local-midnight instant lands on the dropped day | New pin, two Los Angeles spans | The card's case: a start of `2026-11-01T07:00:00.000Z`, grabbed on Nov 2 and dropped on Nov 3, wrote `2026-11-02T07:00:00.000Z` (Nov 1, 23:00). A span ending at midnight on Mar 9, moved back one day, started at `2026-03-06T09:00:00.000Z` (01:00). | Midnight of the dropped day | | A date-only value is unchanged | Slice 4's ten day moves through `ObjectCalendar`, Los Angeles and Shanghai | Written by the repair | The same ten days, with the repair removed | | A zone without DST behaves as before | New pin, four Shanghai drags | Exactly N times 24 hours | The same instants | | `resize-end` follows the same rule | New pin, two Los Angeles end drags | Hours, minutes and seconds already kept; milliseconds dropped (`19:30:15.000Z` for `19:30:15.250Z`) | The whole time of day | ## Mechanism assumptions (Zone 2), measured 1. **Partly held.** The move derived one `deltaMs` from local-midnight cells. `resize-end` did not: it set the drop day's clock from the old end with `setHours(h, m, s, 0)`, so it already kept hours, minutes and seconds across a DST change. On base, the March 8th end drag passed and the November 2nd one differed only in milliseconds. The resize guards compare day cells, as assumed. 2. **Held: the week and day views do not ride.** The time grid places a moved event at its snapped minutes on the drop day's own clock (`setHours(0, snapped)` on the drop column's day). A throwaway probe in a forked child under Los Angeles (not committed) moved a 10:00 event off November 1st and onto it in the week view. It wrote 10:00 both ways: `2026-11-03T18:00:00.000Z` and `2026-11-01T18:00:00.000Z`. 3. **Held: the repair is redundant, and B is what makes it so.** With B in the grid and the repair removed, slice 4's ten day moves pass in both zones. Ablation: put the base grid back under the committed head (repair still removed), and four of those ten go red in Los Angeles. They are exactly the four that slice 4 named (see Evidence). 4. **The named file does not describe the repair.** `.changeset/10866-calendar-gantt-date-only-days.md` (slice 1) does not describe the repair. Its calendar sentence "A `datetime` field keeps its instant" is about the stored type, so it stays true under the ruling's own reading, and the file is untouched. The repair is described in slice 4's `.changeset/10866-calendar-dst-day-moves.md`. Two phrases there go false at this change: "The month grid moves each date…" and "…the grid's own arithmetic does not change". Both are now scoped "at this change", followed by a dated note naming this card. The front matter is byte-identical (the md5 of the first six lines matches the base). Its gantt and core bullets are untouched. `check-changeset-overwrite` reports the modification (report-only, exit 0), and that is expected. ## Evidence All counts are read from vitest's own summary lines. - **New pin, red on base.** Before the fix, in a forked child with `OBJECTUI_DATE_ZONE_CHILD=1`: `Tests 8 failed | 9 passed (17)`. The eight red cases: - the six Los Angeles moves; - the Los Angeles November end drag (milliseconds only); - the suite-zone end drag (milliseconds only). The Los Angeles March end drag, the rigs, the fixture case and every Shanghai case passed on base. - **Head, all calendar zone pins driven.** `CalendarView.monthMoveCalendarDays-11005`, `ObjectCalendar.dstDayMove-10866`, `ObjectCalendar.endDateNoSchemaZone-10866`, `ObjectCalendar.dateOnlyZone-10866` and `calendarViewRenderer.dateOnlyZone-10866`, forked with the flag: `Test Files 5 passed (5)`, `Tests 104 passed (104)`. - **Ablation.** Run on committed head `55d7add`. The base blob of `CalendarView.tsx` was checked out on its own, with `ObjectCalendar.tsx` staying at head (repair removed). - Landing proof: after the mutation the file hashed to the base blob `0fbcb72b`, and the `deltaMs` count went from 0 to 4 while the `moveByCalendarDays` count went from 5 to 0. - Result, forked, driven: `Tests 13 failed | 32 passed (45)`. The red cases are the eight new-pin cases, slice 4's four Los Angeles day moves (Oct 29 to 31 moved two days, Nov 1 to 3 grabbed on the 2nd, Oct 31 to Nov 2 moved back one, Mar 5 to 9 moved back one), and the first Los Angeles `datetime` control. - Restore, from a `trap` on EXIT/INT/TERM with absolute paths: `git checkout HEAD --` brought the file back to the HEAD blob `29701d6f`, and `git diff HEAD` came back empty. - **Package suite at `600e84c`.** `pnpm exec vitest run packages/plugin-calendar/`: `Test Files 55 passed (55)`, `Tests 365 passed | 85 skipped (450)`. The skipped cases are zone pins, which run in the driver's child. - **Zone-pin driver at `600e84c`.** `scripts/__tests__/date-only-zone-pins-10183.test.ts`: `Tests 24 passed (24)`, which is every registered pin plus the child-exit case. - **Tests outside the package that name an edited file**, derived with `git grep` over test files for the edited paths and symbols. The zone pins are excluded because the driver ran them. 14 files: `Test Files 14 passed (14)`, `Tests 1067 passed (1067)`. The 16 test files that read `.changeset/`: `Test Files 16 passed (16)`, `Tests 618 passed (618)`. - **Type-check.** The dependency closure was built with `turbo run build --filter='@object-ui/plugin-calendar^...' --concurrency=2`: 13 tasks, all restored from turbo's shared worktree cache, none of them in this diff. Then `pnpm --filter @object-ui/plugin-calendar type-check`, which echoed `tsc --noEmit && tsc -p tsconfig.test.json`, exited 0. `tsc -p tsconfig.test.json --listFiles` lists both edited pins. - **Gates, all exit 0:** - `check-changeset-presence`: "4 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)". - `check-changeset-no-major`, `-fixed`, `-overwrite` (report-only, see above) and `-claims` (report-only). For `-claims`, five pending changesets name `ObjectCalendar.tsx`, the README or the plugin page. Each was re-read: they concern the record source, `filter`, `sort` and the colour and all-day fields, and none is falsified. - `check-pending-changeset-literals`, `check-control-bytes` and `check-new-cross-file-line-citations` ("0 new citation(s)"). - `check-test-path-roots`, the three `check-vi-mock-*` gates and `check-unreferenced-sources`. - `check-doc-fence-languages`, `check-doc-component-types` and `check-doc-example-ids`. - `check-governed-queue-guard --test` over the nine paths: NOT GOVERNED. - **NOT MEASURED: `check-doc-snippet-types` (exit 2), `check-doc-example-types` (exit 2) and `check-readme-exports` (exit 1).** All three stopped on their prerequisite: packages outside this closure are unbuilt, reported as `[unbuilt-package]` and "not on disk — run the build first". That means the runs never reached a verdict, not that the gates failed. The README and doc edits here change table rows and bullets only, with no fenced block. These gates belong to CI. - **Lint, narrowed and declared as a narrowing.** - Population: the root config lints `**/*.{ts,tsx}`, and this diff has five such files. - Count: `eslint --no-inline-config --format json` returned 5 results with no "ignored" message: 0 errors in total. Warnings equal each modified file's base blob (`CalendarView.tsx` 24, `ObjectCalendar.tsx` 31), and the three test files have 0. - Invariance: `eslint.config.js` sets no `parserOptions`, `project` or `projectService`, so linting is not type-aware and this diff cannot move the verdict for any untouched file. Repo-wide lint is CI's. ## Acceptance notes The dev records these here and files nothing; the seat decides. - **A week or day view move of an overnight timed event shortens it** (class (a): reproduced, pre-existing, unrelated to DST). - The time grid clips a timed event into one piece per day it touches. A move takes the grabbed piece's duration (`durationMin = entry.endMin - entry.startMin`), not the event's. - Measured with a throwaway jsdom probe (not committed): a 4-hour event from January 12th 22:00 to January 13th 02:00, dragged by its first-day piece to January 14th at 10:00, reached `onEventDrop` as 10:00 to 12:00, which is 120 minutes. `ObjectCalendar`'s default drop handler writes the `newEnd` it is handed (read, not driven through an app). - Reach: the calendar's week and day view drag. - Dedupe words: `week view drag overnight event duration`, `TimeGridView move durationMin clipped piece`, `calendar time grid move shortens event`. - **objectui#10866 slice 5, which is serial behind this card.** Its `ObjectCalendar` mixed-event half (a `date` start with a `datetime` end) was not measured here. With the grid moving by calendar days, a `date` start now comes back at local midnight of the dropped day. Re-price that half against this head. - **Observation, not measured, and out of this card's scope.** When a gantt summary bar is dragged, `GanttView` shifts it and its descendants by one `deltaMs` (the group move and its live preview). Its axis is continuous time, so it is not known to be the same defect. It is not touched here. Carrier: none. Commits carry only the model-free trailer pair. --- _Generated by [Claude Code](https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3ad6100 commit f9b6dfe

9 files changed

Lines changed: 411 additions & 95 deletions

‎.changeset/10866-calendar-dst-day-moves.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
fix(plugin-calendar): a day event moved across a DST change writes the days it was dropped on (objectui#10866, slice 4)
99

10-
- **plugin-calendar.** The month grid moves each date of an event by the milliseconds between two local midnights, the grabbed cell's and the drop cell's, and a date-only value is read at local midnight of its day. So when a DST change lay between one of an event's dates and where it landed, but not between the two cells (or the other way round), that date came back an hour off local midnight; at 23:00 of the day before, the write took the day before. Under `America/Los_Angeles` a span of `date` fields moved across November 1st or March 8th could be written a day short at its start or its end. A move of an event whose start, and end when it has one, are both stored calendar days now writes each `date` field as its stored day moved by the whole days the grid moved it. Every other write is unchanged: a `datetime` field still writes the instant the grid hands back, and the grid's own arithmetic does not change.
10+
- **plugin-calendar.** At this change the month grid moved each date of an event by the milliseconds between two local midnights, the grabbed cell's and the drop cell's, and a date-only value is read at local midnight of its day. So when a DST change lay between one of an event's dates and where it landed, but not between the two cells (or the other way round), that date came back an hour off local midnight; at 23:00 of the day before, the write took the day before. Under `America/Los_Angeles` a span of `date` fields moved across November 1st or March 8th could be written a day short at its start or its end. A move of an event whose start, and end when it has one, are both stored calendar days now writes each `date` field as its stored day moved by the whole days the grid moved it. Every other write is unchanged: a `datetime` field still writes the instant the grid hands back, and at this change the grid's own arithmetic does not change.
11+
12+
⚠️ **Dated note, 2026-09-29 — the month grid now moves by calendar days — objectui#11005.** Later in this same release the month grid moves a value by the calendar days between the grabbed cell and the drop cell and keeps its time of day, for a move and for a drag of a span's end, so a 10:00 `datetime` stays at 10:00 across a DST change. A `date` value read at local midnight of its day then comes back at local midnight of the day it was dropped on, so the repair described above is removed and a moved `date` field is written as that local day, which is the same day the repair wrote. The rest of this entry is kept as the reading of this change; the objectui#11005 entry states what ships.
1113
- **plugin-gantt, types.** The business `timeZone` prose (`GanttConfig.timeZone`, `GanttViewProps.timeZone` and `makeTzShift`) said persisted data stays real instants. A `date` field under a zoned chart has been written as a calendar day since this card's first slice; the prose now says so, and says that near a DST change of the chart's zone or the viewer's the shim can place a day's midnight an hour early, onto the day before. Documentation only.
1214
- **core.** The `DATEADD` / `DATEDIFF` / `DATEFORMAT` docblock names how a mixed `DATEDIFF`, one day and one instant, reads each argument when it counts months or years. Comment only.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/plugin-calendar': patch
3+
---
4+
5+
fix(plugin-calendar): a month-grid move keeps the wall-clock time across a DST change (objectui#11005)
6+
7+
The month grid moved an event by the milliseconds between the grabbed cell's local midnight and the drop cell's. Across a DST change that span is 23 or 25 hours. Under `America/Los_Angeles`, a 10:00 event moved onto or off November 1st or March 8th landed at 09:00 or 11:00. A span starting at local midnight, grabbed on a later day and moved, started at 23:00 of the day before the one it was dropped on. The calendar's own month-view quick-create writes such local-midnight instants.
8+
9+
- **Move.** The grid now counts the calendar days between the grabbed cell and the drop cell and moves the start and the end by that many days on the local calendar. Each keeps its hours, minutes, seconds and milliseconds, so a 10:00 event stays at 10:00 on the day it is dropped on, whatever DST change lies between. This deliberately changes the instant a drag writes into a `datetime` field; what a `datetime` field stores and how it is read are unchanged. In a zone without DST every day is 24 hours, so a move writes what it wrote before.
10+
- **Drag of a span's end.** The right-edge handle moves the end by calendar days the same way. It already kept the end's hours, minutes and seconds; it now keeps its milliseconds too.
11+
- **`date` fields.** A `date` value is read at local midnight of its day, so it now comes back at local midnight of the day it was dropped on, and `ObjectCalendar` writes that local day. The `ObjectCalendar` repair from objectui#10866, which moved a day event's stored day by the whole days the grid moved it, is removed: every day move it pinned writes the same day without it.
12+
- The week and day views are unchanged. Their time grid places a moved event at its snapped time on the drop day's own clock, not by a day delta.

‎content/docs/plugins/plugin-calendar.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ Both components support month, week, and day views with full event management ca
2828

2929
### Month view
3030

31-
- **Move an event** — grab any cell of the event pill and drop it on another day. The grabbed-day → drop-day distance is the day delta applied to both `startDateField` and `endDateField`, so dragging a multi-day span from any of its days behaves intuitively.
32-
- **Resize an event** — grab the small right-edge handle on the last day of a multi-day pill and drop it on a different day to extend or shrink the end date. The start date is preserved; drops earlier than the start are ignored.
31+
- **Move an event** — grab any cell of the event pill and drop it on another day. The calendar days from the grabbed day to the drop day move both `startDateField` and `endDateField`, so dragging a multi-day span from any of its days behaves intuitively. Each value keeps its time of day: a 10:00 event stays at 10:00, even across a DST change.
32+
- **Resize an event** — grab the small right-edge handle on the last day of a multi-day pill and drop it on a different day to extend or shrink the end date. The end keeps its time of day, and the start date is preserved; drops earlier than the start are ignored.
3333

3434
### Week / Day view (time grid)
3535

‎packages/plugin-calendar/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ Calendar view plugins for Object UI - includes both ObjectQL-integrated and stan
2828

2929
| Gesture | Effect |
3030
| --- | --- |
31-
| Drag the event pill body to another day cell | Shifts both `startDateField` and `endDateField` by the day delta. Grab cell → drop cell defines the delta, so dragging from any day of a multi-day span works as expected. |
32-
| Drag the right-edge handle of a multi-day pill | Adjusts only `endDateField`; start is preserved. Refuses drops earlier than start. |
31+
| Drag the event pill body to another day cell | Moves both `startDateField` and `endDateField` by the calendar days from the grabbed cell to the drop cell, each keeping its time of day: a 10:00 event stays at 10:00, even across a DST change. Grab cell → drop cell defines the days, so dragging from any day of a multi-day span works as expected. |
32+
| Drag the right-edge handle of a multi-day pill | Moves only `endDateField` to the drop day, keeping its time of day; start is preserved. Refuses drops earlier than start. |
3333

3434
### Week / Day view (time grid)
3535

‎packages/plugin-calendar/src/CalendarView.tsx‎

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,33 @@ function getEventsForDate(date: Date, events: CalendarViewEvent[]): CalendarView
502502
})
503503
}
504504

505+
/**
506+
* The calendar days from `from`'s local day to `to`'s: the difference of their
507+
* local year / month / day, counted on the UTC calendar, where every day is
508+
* exactly 24 hours, so the quotient is a whole number. ⛔ Never the
509+
* milliseconds between two local midnights over a day's length: across a DST
510+
* change that span is 23 or 25 hours (objectui#11005).
511+
*/
512+
function calendarDaysBetween(from: Date, to: Date): number {
513+
return (
514+
(Date.UTC(to.getFullYear(), to.getMonth(), to.getDate()) -
515+
Date.UTC(from.getFullYear(), from.getMonth(), from.getDate())) /
516+
86_400_000
517+
)
518+
}
519+
520+
/**
521+
* `value` moved by `days` calendar days on the local calendar: a copy whose
522+
* date moves with `setDate`, its hours, minutes, seconds and milliseconds
523+
* untouched, so a 10:00 event stays at 10:00 across a DST change and a value
524+
* at local midnight stays at midnight of the day it lands on (objectui#11005).
525+
*/
526+
function moveByCalendarDays(value: Date, days: number): Date {
527+
const moved = new Date(value)
528+
moved.setDate(moved.getDate() + days)
529+
return moved
530+
}
531+
505532
interface MonthViewProps {
506533
date: Date
507534
events: CalendarViewEvent[]
@@ -567,7 +594,9 @@ function MonthView({ date, events, locale = "default", onEventClick, onDateClick
567594
// Drag mode: "move" shifts the entire event so the day cell the user
568595
// grabbed lands on the drop-target day. "resize-end" only adjusts the
569596
// event's end date — used by the right-edge resize handle on multi-day
570-
// spans. The mode and the source cell are encoded in the dataTransfer
597+
// spans. Both move a value by calendar days and keep its time of day
598+
// (`moveByCalendarDays`, objectui#11005). The mode and the source cell
599+
// are encoded in the dataTransfer
571600
// payload so the drop target doesn't need any DOM state to interpret the
572601
// gesture, and so dragging from a *continuation* day of a multi-day
573602
// event still results in a sensible delta.
@@ -640,42 +669,47 @@ function MonthView({ date, events, locale = "default", onEventClick, onDateClick
640669
newTargetDay.setHours(0, 0, 0, 0)
641670

642671
if (payload.mode === "resize-end") {
643-
// Resize: snap the end date to the drop-target day, keep start fixed.
672+
// Resize: move the end to the drop-target day, keep start fixed. The
673+
// handle sits on the span's end cell, so the grabbed cell is the end's
674+
// own day, and the end moves by the calendar days from it to the drop
675+
// cell with its time of day untouched (objectui#11005).
644676
const oldEnd = draggedEvent.end ? new Date(draggedEvent.end) : new Date(draggedEvent.start)
645-
const oldEndDay = new Date(oldEnd)
646-
oldEndDay.setHours(0, 0, 0, 0)
647-
if (newTargetDay.getTime() === oldEndDay.getTime()) return
677+
const days = calendarDaysBetween(oldEnd, newTargetDay)
678+
if (days === 0) return
648679
// Guard: new end can't precede start day.
649680
const startDay = new Date(draggedEvent.start)
650681
startDay.setHours(0, 0, 0, 0)
651682
if (newTargetDay.getTime() < startDay.getTime()) return
652-
// Preserve the time-of-day component of the original end.
653-
const newEnd = new Date(newTargetDay)
654-
newEnd.setHours(oldEnd.getHours(), oldEnd.getMinutes(), oldEnd.getSeconds(), 0)
655-
onEventDrop(draggedEvent, new Date(draggedEvent.start), newEnd)
683+
onEventDrop(draggedEvent, new Date(draggedEvent.start), moveByCalendarDays(oldEnd, days))
656684
return
657685
}
658686

659-
// Move: translate both start and end by the day delta. When sourceDay
687+
// Move: translate both start and end by the same number of calendar
688+
// days, each keeping its time of day (objectui#11005). When sourceDay
660689
// is present (drag started from a specific cell of the span), the
661-
// delta is `targetDay - sourceDay` so the grabbed cell lands on the
662-
// drop target. Without sourceDay we fall back to `targetDay -
663-
// eventStartDay` (legacy behavior).
690+
// days are counted from sourceDay to targetDay so the grabbed cell
691+
// lands on the drop target. Without sourceDay we fall back to the
692+
// event's start day (legacy behavior).
693+
//
694+
// ⛔ Not the milliseconds between the two cells' local midnights added
695+
// to each instant: across a DST change that span is 23 or 25 hours, so a
696+
// 10:00 event landed at 09:00 or 11:00, and a span starting at local
697+
// midnight, grabbed on a later cell, started at 23:00 of the day before
698+
// the one it was dropped on.
664699
const oldStart = new Date(draggedEvent.start)
665700
let anchorDay: Date
666701
if (payload.sourceDay) {
667702
const [y, m, d] = payload.sourceDay.split("-").map((v) => parseInt(v, 10))
668703
anchorDay = new Date(y, m, d)
669704
} else {
670-
anchorDay = new Date(oldStart)
705+
anchorDay = oldStart
671706
}
672-
anchorDay.setHours(0, 0, 0, 0)
673-
const deltaMs = newTargetDay.getTime() - anchorDay.getTime()
674-
if (deltaMs === 0) return
675-
const newStart = new Date(oldStart.getTime() + deltaMs)
707+
const days = calendarDaysBetween(anchorDay, newTargetDay)
708+
if (days === 0) return
709+
const newStart = moveByCalendarDays(oldStart, days)
676710
let newEnd: Date | undefined
677711
if (draggedEvent.end) {
678-
newEnd = new Date(new Date(draggedEvent.end).getTime() + deltaMs)
712+
newEnd = moveByCalendarDays(new Date(draggedEvent.end), days)
679713
}
680714
onEventDrop(draggedEvent, newStart, newEnd)
681715
}

‎packages/plugin-calendar/src/ObjectCalendar.tsx‎

Lines changed: 7 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -346,47 +346,6 @@ function isDateOnlyField(declaredType: unknown, stored: unknown): boolean {
346346
: typeof stored === 'string' && isRealCalendarDate(stored);
347347
}
348348

349-
/** A stored calendar day: a `YYYY-MM-DD` string naming a day its month has. */
350-
function isStoredDay(value: unknown): value is string {
351-
return typeof value === 'string' && isRealCalendarDate(value);
352-
}
353-
354-
const DAY_MS = 86_400_000;
355-
356-
/**
357-
* The value a MOVED event writes into one of its date fields (objectui#10866,
358-
* slice 4): {@link toStoredDateValue}'s answer, except for a `date` field of a
359-
* DAY EVENT, one whose start and end (when it has one) are both stored calendar
360-
* days.
361-
*
362-
* Only the month grid drags a day event: the week and day views draw it in
363-
* their all-day row, which does not drag. The month grid moves each date by the
364-
* milliseconds between two local midnights, the grabbed cell's and the drop
365-
* cell's, and a day event's dates were handed to it at local midnight of their
366-
* days (`toDisplayDate`). When a DST change lies between a date and where it
367-
* lands but not between the two cells, or the other way round, the moved date
368-
* comes back an hour off local midnight: 23:00 of the day before when the
369-
* clocks fell back in between. Measured under `America/Los_Angeles`: a
370-
* two-day span grabbed on its start and moved two days, across November 1st,
371-
* wrote its end one day short, and the local day of that `Date` is what
372-
* {@link toStoredDateValue} writes.
373-
*
374-
* So a day event's `date` field is written as its stored day moved by the
375-
* whole days the grid moved it, on the UTC calendar, the way this card's
376-
* binding reading moves a calendar day. The grid's own arithmetic is
377-
* unchanged, because the grid cannot tell a day from an instant at local
378-
* midnight: every other field, instants included, writes exactly what it did.
379-
*/
380-
function toMovedDateValue(moved: Date, declaredType: unknown, stored: unknown, dayEvent: boolean): string {
381-
if (!dayEvent || !isStoredDay(stored) || !isDateOnlyField(declaredType, stored)) {
382-
return toStoredDateValue(moved, declaredType, stored);
383-
}
384-
const days = Math.round((moved.getTime() - toDisplayDate(stored).getTime()) / DAY_MS);
385-
const day = new Date(`${stored}T00:00:00.000Z`);
386-
day.setUTCDate(day.getUTCDate() + days);
387-
return day.toISOString().slice(0, 10);
388-
}
389-
390349
/**
391350
* A record the calendar cannot place: the field declared as `startDateField`
392351
* carries no value on it, so there is no date to draw and none is invented
@@ -1104,18 +1063,17 @@ export const ObjectCalendar: React.FC<ObjectCalendarComponentProps> = ({
11041063
if (!id || !schema.objectName || !dataSource?.update) return;
11051064

11061065
// A `date` field is written as the calendar day, a `datetime` as the
1107-
// instant (objectui#10866, `toStoredDateValue`); a day event's `date`
1108-
// field moves by whole days (`toMovedDateValue`). `dayEvent` asks the same
1109-
// values the read handed the view: the start, and the end only when set.
1066+
// instant (objectui#10866, `toStoredDateValue`). The month grid moves a
1067+
// value by calendar days and keeps its time of day (objectui#11005), so a
1068+
// day read at local midnight comes back at local midnight of the day it
1069+
// was dropped on, whatever DST change lies between, and its local day is
1070+
// the day to write.
11101071
const fieldDefs = objectSchema?.fields as Record<string, { type?: unknown } | undefined> | undefined;
1111-
const storedStart: unknown = record?.[startDateField];
1112-
const storedEnd: unknown = endDateField ? record?.[endDateField] : undefined;
1113-
const dayEvent = isStoredDay(storedStart) && (!storedEnd || isStoredDay(storedEnd));
11141072
const patch: Record<string, string> = {
1115-
[startDateField]: toMovedDateValue(newStart, fieldDefs?.[startDateField]?.type, storedStart, dayEvent),
1073+
[startDateField]: toStoredDateValue(newStart, fieldDefs?.[startDateField]?.type, record?.[startDateField]),
11161074
};
11171075
if (endDateField && newEnd) {
1118-
patch[endDateField] = toMovedDateValue(newEnd, fieldDefs?.[endDateField]?.type, storedEnd, dayEvent);
1076+
patch[endDateField] = toStoredDateValue(newEnd, fieldDefs?.[endDateField]?.type, record?.[endDateField]);
11191077
}
11201078

11211079
// Optimistic UI update

0 commit comments

Comments
 (0)