✨(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