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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user