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

View File

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