Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .changeset/10866-calendar-dst-day-moves.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

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

- **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.
- **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.

⚠️ **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.
- **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.
- **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.
12 changes: 12 additions & 0 deletions .changeset/11005-month-grid-calendar-days.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@object-ui/plugin-calendar': patch
---

fix(plugin-calendar): a month-grid move keeps the wall-clock time across a DST change (objectui#11005)

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.

- **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.
- **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.
- **`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.
- 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.
4 changes: 2 additions & 2 deletions content/docs/plugins/plugin-calendar.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ Both components support month, week, and day views with full event management ca

### Month view

- **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.
- **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.
- **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.
- **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.

### Week / Day view (time grid)

Expand Down
4 changes: 2 additions & 2 deletions packages/plugin-calendar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ Calendar view plugins for Object UI - includes both ObjectQL-integrated and stan

| Gesture | Effect |
| --- | --- |
| 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. |
| Drag the right-edge handle of a multi-day pill | Adjusts only `endDateField`; start is preserved. Refuses drops earlier than start. |
| 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. |
| 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. |

### Week / Day view (time grid)

Expand Down
72 changes: 53 additions & 19 deletions packages/plugin-calendar/src/CalendarView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,33 @@ function getEventsForDate(date: Date, events: CalendarViewEvent[]): CalendarView
})
}

/**
* The calendar days from `from`'s local day to `to`'s: the difference of their
* local year / month / day, counted on the UTC calendar, where every day is
* exactly 24 hours, so the quotient is a whole number. ⛔ Never the
* milliseconds between two local midnights over a day's length: across a DST
* change that span is 23 or 25 hours (objectui#11005).
*/
function calendarDaysBetween(from: Date, to: Date): number {
return (
(Date.UTC(to.getFullYear(), to.getMonth(), to.getDate()) -
Date.UTC(from.getFullYear(), from.getMonth(), from.getDate())) /
86_400_000
)
}

/**
* `value` moved by `days` calendar days on the local calendar: a copy whose
* date moves with `setDate`, its hours, minutes, seconds and milliseconds
* untouched, so a 10:00 event stays at 10:00 across a DST change and a value
* at local midnight stays at midnight of the day it lands on (objectui#11005).
*/
function moveByCalendarDays(value: Date, days: number): Date {
const moved = new Date(value)
moved.setDate(moved.getDate() + days)
return moved
}

interface MonthViewProps {
date: Date
events: CalendarViewEvent[]
Expand Down Expand Up @@ -567,7 +594,9 @@ function MonthView({ date, events, locale = "default", onEventClick, onDateClick
// Drag mode: "move" shifts the entire event so the day cell the user
// grabbed lands on the drop-target day. "resize-end" only adjusts the
// event's end date — used by the right-edge resize handle on multi-day
// spans. The mode and the source cell are encoded in the dataTransfer
// spans. Both move a value by calendar days and keep its time of day
// (`moveByCalendarDays`, objectui#11005). The mode and the source cell
// are encoded in the dataTransfer
// payload so the drop target doesn't need any DOM state to interpret the
// gesture, and so dragging from a *continuation* day of a multi-day
// event still results in a sensible delta.
Expand Down Expand Up @@ -640,42 +669,47 @@ function MonthView({ date, events, locale = "default", onEventClick, onDateClick
newTargetDay.setHours(0, 0, 0, 0)

if (payload.mode === "resize-end") {
// Resize: snap the end date to the drop-target day, keep start fixed.
// Resize: move the end to the drop-target day, keep start fixed. The
// handle sits on the span's end cell, so the grabbed cell is the end's
// own day, and the end moves by the calendar days from it to the drop
// cell with its time of day untouched (objectui#11005).
const oldEnd = draggedEvent.end ? new Date(draggedEvent.end) : new Date(draggedEvent.start)
const oldEndDay = new Date(oldEnd)
oldEndDay.setHours(0, 0, 0, 0)
if (newTargetDay.getTime() === oldEndDay.getTime()) return
const days = calendarDaysBetween(oldEnd, newTargetDay)
if (days === 0) return
// Guard: new end can't precede start day.
const startDay = new Date(draggedEvent.start)
startDay.setHours(0, 0, 0, 0)
if (newTargetDay.getTime() < startDay.getTime()) return
// Preserve the time-of-day component of the original end.
const newEnd = new Date(newTargetDay)
newEnd.setHours(oldEnd.getHours(), oldEnd.getMinutes(), oldEnd.getSeconds(), 0)
onEventDrop(draggedEvent, new Date(draggedEvent.start), newEnd)
onEventDrop(draggedEvent, new Date(draggedEvent.start), moveByCalendarDays(oldEnd, days))
return
}

// Move: translate both start and end by the day delta. When sourceDay
// Move: translate both start and end by the same number of calendar
// days, each keeping its time of day (objectui#11005). When sourceDay
// is present (drag started from a specific cell of the span), the
// delta is `targetDay - sourceDay` so the grabbed cell lands on the
// drop target. Without sourceDay we fall back to `targetDay -
// eventStartDay` (legacy behavior).
// days are counted from sourceDay to targetDay so the grabbed cell
// lands on the drop target. Without sourceDay we fall back to the
// event's start day (legacy behavior).
//
// ⛔ Not the milliseconds between the two cells' local midnights added
// to each instant: across a DST change that span is 23 or 25 hours, so a
// 10:00 event landed at 09:00 or 11:00, and a span starting at local
// midnight, grabbed on a later cell, started at 23:00 of the day before
// the one it was dropped on.
const oldStart = new Date(draggedEvent.start)
let anchorDay: Date
if (payload.sourceDay) {
const [y, m, d] = payload.sourceDay.split("-").map((v) => parseInt(v, 10))
anchorDay = new Date(y, m, d)
} else {
anchorDay = new Date(oldStart)
anchorDay = oldStart
}
anchorDay.setHours(0, 0, 0, 0)
const deltaMs = newTargetDay.getTime() - anchorDay.getTime()
if (deltaMs === 0) return
const newStart = new Date(oldStart.getTime() + deltaMs)
const days = calendarDaysBetween(anchorDay, newTargetDay)
if (days === 0) return
const newStart = moveByCalendarDays(oldStart, days)
let newEnd: Date | undefined
if (draggedEvent.end) {
newEnd = new Date(new Date(draggedEvent.end).getTime() + deltaMs)
newEnd = moveByCalendarDays(new Date(draggedEvent.end), days)
}
onEventDrop(draggedEvent, newStart, newEnd)
}
Expand Down
56 changes: 7 additions & 49 deletions packages/plugin-calendar/src/ObjectCalendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -346,47 +346,6 @@ function isDateOnlyField(declaredType: unknown, stored: unknown): boolean {
: typeof stored === 'string' && isRealCalendarDate(stored);
}

