From 1a5b2f0e5efb468f2eaa35e4d6107e06190d30f1 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Wed, 4 Mar 2026 11:04:39 +0000 Subject: [PATCH] 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 --- frontend/src/components/DashboardCalendar.vue | 34 +++++++----- frontend/src/components/SpaceCalendar.vue | 52 ++++++++----------- 2 files changed, 44 insertions(+), 42 deletions(-) diff --git a/frontend/src/components/DashboardCalendar.vue b/frontend/src/components/DashboardCalendar.vue index 7f21bba..576b8c5 100644 --- a/frontend/src/components/DashboardCalendar.vue +++ b/frontend/src/components/DashboardCalendar.vue @@ -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(() => { 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(() => ({ 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, diff --git a/frontend/src/components/SpaceCalendar.vue b/frontend/src/components/SpaceCalendar.vue index a04fec8..efeddd7 100644 --- a/frontend/src/components/SpaceCalendar.vue +++ b/frontend/src/components/SpaceCalendar.vue @@ -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(() => { 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(() => ({ 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,