✨(front) add Scheduler utilities and hooks
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<CalDavCalendar[]>;
|
||||
calendarRef: MutableRefObject<CalendarApi | null>;
|
||||
calendarUrl: string;
|
||||
modalState: EventModalState;
|
||||
setModalState: React.Dispatch<React.SetStateAction<EventModalState>>;
|
||||
}
|
||||
|
||||
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<IcsEvent> = {
|
||||
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<IcsEvent> = {
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -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<HTMLDivElement | null>;
|
||||
calendarRef: MutableRefObject<CalendarApi | null>;
|
||||
isConnected: boolean;
|
||||
calendarUrl: string;
|
||||
caldavService: CalDavService;
|
||||
adapter: EventCalendarAdapter;
|
||||
visibleCalendarUrlsRef: MutableRefObject<Set<string>>;
|
||||
davCalendarsRef: MutableRefObject<CalDavCalendar[]>;
|
||||
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<string, unknown>();
|
||||
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]);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user