/** A stored calendar day: a `YYYY-MM-DD` string naming a day its month has. */
function isStoredDay(value: unknown): value is string {
return typeof value === 'string' && isRealCalendarDate(value);
}

const DAY_MS = 86_400_000;

/**
* The value a MOVED event writes into one of its date fields (objectui#10866,
* slice 4): {@link toStoredDateValue}'s answer, except for a `date` field of a
* DAY EVENT, one whose start and end (when it has one) are both stored calendar
* days.
*
* Only the month grid drags a day event: the week and day views draw it in
* their all-day row, which does not drag. The month grid moves each date by the
* milliseconds between two local midnights, the grabbed cell's and the drop
* cell's, and a day event's dates were handed to it at local midnight of their
* days (`toDisplayDate`). When a DST change lies between a date and where it
* lands but not between the two cells, or the other way round, the moved date
* comes back an hour off local midnight: 23:00 of the day before when the
* clocks fell back in between. Measured under `America/Los_Angeles`: a
* two-day span grabbed on its start and moved two days, across November 1st,
* wrote its end one day short, and the local day of that `Date` is what
* {@link toStoredDateValue} writes.
*
* So a day event's `date` field is written as its stored day moved by the
* whole days the grid moved it, on the UTC calendar, the way this card's
* binding reading moves a calendar day. The grid's own arithmetic is
* unchanged, because the grid cannot tell a day from an instant at local
* midnight: every other field, instants included, writes exactly what it did.
*/
function toMovedDateValue(moved: Date, declaredType: unknown, stored: unknown, dayEvent: boolean): string {
if (!dayEvent || !isStoredDay(stored) || !isDateOnlyField(declaredType, stored)) {
return toStoredDateValue(moved, declaredType, stored);
}
const days = Math.round((moved.getTime() - toDisplayDate(stored).getTime()) / DAY_MS);
const day = new Date(`${stored}T00:00:00.000Z`);
day.setUTCDate(day.getUTCDate() + days);
return day.toISOString().slice(0, 10);
}

/**
* A record the calendar cannot place: the field declared as `startDateField`
* carries no value on it, so there is no date to draw and none is invented
Expand Down Expand Up @@ -1104,18 +1063,17 @@ export const ObjectCalendar: React.FC<ObjectCalendarComponentProps> = ({
if (!id || !schema.objectName || !dataSource?.update) return;

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

// Optimistic UI update
Expand Down
Loading
Loading