fix(calendar): pre-convert datetimes to user timezone, use timeZone UTC

FullCalendar does not reliably convert named timezone events without
a timezone adapter plugin. Instead, we pre-convert UTC datetimes to
the user's display timezone using isoToLocalDateTime and pass them to
FullCalendar with timeZone:'UTC', which displays wall-clock values as-is.

Drag/drop handlers now use delta-based UTC calculation from extendedProps
to send correct UTC times to the API regardless of display timezone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-03-04 11:04:39 +00:00
parent 7295c9d243
commit 1a5b2f0e5e
2 changed files with 44 additions and 42 deletions

View File

@@ -60,7 +60,7 @@ import type { CalendarOptions, EventInput, DatesSetArg, EventDropArg } from '@fu
import { bookingsApi, adminBookingsApi, handleApiError } from '@/services/api'
import { useAuthStore } from '@/stores/auth'
import { useIsMobile } from '@/composables/useMediaQuery'
import { formatDateTime as formatDateTimeUtil, ensureUTC } from '@/utils/datetime'
import { formatDateTime as formatDateTimeUtil, ensureUTC, isoToLocalDateTime } from '@/utils/datetime'
import BookingPreviewModal from '@/components/BookingPreviewModal.vue'
import type { Booking } from '@/types'
@@ -126,13 +126,15 @@ const events = computed<EventInput[]>(() => {
return bookings.value.map((booking) => ({
id: String(booking.id),
title: booking.space?.name ? `${booking.space.name} - ${booking.title}` : booking.title,
start: ensureUTC(booking.start_datetime),
end: ensureUTC(booking.end_datetime),
start: isoToLocalDateTime(booking.start_datetime, userTimezone.value) + ':00',
end: isoToLocalDateTime(booking.end_datetime, userTimezone.value) + ':00',
backgroundColor: STATUS_COLORS[booking.status] || '#9E9E9E',
borderColor: STATUS_COLORS[booking.status] || '#9E9E9E',
extendedProps: {
status: booking.status,
description: booking.description
description: booking.description,
utcStart: booking.start_datetime,
utcEnd: booking.end_datetime
}
}))
})
@@ -183,25 +185,31 @@ const handleEventClick = (info: any) => {
// Drag & drop handlers
const handleEventDrop = (info: EventDropArg) => {
const deltaMs = info.event.start!.getTime() - info.oldEvent.start!.getTime()
const origStart = new Date(ensureUTC(info.event.extendedProps.utcStart as string))
const origEnd = new Date(ensureUTC(info.event.extendedProps.utcEnd as string))
confirmModal.value = {
show: true,
booking: info.event,
oldStart: info.oldEvent.start,
oldEnd: info.oldEvent.end,
newStart: info.event.start,
newEnd: info.event.end,
oldStart: origStart,
oldEnd: origEnd,
newStart: new Date(origStart.getTime() + deltaMs),
newEnd: new Date(origEnd.getTime() + deltaMs),
revertFunc: info.revert
}
}
const handleEventResize = (info: any) => {
const resizeDeltaMs = info.event.end.getTime() - info.oldEvent.end.getTime()
const origStart = new Date(ensureUTC(info.event.extendedProps.utcStart as string))
const origEnd = new Date(ensureUTC(info.event.extendedProps.utcEnd as string))
confirmModal.value = {
show: true,
booking: info.event,
oldStart: info.oldEvent.start,
oldEnd: info.oldEvent.end,
newStart: info.event.start,
newEnd: info.event.end,
oldStart: origStart,
oldEnd: origEnd,
newStart: origStart,
newEnd: new Date(origEnd.getTime() + resizeDeltaMs),
revertFunc: info.revert
}
}
@@ -291,7 +299,7 @@ const calendarOptions = computed<CalendarOptions>(() => ({
headerToolbar: isMobile.value
? { left: 'prev,next', center: 'title', right: 'listWeek,dayGridMonth' }
: { left: 'prev,next today', center: 'title', right: 'dayGridMonth,timeGridWeek' },
timeZone: userTimezone.value,
timeZone: 'UTC',
firstDay: 1,
events: events.value,
datesSet: handleDatesSet,

View File

@@ -61,7 +61,7 @@ import type { CalendarOptions, EventInput, DatesSetArg, EventDropArg } from '@fu
import type { EventResizeDoneArg } from '@fullcalendar/interaction'
import { bookingsApi, adminBookingsApi, handleApiError } from '@/services/api'
import { useAuthStore } from '@/stores/auth'
import { formatDateTime as formatDateTimeUtil, ensureUTC } from '@/utils/datetime'
import { formatDateTime as formatDateTimeUtil, ensureUTC, isoToLocalDateTime } from '@/utils/datetime'
import { useIsMobile } from '@/composables/useMediaQuery'
import BookingPreviewModal from '@/components/BookingPreviewModal.vue'
import type { Booking } from '@/types'
@@ -136,53 +136,47 @@ const events = computed<EventInput[]>(() => {
return bookings.value.map((booking) => ({
id: String(booking.id),
title: booking.title,
start: ensureUTC(booking.start_datetime),
end: ensureUTC(booking.end_datetime),
start: isoToLocalDateTime(booking.start_datetime, userTimezone.value) + ':00',
end: isoToLocalDateTime(booking.end_datetime, userTimezone.value) + ':00',
backgroundColor: STATUS_COLORS[booking.status] || '#9E9E9E',
borderColor: STATUS_COLORS[booking.status] || '#9E9E9E',
extendedProps: {
status: booking.status,
description: booking.description
description: booking.description,
utcStart: booking.start_datetime,
utcEnd: booking.end_datetime
}
}))
})
// Handle event drop (drag)
const handleEventDrop = (info: EventDropArg) => {
const booking = info.event
const oldStart = info.oldEvent.start
const oldEnd = info.oldEvent.end
const newStart = info.event.start
const newEnd = info.event.end
// Show confirmation modal
const deltaMs = info.event.start!.getTime() - info.oldEvent.start!.getTime()
const origStart = new Date(ensureUTC(info.event.extendedProps.utcStart as string))
const origEnd = new Date(ensureUTC(info.event.extendedProps.utcEnd as string))
confirmModal.value = {
show: true,
booking: booking,
oldStart: oldStart,
oldEnd: oldEnd,
newStart: newStart,
newEnd: newEnd,
booking: info.event,
oldStart: origStart,
oldEnd: origEnd,
newStart: new Date(origStart.getTime() + deltaMs),
newEnd: new Date(origEnd.getTime() + deltaMs),
revertFunc: info.revert
}
}
// Handle event resize
const handleEventResize = (info: EventResizeDoneArg) => {
const booking = info.event
const oldStart = info.oldEvent.start
const oldEnd = info.oldEvent.end
const newStart = info.event.start
const newEnd = info.event.end
// Show confirmation modal
const resizeDeltaMs = info.event.end!.getTime() - info.oldEvent.end!.getTime()
const origStart = new Date(ensureUTC(info.event.extendedProps.utcStart as string))
const origEnd = new Date(ensureUTC(info.event.extendedProps.utcEnd as string))
confirmModal.value = {
show: true,
booking: booking,
oldStart: oldStart,
oldEnd: oldEnd,
newStart: newStart,
newEnd: newEnd,
booking: info.event,
oldStart: origStart,
oldEnd: origEnd,
newStart: origStart,
newEnd: new Date(origEnd.getTime() + resizeDeltaMs),
revertFunc: info.revert
}
}
@@ -284,7 +278,7 @@ const calendarOptions = computed<CalendarOptions>(() => ({
headerToolbar: isMobile.value
? { left: 'prev,next', center: 'title', right: 'listWeek,dayGridMonth' }
: { left: 'prev,next today', center: 'title', right: 'dayGridMonth,timeGridWeek,timeGridDay' },
timeZone: userTimezone.value,
timeZone: 'UTC',
firstDay: 1, // Start week on Monday (0=Sunday, 1=Monday)
events: events.value,
datesSet: handleDatesSet,