Skip to content

Commit 3d26fca

Browse files
committed
Fix DST-related timezone offset when creating/resizing/dragging calendar
events
1 parent 1e73a90 commit 3d26fca

6 files changed

Lines changed: 41 additions & 50 deletions

File tree

resources/js/packages/ui/src/FullCalendar/CalendarDayColumn.vue

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,7 @@ const emit = defineEmits<{
9191
:aria-label="dayEvent.event.title"
9292
role="button"
9393
@pointerdown="emit('event-pointerdown', $event, dayEvent)"
94-
@keydown.enter.prevent="
95-
!dayEvent.event.isRunning && emit('event-keydown-enter', dayEvent)
96-
">
94+
@keydown.enter.prevent="emit('event-keydown-enter', dayEvent)">
9795
<div
9896
v-if="!dayEvent.isClippedStart"
9997
class="fc-event-resizer fc-event-resizer-start absolute z-[99] w-full h-3 left-0 top-[-2px] cursor-row-resize flex items-center justify-center opacity-0 group-hover:opacity-100"

resources/js/packages/ui/src/FullCalendar/useContextMenu.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { ref, type Ref, type ComputedRef } from 'vue';
22
import type { Dayjs } from 'dayjs';
33
import type { TimeEntry } from '@/packages/api/src';
4-
import { getDayJsInstance } from '../utils/time';
5-
import { getUserTimezone } from '../utils/settings';
4+
import { getDayJsInstance, getLocalizedDayJsFromMinutes } from '../utils/time';
5+
66
import type { CalendarSettings } from './calendarSettings';
77
import type { CalendarEvent } from './calendarTypes';
88

@@ -34,11 +34,8 @@ export function useContextMenu(params: {
3434
const snap = params.calendarSettings.value.snapMinutes;
3535
const snappedMinutes = Math.floor(minutesFromGridStart / snap) * snap;
3636

37-
const dayjs = getDayJsInstance();
38-
const startLocal = dayjs(`${date}T00:00:00`)
39-
.tz(getUserTimezone(), true)
40-
.add(snappedMinutes, 'minute');
41-
const snappedEnd = startLocal.add(snap, 'minute');
37+
const startLocal = getLocalizedDayJsFromMinutes(date, snappedMinutes);
38+
const snappedEnd = getLocalizedDayJsFromMinutes(date, snappedMinutes + snap);
4239

4340
return { start: startLocal.utc(), end: snappedEnd.utc() };
4441
}

resources/js/packages/ui/src/FullCalendar/useEventDrag.ts

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue';
22
import type { Dayjs } from 'dayjs';
33
import type { TimeEntry } from '@/packages/api/src';
4-
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
5-
import { getUserTimezone } from '../utils/settings';
4+
import { getLocalizedDayJs, getLocalizedDayJsFromMinutes } from '../utils/time';
65
import type { CalendarSettings } from './calendarSettings';
76
import type { CalendarEvent, DayEvent } from './calendarTypes';
87
import { SLOT_HEIGHT, DRAG_THRESHOLD } from './calendarTypes';
@@ -67,8 +66,7 @@ export function useEventDrag(params: {
6766
}
6867

6968
if (dayEvent.isClippedStart && originDay && ev.timeEntry.end) {
70-
const dayjs = getDayJsInstance();
71-
const dayMidnight = dayjs(`${originDay}T00:00:00`).tz(getUserTimezone(), true);
69+
const dayMidnight = getLocalizedDayJsFromMinutes(originDay, 0);
7270
const evStart = getLocalizedDayJs(ev.timeEntry.start);
7371
const eventStartFromGridStart = evStart.diff(dayMidnight, 'minute') - s.startHour * 60;
7472
const segmentTopMinutes = (dayEvent.top / SLOT_HEIGHT) * s.slotMinutes;
@@ -154,13 +152,11 @@ export function useEventDrag(params: {
154152
const lowerBound = startMin - 4 * 60;
155153
const clampedMinutes = Math.max(lowerBound, Math.min(snappedMinutes, s.endHour * 60));
156154

157-
const dayjs = getDayJsInstance();
158-
const originalSegmentStart = dayjs(`${savedOriginalDayStr}T00:00:00`)
159-
.tz(getUserTimezone(), true)
160-
.add(startMin + params.pixelsToMinutesFromMidnight(dragStartEventTop), 'minute');
161-
const newSegmentStart = dayjs(`${targetDateStr}T00:00:00`)
162-
.tz(getUserTimezone(), true)
163-
.add(clampedMinutes, 'minute');
155+
const originalSegmentStart = getLocalizedDayJsFromMinutes(
156+
savedOriginalDayStr,
157+
startMin + params.pixelsToMinutesFromMidnight(dragStartEventTop)
158+
);
159+
const newSegmentStart = getLocalizedDayJsFromMinutes(targetDateStr, clampedMinutes);
164160
const deltaMs = newSegmentStart.diff(originalSegmentStart);
165161

166162
const origStart = getLocalizedDayJs(timeEntry.start);
@@ -240,11 +236,14 @@ export function useEventDrag(params: {
240236
}
241237

242238
// Multi-day: compute actual start/end datetimes, then clip per day
243-
const dayjs = getDayJsInstance();
244-
const eventStartAbsolute = dayjs(`${dragCurrentDay.value}T00:00:00`)
245-
.tz(getUserTimezone(), true)
246-
.add(startMin + eventStartOnGrid, 'minute');
247-
const eventEndAbsolute = eventStartAbsolute.add(dragFullDurationMinutes, 'minute');
239+
const eventStartAbsolute = getLocalizedDayJsFromMinutes(
240+
dragCurrentDay.value,
241+
startMin + eventStartOnGrid
242+
);
243+
const eventEndAbsolute = getLocalizedDayJsFromMinutes(
244+
dragCurrentDay.value,
245+
startMin + eventStartOnGrid + dragFullDurationMinutes
246+
);
248247

249248
const result: Record<string, Record<string, string>> = {};
250249

resources/js/packages/ui/src/FullCalendar/useEventResize.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue';
22
import type { Dayjs } from 'dayjs';
33
import type { TimeEntry } from '@/packages/api/src';
4-
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
5-
import { getUserTimezone } from '../utils/settings';
4+
import { getDayJsInstance, getLocalizedDayJs, getLocalizedDayJsFromMinutes } from '../utils/time';
65
import type { CalendarSettings } from './calendarSettings';
76
import type { CalendarEvent, DayEvent } from './calendarTypes';
87
import { SLOT_HEIGHT } from './calendarTypes';
@@ -11,10 +10,6 @@ function snapTo(value: number, step: number): number {
1110
return Math.round(value / step) * step;
1211
}
1312

14-
function dayMidnightLocal(dayStr: string): Dayjs {
15-
return getDayJsInstance()(`${dayStr}T00:00:00`).tz(getUserTimezone(), true);
16-
}
17-
1813
export function useEventResize(params: {
1914
calendarSettings: Ref<CalendarSettings>;
2015
viewDays: ComputedRef<Dayjs[]>;
@@ -89,7 +84,7 @@ export function useEventResize(params: {
8984
),
9085
s.snapMinutes
9186
);
92-
return { start, end: dayMidnightLocal(endDay).add(endMinutes, 'minute') };
87+
return { start, end: getLocalizedDayJsFromMinutes(endDay, endMinutes) };
9388
} else {
9489
const end = resizeOriginalEvent.isRunning
9590
? getLocalizedDayJs()
@@ -105,7 +100,7 @@ export function useEventResize(params: {
105100
params.pixelsToMinutesFromMidnight(resizeCurrentTop.value),
106101
s.snapMinutes
107102
);
108-
return { start: dayMidnightLocal(startDay).add(startMinutes, 'minute'), end };
103+
return { start: getLocalizedDayJsFromMinutes(startDay, startMinutes), end };
109104
}
110105
}
111106

resources/js/packages/ui/src/FullCalendar/useSlotSelection.ts

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue';
22
import type { Dayjs } from 'dayjs';
3-
import { getDayJsInstance } from '../utils/time';
4-
import { getUserTimezone } from '../utils/settings';
3+
import { getLocalizedDayJsFromMinutes } from '../utils/time';
4+
55
import type { CalendarSettings } from './calendarSettings';
66
import { SLOT_HEIGHT } from './calendarTypes';
77

@@ -102,8 +102,6 @@ export function useSlotSelection(params: {
102102

103103
const s = params.calendarSettings.value;
104104
const snap = s.snapMinutes;
105-
const dayjs = getDayJsInstance();
106-
107105
const startMinutes = params.pixelsToMinutesFromMidnight(selectionTop.value);
108106
const snappedStartMin = Math.floor(startMinutes / snap) * snap;
109107

@@ -138,12 +136,8 @@ export function useSlotSelection(params: {
138136
if (endMin <= 0) endMin = snap;
139137
}
140138

141-
startLocal = dayjs(`${startDateStr}T00:00:00`)
142-
.tz(getUserTimezone(), true)
143-
.add(startMin, 'minute');
144-
endLocal = dayjs(`${endDateStr}T00:00:00`)
145-
.tz(getUserTimezone(), true)
146-
.add(endMin, 'minute');
139+
startLocal = getLocalizedDayJsFromMinutes(startDateStr, startMin);
140+
endLocal = getLocalizedDayJsFromMinutes(endDateStr, endMin);
147141
} else {
148142
const startDateStr = selectionStartDay;
149143
const endMinutes = params.pixelsToMinutesFromMidnight(
@@ -153,12 +147,8 @@ export function useSlotSelection(params: {
153147
if (snappedEndMin <= snappedStartMin) {
154148
snappedEndMin = snappedStartMin + snap;
155149
}
156-
startLocal = dayjs(`${startDateStr}T00:00:00`)
157-
.tz(getUserTimezone(), true)
158-
.add(snappedStartMin, 'minute');
159-
endLocal = dayjs(`${startDateStr}T00:00:00`)
160-
.tz(getUserTimezone(), true)
161-
.add(snappedEndMin, 'minute');
150+
startLocal = getLocalizedDayJsFromMinutes(startDateStr, snappedStartMin);
151+
endLocal = getLocalizedDayJsFromMinutes(startDateStr, snappedEndMin);
162152
}
163153

164154
params.onSelectionComplete(startLocal.utc(), endLocal.utc());

resources/js/packages/ui/src/utils/time.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,18 @@ export function getLocalizedDayJs(timestamp?: string | null) {
147147
return dayjs.utc(timestamp).tz(getUserTimezone());
148148
}
149149

150+
/**
151+
* Create a dayjs instance for a specific wall-clock time on a given day.
152+
* Sets hour/minute directly to avoid DST issues with .add() on transition days.
153+
*/
154+
export function getLocalizedDayJsFromMinutes(dayStr: string, minutesFromMidnight: number) {
155+
return dayjs
156+
.tz(`${dayStr}T00:00:00`, getUserTimezone())
157+
.hour(Math.floor(minutesFromMidnight / 60))
158+
.minute(Math.round(minutesFromMidnight % 60))
159+
.second(0);
160+
}
161+
150162
export function getLocalizedDateFromTimestamp(timestamp: string) {
151163
return getLocalizedDayJs(timestamp).format('YYYY-MM-DD');
152164
}

0 commit comments

Comments
 (0)