(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:
Nathan Panchout
2026-01-25 20:34:09 +01:00
parent 0ddb47d095
commit cb07b77389
4 changed files with 224 additions and 149 deletions

View File

@@ -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>;

View File

@@ -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],
});
},
});
};

View File

@@ -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 : ''),
};
};

View File

@@ -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}
/>
),
};
};