✨(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 {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
createCalendar,
|
createCalendarApi,
|
||||||
|
createSubscriptionToken,
|
||||||
|
deleteSubscriptionToken,
|
||||||
getCalendars,
|
getCalendars,
|
||||||
|
getSubscriptionToken,
|
||||||
|
GetSubscriptionTokenResult,
|
||||||
|
SubscriptionToken,
|
||||||
|
SubscriptionTokenError,
|
||||||
|
SubscriptionTokenParams,
|
||||||
toggleCalendarVisibility,
|
toggleCalendarVisibility,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
|
|
||||||
@@ -30,7 +37,7 @@ export const useCreateCalendar = () => {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: createCalendar,
|
mutationFn: createCalendarApi,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: CALENDARS_KEY });
|
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