✨(front) add calendar hooks
Add useCalendars hook for CalDAV calendar management and useCalendarLocale for locale-aware date formatting. Remove obsolete modal hooks replaced by Scheduler. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Hook for calendar locale management
|
||||
*
|
||||
* Provides locale-aware utilities for date formatting and calendar configuration
|
||||
*/
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { enUS, fr, nl, Locale } from 'date-fns/locale';
|
||||
|
||||
// Map i18n language codes to date-fns locales
|
||||
const DATE_FNS_LOCALES: Record<string, Locale> = {
|
||||
en: enUS,
|
||||
'en-us': enUS,
|
||||
'en-US': enUS,
|
||||
fr: fr,
|
||||
'fr-fr': fr,
|
||||
'fr-FR': fr,
|
||||
nl: nl,
|
||||
'nl-nl': nl,
|
||||
'nl-NL': nl,
|
||||
};
|
||||
|
||||
// Map i18n language codes to Intl locale codes
|
||||
const INTL_LOCALES: Record<string, string> = {
|
||||
en: 'en-US',
|
||||
'en-us': 'en-US',
|
||||
'en-US': 'en-US',
|
||||
fr: 'fr-FR',
|
||||
'fr-fr': 'fr-FR',
|
||||
'fr-FR': 'fr-FR',
|
||||
nl: 'nl-NL',
|
||||
'nl-nl': 'nl-NL',
|
||||
'nl-NL': 'nl-NL',
|
||||
};
|
||||
|
||||
// First day of week by locale (0 = Sunday, 1 = Monday)
|
||||
const FIRST_DAY_OF_WEEK: Record<string, 0 | 1 | 2 | 3 | 4 | 5 | 6> = {
|
||||
en: 0, // US: Sunday
|
||||
'en-us': 0,
|
||||
'en-US': 0,
|
||||
fr: 1, // France: Monday
|
||||
'fr-fr': 1,
|
||||
'fr-FR': 1,
|
||||
nl: 1, // Netherlands: Monday
|
||||
'nl-nl': 1,
|
||||
'nl-NL': 1,
|
||||
};
|
||||
|
||||
export function useCalendarLocale() {
|
||||
const { i18n, t } = useTranslation();
|
||||
const currentLanguage = i18n.language;
|
||||
|
||||
// Get date-fns locale
|
||||
const dateFnsLocale = useMemo(() => {
|
||||
return DATE_FNS_LOCALES[currentLanguage] || DATE_FNS_LOCALES['en'] || enUS;
|
||||
}, [currentLanguage]);
|
||||
|
||||
// Get Intl locale string (e.g., 'fr-FR')
|
||||
const intlLocale = useMemo(() => {
|
||||
return INTL_LOCALES[currentLanguage] || INTL_LOCALES['en'] || 'en-US';
|
||||
}, [currentLanguage]);
|
||||
|
||||
// Get calendar library locale code
|
||||
const calendarLocale = useMemo(() => {
|
||||
const base = currentLanguage.split('-')[0];
|
||||
return base || 'en';
|
||||
}, [currentLanguage]);
|
||||
|
||||
// Get first day of week for this locale
|
||||
const firstDayOfWeek = useMemo(() => {
|
||||
return FIRST_DAY_OF_WEEK[currentLanguage] ?? FIRST_DAY_OF_WEEK['en'] ?? 0;
|
||||
}, [currentLanguage]);
|
||||
|
||||
// Format date using Intl.DateTimeFormat with current locale
|
||||
const formatDate = useCallback((
|
||||
date: Date | string,
|
||||
options?: Intl.DateTimeFormatOptions
|
||||
): string => {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return new Intl.DateTimeFormat(intlLocale, options).format(d);
|
||||
}, [intlLocale]);
|
||||
|
||||
// Format time using Intl.DateTimeFormat with current locale
|
||||
const formatTime = useCallback((
|
||||
date: Date | string,
|
||||
options?: Intl.DateTimeFormatOptions
|
||||
): string => {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return new Intl.DateTimeFormat(intlLocale, {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
...options,
|
||||
}).format(d);
|
||||
}, [intlLocale]);
|
||||
|
||||
// Format day header for calendar views
|
||||
const formatDayHeader = useCallback((date: Date): { html: string } => {
|
||||
const dayOfWeek = date.toLocaleDateString(intlLocale, { weekday: 'short' });
|
||||
const dayOfMonth = date.toLocaleDateString(intlLocale, { day: 'numeric' });
|
||||
return {
|
||||
html: `<div class="day-header"><span class="day-of-month">${dayOfMonth}</span> <span class="day-of-week">${dayOfWeek}</span></div>`,
|
||||
};
|
||||
}, [intlLocale]);
|
||||
|
||||
// Get calendar translations for the library
|
||||
const getCalendarTranslations = useCallback(() => ({
|
||||
dayGridMonth: t('calendar.views.month'),
|
||||
dayGridWeek: t('calendar.views.week'),
|
||||
dayGridDay: t('calendar.views.day'),
|
||||
timeGridWeek: t('calendar.views.week'),
|
||||
timeGridDay: t('calendar.views.day'),
|
||||
listDay: t('calendar.views.listDay'),
|
||||
listWeek: t('calendar.views.listWeek'),
|
||||
listMonth: t('calendar.views.listMonth'),
|
||||
listYear: t('calendar.views.listYear'),
|
||||
today: t('calendar.views.today'),
|
||||
}), [t]);
|
||||
|
||||
return {
|
||||
// Locales
|
||||
dateFnsLocale,
|
||||
intlLocale,
|
||||
calendarLocale,
|
||||
currentLanguage,
|
||||
|
||||
// Calendar settings
|
||||
firstDayOfWeek,
|
||||
|
||||
// Formatters
|
||||
formatDate,
|
||||
formatTime,
|
||||
formatDayHeader,
|
||||
|
||||
// Translations
|
||||
getCalendarTranslations,
|
||||
t,
|
||||
};
|
||||
}
|
||||
|
||||
export type CalendarLocale = ReturnType<typeof useCalendarLocale>;
|
||||
@@ -6,8 +6,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
Calendar,
|
||||
createCalendar,
|
||||
createCalendarApi,
|
||||
createSubscriptionToken,
|
||||
deleteSubscriptionToken,
|
||||
getCalendars,
|
||||
getSubscriptionToken,
|
||||
GetSubscriptionTokenResult,
|
||||
SubscriptionToken,
|
||||
SubscriptionTokenError,
|
||||
SubscriptionTokenParams,
|
||||
toggleCalendarVisibility,
|
||||
} from "../api";
|
||||
|
||||
@@ -30,7 +37,7 @@ export const useCreateCalendar = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: createCalendar,
|
||||
mutationFn: createCalendarApi,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: CALENDARS_KEY });
|
||||
},
|
||||
@@ -50,3 +57,78 @@ export const useToggleCalendarVisibility = () => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Result type for useSubscriptionToken hook.
|
||||
*/
|
||||
export interface UseSubscriptionTokenResult {
|
||||
token: SubscriptionToken | null;
|
||||
tokenError: SubscriptionTokenError | null;
|
||||
isLoading: boolean;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get subscription token for a calendar by CalDAV path.
|
||||
* Handles the result/error pattern from getSubscriptionToken.
|
||||
*/
|
||||
export const useSubscriptionToken = (caldavPath: string): UseSubscriptionTokenResult => {
|
||||
const query = useQuery<GetSubscriptionTokenResult>({
|
||||
queryKey: ["subscription-token", caldavPath],
|
||||
queryFn: () => getSubscriptionToken(caldavPath),
|
||||
enabled: !!caldavPath,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Extract token and error from the result using proper type narrowing
|
||||
const result = query.data;
|
||||
let token: SubscriptionToken | null = null;
|
||||
let tokenError: SubscriptionTokenError | null = null;
|
||||
|
||||
if (result) {
|
||||
if (result.success) {
|
||||
token = result.token;
|
||||
} else {
|
||||
tokenError = result.error;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
tokenError,
|
||||
isLoading: query.isLoading,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to create a subscription token.
|
||||
*/
|
||||
export const useCreateSubscriptionToken = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: createSubscriptionToken,
|
||||
onSuccess: (_data, params: SubscriptionTokenParams) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["subscription-token", params.caldavPath],
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to delete (revoke) a subscription token.
|
||||
*/
|
||||
export const useDeleteSubscriptionToken = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: deleteSubscriptionToken,
|
||||
onSuccess: (_data, caldavPath: string) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["subscription-token", caldavPath],
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* Simple wrapper around useEventModal for the "Créer" button.
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { IcsEvent } from 'ts-ics';
|
||||
import { useEventModal, apiCalendarToOpenCalendar } from './useEventModal';
|
||||
import { Calendar as ApiCalendar } from '../api';
|
||||
|
||||
interface UseCreateEventModalProps {
|
||||
calendars?: ApiCalendar[] | null;
|
||||
selectedDate?: Date;
|
||||
}
|
||||
|
||||
export const useCreateEventModal = ({ calendars, selectedDate }: UseCreateEventModalProps) => {
|
||||
const openCalendars = useMemo(() => {
|
||||
if (!Array.isArray(calendars)) return [];
|
||||
return calendars.map(apiCalendarToOpenCalendar);
|
||||
}, [calendars]);
|
||||
|
||||
const initialEvent = useMemo((): IcsEvent | null => {
|
||||
if (!selectedDate) return null;
|
||||
const start = new Date(selectedDate);
|
||||
start.setHours(9, 0, 0, 0);
|
||||
const end = new Date(start);
|
||||
end.setHours(10, 0, 0, 0);
|
||||
return {
|
||||
uid: `event-${Date.now()}`,
|
||||
summary: '',
|
||||
start: { type: 'DATE-TIME', date: start, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone },
|
||||
end: { type: 'DATE-TIME', date: end, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone },
|
||||
};
|
||||
}, [selectedDate]);
|
||||
|
||||
const modal = useEventModal({
|
||||
calendars: openCalendars,
|
||||
initialEvent,
|
||||
initialCalendarUrl: Array.isArray(calendars) && calendars[0]?.id ? calendars[0].id : '',
|
||||
});
|
||||
|
||||
return {
|
||||
...modal,
|
||||
open: () => modal.open(initialEvent, 'create', Array.isArray(calendars) && calendars[0]?.id ? calendars[0].id : ''),
|
||||
};
|
||||
};
|
||||
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* Unified hook for managing EventModal state.
|
||||
* Can be used both in React components and adapted for open-calendar.
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import type { IcsEvent } from 'ts-ics';
|
||||
import { EventModal } from '../components/EventModal';
|
||||
import { Calendar as ApiCalendar } from '../api';
|
||||
|
||||
// Re-export Calendar type from EventModal for consistency
|
||||
export type { Calendar as OpenCalendar } from '../components/EventModal';
|
||||
|
||||
// Convert API Calendar to open-calendar Calendar format
|
||||
export function apiCalendarToOpenCalendar(cal: ApiCalendar): OpenCalendar {
|
||||
return {
|
||||
url: cal.id,
|
||||
uid: cal.id,
|
||||
displayName: cal.name,
|
||||
calendarColor: cal.color,
|
||||
description: cal.description,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
interface UseEventModalProps {
|
||||
calendars?: OpenCalendar[];
|
||||
initialEvent?: IcsEvent | null;
|
||||
initialCalendarUrl?: string;
|
||||
onSubmit?: (event: IcsEvent, calendarUrl: string) => Promise<void>;
|
||||
onDelete?: (event: IcsEvent, calendarUrl: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useEventModal = ({
|
||||
calendars,
|
||||
initialEvent = null,
|
||||
initialCalendarUrl = '',
|
||||
onSubmit: customOnSubmit,
|
||||
onDelete: customOnDelete,
|
||||
}: UseEventModalProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [mode, setMode] = useState<'create' | 'edit'>('create');
|
||||
const [event, setEvent] = useState<IcsEvent | null>(initialEvent);
|
||||
const [currentCalendars, setCurrentCalendars] = useState<OpenCalendar[]>(calendars || []);
|
||||
const [calendarUrl, setCalendarUrl] = useState<string>(initialCalendarUrl || calendars[0]?.url || '');
|
||||
const [allDay, setAllDay] = useState(false);
|
||||
|
||||
// Update calendars when they change
|
||||
useEffect(() => {
|
||||
if (calendars && calendars.length > 0) {
|
||||
setCurrentCalendars(calendars);
|
||||
}
|
||||
}, [calendars]);
|
||||
|
||||
const handleSubmit = useCallback(async (event: IcsEvent, calendarUrl: string) => {
|
||||
if (customOnSubmit) {
|
||||
await customOnSubmit(event, calendarUrl);
|
||||
}
|
||||
setIsOpen(false);
|
||||
}, [customOnSubmit]);
|
||||
|
||||
const handleDelete = useCallback(async (event: IcsEvent, calendarUrl: string) => {
|
||||
if (customOnDelete) {
|
||||
await customOnDelete(event, calendarUrl);
|
||||
}
|
||||
setIsOpen(false);
|
||||
}, [customOnDelete]);
|
||||
|
||||
const open = useCallback((event?: IcsEvent | null, mode: 'create' | 'edit' = 'create', calendarUrl?: string, calendars?: OpenCalendar[]) => {
|
||||
setEvent(event || null);
|
||||
setMode(mode);
|
||||
if (calendarUrl !== undefined) setCalendarUrl(calendarUrl);
|
||||
if (calendars && calendars.length > 0) setCurrentCalendars(calendars);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
mode,
|
||||
event,
|
||||
calendarUrl,
|
||||
open,
|
||||
close,
|
||||
Modal: (
|
||||
<EventModal
|
||||
isOpen={isOpen}
|
||||
mode={mode}
|
||||
calendars={currentCalendars}
|
||||
selectedEvent={event}
|
||||
calendarUrl={calendarUrl}
|
||||
onSubmit={handleSubmit}
|
||||
onAllDayChange={setAllDay}
|
||||
onClose={close}
|
||||
onDelete={mode === 'edit' ? handleDelete : undefined}
|
||||
/>
|
||||
),
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user