From 63e91b5eb5203bd1251f8f56b8c0b87d18783044 Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Sun, 25 Jan 2026 20:34:21 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(front)=20add=20Scheduler=20utilities?= =?UTF-8?q?=20and=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add date formatting utilities and custom hooks for Scheduler initialization and event handlers including drag-drop, resize and click operations. Co-Authored-By: Claude Opus 4.5 --- .../scheduler/hooks/useSchedulerHandlers.ts | 499 ++++++++++++++++++ .../scheduler/hooks/useSchedulerInit.ts | 358 +++++++++++++ .../scheduler/utils/dateFormatters.ts | 56 ++ 3 files changed, 913 insertions(+) create mode 100644 src/frontend/apps/calendars/src/features/calendar/components/scheduler/hooks/useSchedulerHandlers.ts create mode 100644 src/frontend/apps/calendars/src/features/calendar/components/scheduler/hooks/useSchedulerInit.ts create mode 100644 src/frontend/apps/calendars/src/features/calendar/components/scheduler/utils/dateFormatters.ts diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/hooks/useSchedulerHandlers.ts b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/hooks/useSchedulerHandlers.ts new file mode 100644 index 0000000..01c14ae --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/hooks/useSchedulerHandlers.ts @@ -0,0 +1,499 @@ +/** + * useSchedulerHandlers hook. + * Provides all event handlers for the Scheduler component. + */ + +import { useCallback, MutableRefObject } from "react"; +import { IcsEvent } from "ts-ics"; + +import { useAuth } from "@/features/auth/Auth"; +import type { + EventCalendarEvent, + EventCalendarSelectInfo, + EventCalendarEventClickInfo, + EventCalendarEventDropInfo, + EventCalendarEventResizeInfo, + EventCalendarDateClickInfo, +} from "../../../services/dav/types/event-calendar"; +import type { EventCalendarAdapter, CalDavExtendedProps } from "../../../services/dav/EventCalendarAdapter"; +import type { CalDavService } from "../../../services/dav/CalDavService"; +import type { CalDavCalendar } from "../../../services/dav/types/caldav-service"; +import type { EventModalState, RecurringDeleteOption } from "../types"; + +// Get browser timezone +const BROWSER_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone; + +type ECEvent = EventCalendarEvent; + +// Calendar API interface (subset of what we need from the calendar instance) +interface CalendarApi { + updateEvent: (event: ECEvent) => void; + addEvent: (event: ECEvent) => void; + unselect: () => void; + refetchEvents: () => void; +} + +interface UseSchedulerHandlersProps { + adapter: EventCalendarAdapter; + caldavService: CalDavService; + davCalendarsRef: MutableRefObject; + calendarRef: MutableRefObject; + calendarUrl: string; + modalState: EventModalState; + setModalState: React.Dispatch>; +} + +export const useSchedulerHandlers = ({ + adapter, + caldavService, + davCalendarsRef, + calendarRef, + calendarUrl, + modalState, + setModalState, +}: UseSchedulerHandlersProps) => { + const { user } = useAuth(); + + /** + * Handle event drop (drag & drop to new time/date). + * Uses adapter to correctly convert dates with timezone. + */ + const handleEventDrop = useCallback( + async (info: EventCalendarEventDropInfo) => { + const extProps = info.event.extendedProps as CalDavExtendedProps; + + if (!extProps?.eventUrl) { + console.error("No eventUrl in extendedProps, cannot update"); + info.revert(); + return; + } + + console.log('[EventDrop] Event:', info.event); + console.log('[EventDrop] allDay:', info.event.allDay); + console.log('[EventDrop] start:', info.event.start); + console.log('[EventDrop] end:', info.event.end); + + try { + const icsEvent = adapter.toIcsEvent(info.event as EventCalendarEvent, { + defaultTimezone: extProps.timezone || BROWSER_TIMEZONE, + }); + + console.log('[EventDrop] IcsEvent start:', icsEvent.start); + console.log('[EventDrop] IcsEvent end:', icsEvent.end); + + const result = await caldavService.updateEvent({ + eventUrl: extProps.eventUrl, + event: icsEvent, + etag: extProps.etag, + }); + + if (!result.success) { + console.error("Failed to update event:", result.error); + info.revert(); + return; + } + + // Update etag for next update + if (result.data?.etag && calendarRef.current) { + const updatedEvent = { + ...info.event, + extendedProps: { ...extProps, etag: result.data.etag }, + }; + calendarRef.current.updateEvent(updatedEvent as ECEvent); + } + } catch (error) { + console.error("Error updating event:", error); + info.revert(); + } + }, + [adapter, caldavService, calendarRef] + ); + + /** + * Handle event resize (change duration). + */ + const handleEventResize = useCallback( + async (info: EventCalendarEventResizeInfo) => { + const extProps = info.event.extendedProps as CalDavExtendedProps; + + if (!extProps?.eventUrl) { + console.error("No eventUrl in extendedProps, cannot update"); + info.revert(); + return; + } + + try { + const icsEvent = adapter.toIcsEvent(info.event as EventCalendarEvent, { + defaultTimezone: extProps.timezone || BROWSER_TIMEZONE, + }); + + const result = await caldavService.updateEvent({ + eventUrl: extProps.eventUrl, + event: icsEvent, + etag: extProps.etag, + }); + + if (!result.success) { + console.error("Failed to resize event:", result.error); + info.revert(); + return; + } + + // Update etag + if (result.data?.etag && calendarRef.current) { + const updatedEvent = { + ...info.event, + extendedProps: { + ...extProps, + etag: result.data.etag, + }, + }; + calendarRef.current.updateEvent(updatedEvent as ECEvent); + } + } catch (error) { + console.error("Error resizing event:", error); + info.revert(); + } + }, + [adapter, caldavService, calendarRef] + ); + + /** + * Handle event click - open edit modal. + */ + const handleEventClick = useCallback( + (info: EventCalendarEventClickInfo) => { + const extProps = info.event.extendedProps as CalDavExtendedProps; + + // Convert EventCalendar event back to IcsEvent for editing + const icsEvent = adapter.toIcsEvent(info.event as EventCalendarEvent, { + defaultTimezone: extProps?.timezone || BROWSER_TIMEZONE, + }); + + setModalState({ + isOpen: true, + mode: "edit", + event: icsEvent, + calendarUrl: extProps?.calendarUrl || calendarUrl, + eventUrl: extProps?.eventUrl, + etag: extProps?.etag, + }); + }, + [adapter, calendarUrl, setModalState] + ); + + /** + * Handle date click - open create modal for single time slot. + */ + const handleDateClick = useCallback( + (info: EventCalendarDateClickInfo) => { + const start = info.date; + const end = new Date(start.getTime() + 60 * 60 * 1000); // 1 hour default + + const newEvent: Partial = { + uid: crypto.randomUUID(), + stamp: { date: new Date() }, + start: { + date: start, + type: info.allDay ? "DATE" : "DATE-TIME", + // Don't set 'local' here - the date is already in browser local time + // Setting 'local' would make EventModal think it's "fake UTC" + }, + end: { + date: end, + type: info.allDay ? "DATE" : "DATE-TIME", + // Don't set 'local' here - the date is already in browser local time + }, + }; + + setModalState({ + isOpen: true, + mode: "create", + event: newEvent, + calendarUrl: calendarUrl, + }); + }, + [calendarUrl, setModalState] + ); + + /** + * Handle select - open create modal for selected time range. + */ + const handleSelect = useCallback( + (info: EventCalendarSelectInfo) => { + const newEvent: Partial = { + uid: crypto.randomUUID(), + stamp: { date: new Date() }, + start: { + date: info.start, + type: info.allDay ? "DATE" : "DATE-TIME", + // Don't set 'local' here - the date is already in browser local time + }, + end: { + date: info.end, + type: info.allDay ? "DATE" : "DATE-TIME", + // Don't set 'local' here - the date is already in browser local time + }, + }; + + setModalState({ + isOpen: true, + mode: "create", + event: newEvent, + calendarUrl: calendarUrl, + }); + + // Clear the selection + calendarRef.current?.unselect(); + }, + [calendarUrl, calendarRef, setModalState] + ); + + /** + * Handle modal save (create or update event). + */ + const handleModalSave = useCallback( + async (event: IcsEvent, targetCalendarUrl: string) => { + if (modalState.mode === "create") { + // Create new event + const result = await caldavService.createEvent({ + calendarUrl: targetCalendarUrl, + event, + }); + + if (!result.success) { + throw new Error(result.error || "Failed to create event"); + } + + // Add to calendar UI + if (calendarRef.current && result.data) { + // For recurring events, refetch all events to ensure proper timezone conversion + if (event.recurrenceRule) { + calendarRef.current.refetchEvents(); + } else { + // Non-recurring event, add normally + const calendarColors = adapter.createCalendarColorMap( + davCalendarsRef.current + ); + const ecEvents = adapter.toEventCalendarEvents([result.data], { + calendarColors, + }); + if (ecEvents.length > 0) { + calendarRef.current.addEvent(ecEvents[0] as ECEvent); + } + } + } + } else { + // Update existing event + if (!modalState.eventUrl) { + throw new Error("No event URL for update"); + } + + const result = await caldavService.updateEvent({ + eventUrl: modalState.eventUrl, + event, + etag: modalState.etag, + }); + + if (!result.success) { + throw new Error(result.error || "Failed to update event"); + } + + // Update in calendar UI + if (calendarRef.current && result.data) { + // If this is a recurring event, refetch all events to update all instances + if (event.recurrenceRule) { + calendarRef.current.refetchEvents(); + } else { + // Non-recurring event, update normally + const calendarColors = adapter.createCalendarColorMap( + davCalendarsRef.current + ); + const ecEvents = adapter.toEventCalendarEvents([result.data], { + calendarColors, + }); + if (ecEvents.length > 0) { + calendarRef.current.updateEvent(ecEvents[0] as ECEvent); + } + } + } + } + }, + [adapter, caldavService, calendarRef, davCalendarsRef, modalState] + ); + + /** + * Handle modal delete. + */ + const handleModalDelete = useCallback( + async ( + event: IcsEvent, + _targetCalendarUrl: string, + option?: RecurringDeleteOption + ) => { + if (!modalState.eventUrl) { + throw new Error("No event URL for delete"); + } + + // If this is a recurring event and we have an option + if (event.recurrenceRule && option && option !== 'all') { + // Get the occurrence date + // Prefer recurrenceId if available (it identifies this specific occurrence) + // Otherwise fall back to start date + let occurrenceDate: Date; + if (event.recurrenceId?.value?.date) { + occurrenceDate = event.recurrenceId.value.date; + } else if (event.start.date instanceof Date) { + occurrenceDate = event.start.date; + } else { + occurrenceDate = new Date(event.start.date); + } + + if (option === 'this') { + // Option 1: Delete only this occurrence - Add EXDATE + const addExdateResult = await caldavService.addExdateToEvent( + modalState.eventUrl, + occurrenceDate, + modalState.etag + ); + + if (!addExdateResult.success) { + throw new Error(addExdateResult.error || "Failed to add EXDATE"); + } + + // Refetch events to update UI + if (calendarRef.current) { + calendarRef.current.refetchEvents(); + } + } else if (option === 'future') { + // Option 2: Delete this and future occurrences - Modify UNTIL + const fetchResult = await caldavService.fetchEvent(modalState.eventUrl); + + if (!fetchResult.success || !fetchResult.data) { + throw new Error("Failed to fetch source event"); + } + + const sourceIcsEvents = fetchResult.data.data.events ?? []; + const sourceEvent = sourceIcsEvents.find( + (e) => e.uid === event.uid && !e.recurrenceId + ); + + if (!sourceEvent || !sourceEvent.recurrenceRule) { + throw new Error("Source event or recurrence rule not found"); + } + + // Set UNTIL to the day before this occurrence + const untilDate = new Date(occurrenceDate); + untilDate.setDate(untilDate.getDate() - 1); + untilDate.setHours(23, 59, 59, 999); + + const updatedRecurrenceRule = { + ...sourceEvent.recurrenceRule, + until: { + type: 'DATE-TIME' as const, + date: untilDate, + }, + count: undefined, // Remove count if present + }; + + const updatedEvent: IcsEvent = { + ...sourceEvent, + recurrenceRule: updatedRecurrenceRule, + }; + + const updateResult = await caldavService.updateEvent({ + eventUrl: modalState.eventUrl, + event: updatedEvent, + etag: modalState.etag, + }); + + if (!updateResult.success) { + throw new Error(updateResult.error || "Failed to update event"); + } + + // Refetch events to update UI + if (calendarRef.current) { + calendarRef.current.refetchEvents(); + } + } + } else { + // Option 3: Delete all occurrences OR non-recurring event + const result = await caldavService.deleteEvent(modalState.eventUrl); + + if (!result.success) { + throw new Error(result.error || "Failed to delete event"); + } + + // Refetch events to update UI + if (calendarRef.current) { + calendarRef.current.refetchEvents(); + } + } + }, + [caldavService, modalState, calendarRef] + ); + + /** + * Handle modal close. + */ + const handleModalClose = useCallback(() => { + setModalState((prev) => ({ ...prev, isOpen: false })); + }, [setModalState]); + + /** + * Handle respond to invitation. + */ + const handleRespondToInvitation = useCallback( + async (event: IcsEvent, status: 'ACCEPTED' | 'TENTATIVE' | 'DECLINED') => { + if (!user?.email) { + console.error('No user email available'); + return; + } + + if (!modalState.eventUrl) { + console.error('No event URL available'); + return; + } + + try { + const result = await caldavService.respondToMeeting( + modalState.eventUrl, + event, + user.email, + status, + modalState.etag + ); + + if (!result.success) { + throw new Error(result.error || 'Failed to respond to invitation'); + } + + console.log('✉️ Response sent successfully:', status); + + // Refetch events to update the UI with the new status + if (calendarRef.current) { + calendarRef.current.refetchEvents(); + } + + // Close the modal + setModalState((prev) => ({ ...prev, isOpen: false })); + } catch (error) { + console.error('Error responding to invitation:', error); + throw error; + } + }, + [caldavService, user, calendarRef, modalState.eventUrl, modalState.etag, setModalState] + ); + + return { + handleEventDrop, + handleEventResize, + handleEventClick, + handleDateClick, + handleSelect, + handleModalSave, + handleModalDelete, + handleModalClose, + handleRespondToInvitation, + }; +}; diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/hooks/useSchedulerInit.ts b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/hooks/useSchedulerInit.ts new file mode 100644 index 0000000..a35d1e0 --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/hooks/useSchedulerInit.ts @@ -0,0 +1,358 @@ +/** + * useSchedulerInit hook. + * Handles calendar initialization and configuration. + */ + +import { useEffect, useRef, MutableRefObject } from "react"; +import { useTranslation } from "react-i18next"; +import { + createCalendar, + TimeGrid, + DayGrid, + List, + Interaction, +} from "@event-calendar/core"; + +import { useCalendarLocale } from "../../../hooks/useCalendarLocale"; +import type { EventCalendarAdapter, CalDavExtendedProps } from "../../../services/dav/EventCalendarAdapter"; +import type { CalDavService } from "../../../services/dav/CalDavService"; +import type { CalDavCalendar } from "../../../services/dav/types/caldav-service"; +import type { EventCalendarEvent, EventCalendarFetchInfo } from "../../../services/dav/types/event-calendar"; + +type ECEvent = EventCalendarEvent; + +// Calendar API interface +interface CalendarApi { + updateEvent: (event: ECEvent) => void; + addEvent: (event: ECEvent) => void; + unselect: () => void; + refetchEvents: () => void; + $destroy?: () => void; +} + +interface UseSchedulerInitProps { + containerRef: MutableRefObject; + calendarRef: MutableRefObject; + isConnected: boolean; + calendarUrl: string; + caldavService: CalDavService; + adapter: EventCalendarAdapter; + visibleCalendarUrlsRef: MutableRefObject>; + davCalendarsRef: MutableRefObject; + setCurrentDate: (date: Date) => void; + handleEventClick: (info: unknown) => void; + handleEventDrop: (info: unknown) => void; + handleEventResize: (info: unknown) => void; + handleDateClick: (info: unknown) => void; + handleSelect: (info: unknown) => void; +} + +export const useSchedulerInit = ({ + containerRef, + calendarRef, + isConnected, + calendarUrl, + caldavService, + adapter, + visibleCalendarUrlsRef, + davCalendarsRef, + setCurrentDate, + handleEventClick, + handleEventDrop, + handleEventResize, + handleDateClick, + handleSelect, +}: UseSchedulerInitProps) => { + const { t, i18n } = useTranslation(); + const { calendarLocale, firstDayOfWeek, formatDayHeader } = useCalendarLocale(); + + useEffect(() => { + if (!containerRef.current || calendarRef.current || !isConnected) return; + + const ec = createCalendar( + containerRef.current, + [TimeGrid, DayGrid, List, Interaction], + { + // View configuration + view: "timeGridWeek", + headerToolbar: { + start: "prev,next today", + center: "title", + end: "dayGridMonth,timeGridWeek,timeGridDay,listWeek", + }, + + // Button text translations + buttonText: { + today: t('calendar.views.today'), + dayGridMonth: t('calendar.views.month'), + timeGridWeek: t('calendar.views.week'), + timeGridDay: t('calendar.views.day'), + listWeek: t('calendar.views.listWeek'), + }, + + // Locale & time settings + locale: calendarLocale, + firstDay: firstDayOfWeek, + slotDuration: "00:30", + scrollTime: "08:00", + displayEventEnd: true, + + // Interactive features + editable: true, + selectable: true, + dragScroll: true, + eventStartEditable: true, + eventDurationEditable: true, + selectMinDistance: 5, + eventDragMinDistance: 5, + selectBackgroundColor: '#ffcdd2', // Light red color for selection + + // Event handlers - ALL INTERACTIONS + // Cast handlers to bypass library type differences (DomEvent vs MouseEvent) + eventClick: handleEventClick as (info: unknown) => void, + eventDrop: handleEventDrop as (info: unknown) => void, + eventResize: handleEventResize as (info: unknown) => void, + dateClick: handleDateClick as (info: unknown) => void, + select: handleSelect as (info: unknown) => void, + + // Sync current date with MiniCalendar when navigating + datesSet: (info: { start: Date; end: Date }) => { + // Use the middle of the visible range as the "current" date + const midTime = (info.start.getTime() + info.end.getTime()) / 2; + setCurrentDate(new Date(midTime)); + }, + + // Event display + dayMaxEvents: true, + nowIndicator: true, + + // Date formatting (locale-aware) + dayHeaderFormat: formatDayHeader, + + eventFilter: (info: { event: ECEvent; view: unknown }) => { + // Filter events based on visible calendars using the ref + const extProps = info.event.extendedProps as CalDavExtendedProps | undefined; + const eventCalendarUrl = extProps?.calendarUrl; + if (!eventCalendarUrl) return true; + return visibleCalendarUrlsRef.current.has(eventCalendarUrl); + }, + + // Event sources - fetch from ALL CalDAV calendars (filtering done by eventFilter) + eventSources: [ + { + events: async (fetchInfo: EventCalendarFetchInfo) => { + const calendars = davCalendarsRef.current; + if (calendars.length === 0) return []; + + try { + // Fetch events from ALL calendars in parallel + const allEventsPromises = calendars.map(async (calendar) => { + // Fetch source events (with recurrence rules) without expansion + const sourceEventsResult = await caldavService.fetchEvents( + calendar.url, + { + timeRange: { + start: fetchInfo.start, + end: fetchInfo.end, + }, + expand: false, + } + ); + + // Fetch expanded instances + const expandedEventsResult = await caldavService.fetchEvents( + calendar.url, + { + timeRange: { + start: fetchInfo.start, + end: fetchInfo.end, + }, + expand: true, + } + ); + + if (!expandedEventsResult.success || !expandedEventsResult.data) { + console.error( + `Failed to fetch events from ${calendar.url}:`, + expandedEventsResult.error + ); + return []; + } + + // Build a map of source recurrence rules by UID + const sourceRulesByUid = new Map(); + if (sourceEventsResult.success && sourceEventsResult.data) { + for (const sourceEvent of sourceEventsResult.data) { + const icsEvents = sourceEvent.data.events ?? []; + for (const icsEvent of icsEvents) { + if (icsEvent.recurrenceRule && !icsEvent.recurrenceId) { + sourceRulesByUid.set(icsEvent.uid, icsEvent.recurrenceRule); + } + } + } + } + + // Enrich expanded events with recurrence rules from sources + const enrichedExpandedData = expandedEventsResult.data.map( + (event) => { + const enrichedEvents = event.data.events?.map((icsEvent) => { + // If this is an instance without recurrenceRule, add it from source + if (icsEvent.recurrenceId && !icsEvent.recurrenceRule) { + const sourceRule = sourceRulesByUid.get(icsEvent.uid); + if (sourceRule) { + return { ...icsEvent, recurrenceRule: sourceRule }; + } + } + return icsEvent; + }); + + return { + ...event, + data: { + ...event.data, + events: enrichedEvents, + }, + }; + } + ); + + const calendarColors = adapter.createCalendarColorMap(calendars); + // Type assertion needed due to the enrichment process + return adapter.toEventCalendarEvents( + enrichedExpandedData as typeof expandedEventsResult.data, + { calendarColors } + ); + }); + + const allEventsArrays = await Promise.all(allEventsPromises); + return allEventsArrays.flat() as ECEvent[]; + } catch (error) { + console.error("Error fetching events:", error); + return []; + } + }, + }, + ], + + // Loading state is handled internally by the calendar + } + ); + + calendarRef.current = ec as unknown as CalendarApi; + + return () => { + if (calendarRef.current) { + // @event-calendar/core is Svelte-based and uses $destroy + const calendar = calendarRef.current as CalendarApi; + if (typeof calendar.$destroy === 'function') { + calendar.$destroy(); + } + calendarRef.current = null; + } + // Also clear the container + if (containerRef.current) { + containerRef.current.innerHTML = ''; + } + }; + }, [ + isConnected, + calendarUrl, + calendarLocale, + firstDayOfWeek, + formatDayHeader, + handleEventClick, + handleEventDrop, + handleEventResize, + handleDateClick, + handleSelect, + caldavService, + adapter, + setCurrentDate, + t, + i18n.language, + containerRef, + calendarRef, + visibleCalendarUrlsRef, + davCalendarsRef, + ]); +}; + +/** + * Hook to check scheduling capabilities on mount. + */ +export const useSchedulingCapabilitiesCheck = ( + isConnected: boolean, + caldavService: CalDavService +) => { + const hasCheckedRef = useRef(false); + + useEffect(() => { + if (!isConnected || hasCheckedRef.current) return; + + hasCheckedRef.current = true; + + const checkSchedulingCapabilities = async () => { + const result = await caldavService.getSchedulingCapabilities(); + + if (result.success && result.data) { + console.group('📅 CalDAV Scheduling Capabilities'); + console.log( + 'Scheduling Support:', + result.data.hasSchedulingSupport ? '✅ Enabled' : '❌ Disabled' + ); + console.log( + 'Schedule Outbox URL:', + result.data.scheduleOutboxUrl || '❌ Not found' + ); + console.log( + 'Schedule Inbox URL:', + result.data.scheduleInboxUrl || '❌ Not found' + ); + console.log( + 'Calendar User Addresses:', + result.data.calendarUserAddressSet.length > 0 + ? result.data.calendarUserAddressSet + : '❌ None' + ); + console.log(''); + console.log('Raw server response:', result.data.rawResponse); + + if (result.data.hasSchedulingSupport) { + console.log(''); + console.log('✉️ Email Notifications Status:'); + console.log(' The server supports CalDAV scheduling (RFC 6638).'); + console.log( + ' However, this does NOT guarantee email notifications will be sent.' + ); + console.log( + ' Email sending requires the IMip plugin to be configured on the server.' + ); + console.log( + ' Contact your server administrator to verify IMip plugin configuration.' + ); + } else { + console.warn(''); + console.warn( + '⚠️ CalDAV scheduling properties not found on this server.' + ); + console.warn(' This could mean:'); + console.warn( + ' 1. The scheduling plugin is not enabled in Sabre/DAV configuration' + ); + console.warn( + ' 2. The properties are located elsewhere (check raw response above)' + ); + console.warn( + ' 3. The server does not support CalDAV scheduling (RFC 6638)' + ); + } + + console.groupEnd(); + } else { + console.error('Failed to check scheduling capabilities:', result.error); + } + }; + + checkSchedulingCapabilities(); + }, [isConnected, caldavService]); +}; diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/utils/dateFormatters.ts b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/utils/dateFormatters.ts new file mode 100644 index 0000000..fc6cb9c --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/utils/dateFormatters.ts @@ -0,0 +1,56 @@ +/** + * Date formatting utilities for the Scheduler components. + * Handles conversion between Date objects and HTML input formats. + */ + +/** + * Pad a number to 2 digits. + */ +const pad = (n: number): string => n.toString().padStart(2, "0"); + +/** + * Format Date to input datetime-local format (YYYY-MM-DDTHH:mm). + * + * @param date - The date to format + * @param isFakeUtc - If true, use getUTC* methods (for dates from adapter + * that store local time as UTC values) + */ +export const formatDateTimeLocal = (date: Date, isFakeUtc = false): string => { + if (isFakeUtc) { + // For "fake UTC" dates, getUTC* methods return the intended local time + return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}`; + } + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +}; + +/** + * Parse datetime-local input value to Date. + * + * @param value - String in YYYY-MM-DDTHH:mm format + */ +export const parseDateTimeLocal = (value: string): Date => { + return new Date(value); +}; + +/** + * Format Date to input date format (YYYY-MM-DD). + * + * @param date - The date to format + * @param isFakeUtc - If true, use getUTC* methods (for dates from adapter) + */ +export const formatDateLocal = (date: Date, isFakeUtc = false): string => { + if (isFakeUtc) { + return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`; + } + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +}; + +/** + * Parse date input value to Date (at midnight local time). + * + * @param value - String in YYYY-MM-DD format + */ +export const parseDateLocal = (value: string): Date => { + const [year, month, day] = value.split('-').map(Number); + return new Date(year, month - 1, day); +};