(front) integrate mobile views into Scheduler component

Wire mobile components into the main Scheduler, adapt
useSchedulerInit for mobile viewport, update EventModal
and MiniCalendar for responsive behavior.
This commit is contained in:
Nathan Panchout
2026-03-11 10:43:37 +01:00
parent ca743b3fc7
commit 3ce6fea0bd
5 changed files with 257 additions and 18 deletions

View File

@@ -4,6 +4,7 @@
import { useEffect, useMemo, useState } from "react";
import { useIsMobile } from "@/hooks/useIsMobile";
import {
addMonths,
eachDayOfInterval,
@@ -21,6 +22,7 @@ import { useTranslation } from "react-i18next";
import { useCalendarContext } from "../../contexts";
import { useCalendarLocale } from "../../hooks/useCalendarLocale";
import { Button } from "@gouvfr-lasuite/cunningham-react";
import { useLeftPanel } from "@/features/layouts/contexts/LeftPanelContext";
interface MiniCalendarProps {
selectedDate: Date;
@@ -42,6 +44,9 @@ export const MiniCalendar = ({
}: MiniCalendarProps) => {
const { t } = useTranslation();
const { goToDate, currentDate } = useCalendarContext();
const leftPanel = useLeftPanel();
const isMobile = useIsMobile();
const { dateFnsLocale, firstDayOfWeek } = useCalendarLocale();
const [viewDate, setViewDate] = useState(selectedDate);
@@ -98,6 +103,9 @@ export const MiniCalendar = ({
const handleDayClick = (day: Date) => {
onDateSelect(day);
goToDate(day);
if (isMobile) {
leftPanel.setIsLeftPanelOpen(false);
}
};
return (

View File

@@ -24,6 +24,7 @@ import { InvitationResponseSection } from "./event-modal-sections/InvitationResp
import { FreeBusySection } from "./event-modal-sections/FreeBusySection";
import { SectionPills } from "./event-modal-sections/SectionPills";
import { useResourcePrincipals } from "@/features/resources/api/useResourcePrincipals";
import { useIsMobile } from "@/hooks/useIsMobile";
import type { EventModalProps, RecurringDeleteOption } from "./types";
import { SectionRow } from "./event-modal-sections/SectionRow";
@@ -41,6 +42,7 @@ export const EventModal = ({
}: EventModalProps) => {
const { t } = useTranslation();
const { user } = useAuth();
const isMobile = useIsMobile();
const [isLoading, setIsLoading] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
@@ -191,7 +193,7 @@ export const EventModal = ({
<Modal
isOpen={isOpen}
onClose={onClose}
size={ModalSize.MEDIUM}
size={isMobile ? ModalSize.FULL : ModalSize.MEDIUM}
title={
mode === "create"
? t("calendar.event.createTitle")

View File

@@ -9,6 +9,7 @@
* - Click to create (dateClick)
* - Select range to create (select)
* - Custom toolbar with navigation and view selection
* - Mobile-optimized views (1 day, 2 days, list)
*
* Next.js consideration: This component must be client-side only
* due to DOM manipulation. Use dynamic import with ssr: false if needed.
@@ -16,19 +17,57 @@
import "@event-calendar/core/index.css";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCalendarContext } from "../../contexts/CalendarContext";
import { useCalendarLocale } from "../../hooks/useCalendarLocale";
import type { CalDavCalendar } from "../../services/dav/types/caldav-service";
import type { CalDavExtendedProps } from "../../services/dav/EventCalendarAdapter";
import type { EventCalendarEvent } from "../../services/dav/types/event-calendar";
import { useIsMobile } from "@/hooks/useIsMobile";
import { EventModal } from "./EventModal";
import { SchedulerToolbar } from "./SchedulerToolbar";
import type { SchedulerProps, EventModalState } from "./types";
import type { SchedulerProps, EventModalState, MobileListEvent } from "./types";
import { useSchedulerHandlers } from "./hooks/useSchedulerHandlers";
import {
useSchedulerInit,
useSchedulingCapabilitiesCheck,
} from "./hooks/useSchedulerInit";
import { useMobileNavigation } from "./hooks/useMobileNavigation";
const MobileToolbar = dynamic(
() =>
import("./mobile/MobileToolbar").then((m) => ({
default: m.MobileToolbar,
})),
{ ssr: false },
);
const WeekDayBar = dynamic(
() =>
import("./mobile/WeekDayBar").then((m) => ({ default: m.WeekDayBar })),
{ ssr: false },
);
const FloatingActionButton = dynamic(
() =>
import("./mobile/FloatingActionButton").then((m) => ({
default: m.FloatingActionButton,
})),
{ ssr: false },
);
const MobileListView = dynamic(
() =>
import("./mobile/MobileListView").then((m) => ({
default: m.MobileListView,
})),
{ ssr: false },
);
const BROWSER_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
const {
@@ -38,9 +77,13 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
visibleCalendarUrls,
isConnected,
calendarRef: contextCalendarRef,
currentDate,
setCurrentDate,
} = useCalendarContext();
const isMobile = useIsMobile();
const { firstDayOfWeek, intlLocale } = useCalendarLocale();
const containerRef = useRef<HTMLDivElement>(null);
const calendarRef = contextCalendarRef;
const [calendarUrl, setCalendarUrl] = useState(defaultCalendarUrl || "");
@@ -103,8 +146,15 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
view?: { type: string; title: string };
}) => {
// Update current date for MiniCalendar sync
const midTime = (info.start.getTime() + info.end.getTime()) / 2;
setCurrentDate(new Date(midTime));
// Use start for short views (day, 2-day) to avoid mid-point drift,
// use midpoint for longer views (week, month) for better centering
const durationMs = info.end.getTime() - info.start.getTime();
const threeDaysMs = 3 * 24 * 60 * 60 * 1000;
const syncDate =
durationMs <= threeDaysMs
? info.start
: new Date((info.start.getTime() + info.end.getTime()) / 2);
setCurrentDate(syncDate);
// Update toolbar state
if (calendarRef.current) {
@@ -118,6 +168,12 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
[setCurrentDate, calendarRef],
);
// Counter incremented each time the calendar finishes loading events
const [eventsLoadedCounter, setEventsLoadedCounter] = useState(0);
const handleEventsLoaded = useCallback(() => {
setEventsLoadedCounter((c) => c + 1);
}, []);
// Initialize calendar
// Cast handlers to bypass library type differences between specific event types and unknown
useSchedulerInit({
@@ -129,12 +185,14 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
adapter,
visibleCalendarUrlsRef,
davCalendarsRef,
initialView: isMobile ? "timeGridDay" : "timeGridWeek",
setCurrentDate: handleDatesSet,
handleEventClick: handleEventClick as (info: unknown) => void,
handleEventDrop: handleEventDrop as unknown as (info: unknown) => void,
handleEventResize: handleEventResize as unknown as (info: unknown) => void,
handleDateClick: handleDateClick as (info: unknown) => void,
handleSelect: handleSelect as (info: unknown) => void,
onEventsLoaded: handleEventsLoaded,
});
// Update toolbar title on initial render
@@ -151,32 +209,173 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
// Update eventFilter when visible calendars change
useEffect(() => {
if (calendarRef.current) {
// The refs are already updated (synchronously during render)
// Now trigger a re-evaluation of eventFilter by calling refetchEvents
calendarRef.current.refetchEvents();
}
}, [visibleCalendarUrls, davCalendars]);
const handleViewChange = useCallback((view: string) => {
setCurrentView(view);
}, []);
const handleViewChange = useCallback(
(view: string) => {
calendarRef.current?.setOption("view", view);
setCurrentView(view);
},
[calendarRef],
);
// Auto-switch view when crossing mobile/desktop breakpoint
const prevIsMobileRef = useRef(isMobile);
useEffect(() => {
if (prevIsMobileRef.current === isMobile) return;
prevIsMobileRef.current = isMobile;
const targetView = isMobile ? "timeGridDay" : "timeGridWeek";
handleViewChange(targetView);
}, [isMobile, handleViewChange]);
// Mobile list view events (extracted from ref via effect to avoid ref access during render)
const [listEvents, setListEvents] = useState<MobileListEvent[]>([]);
const isListView = isMobile && currentView === "listWeek";
const currentDateMs = currentDate.getTime();
useEffect(() => {
if (!isListView || !calendarRef.current) {
setListEvents([]);
return;
}
const extractTitle = (
title: string | { html: string } | { domNodes: Node[] } | undefined,
): string => {
if (!title) return "";
if (typeof title === "string") return title;
if ("html" in title) {
const div = document.createElement("div");
div.innerHTML = title.html;
return div.textContent || "";
}
return "";
};
const rawEvents = calendarRef.current.getEvents();
const parsed: MobileListEvent[] = rawEvents.map((e) => ({
id: String(e.id),
title: extractTitle(e.title),
start: new Date(e.start),
end: e.end ? new Date(e.end) : new Date(e.start),
allDay: e.allDay ?? false,
backgroundColor: e.backgroundColor || "#2563eb",
extendedProps: e.extendedProps ?? {},
}));
setListEvents(parsed);
}, [isListView, currentDateMs, visibleCalendarUrls, eventsLoadedCounter]);
// Mobile navigation
const {
weekDays,
handleWeekPrev,
handleWeekNext,
handleDayClick,
handleTodayClick,
} = useMobileNavigation({
currentDate,
firstDayOfWeek,
calendarRef,
});
// FAB click: open create modal with current date/time
const handleFabClick = useCallback(() => {
const now = new Date();
const startDate = new Date(currentDate);
startDate.setHours(now.getHours(), 0, 0, 0);
const endDate = new Date(startDate);
endDate.setHours(startDate.getHours() + 1);
const defaultUrl = calendarUrl || davCalendars[0]?.url || "";
setModalState({
isOpen: true,
mode: "create",
event: {
uid: crypto.randomUUID(),
stamp: { date: new Date() },
start: { date: startDate, type: "DATE-TIME" },
end: { date: endDate, type: "DATE-TIME" },
},
calendarUrl: defaultUrl,
});
}, [currentDate, calendarUrl, davCalendars]);
// Mobile list view event click
const handleMobileEventClick = useCallback(
(eventId: string, extendedProps: Record<string, unknown>) => {
const events = calendarRef.current?.getEvents() ?? [];
const event = events.find((e) => String(e.id) === eventId);
if (!event) return;
const extProps = extendedProps as CalDavExtendedProps;
const icsEvent = adapter.toIcsEvent(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, calendarRef],
);
return (
<div className="scheduler">
<SchedulerToolbar
calendarRef={calendarRef}
currentView={currentView}
viewTitle={viewTitle}
onViewChange={handleViewChange}
/>
{isMobile ? (
<>
<MobileToolbar
calendarRef={calendarRef}
currentView={currentView}
currentDate={currentDate}
onViewChange={handleViewChange}
onWeekPrev={handleWeekPrev}
onWeekNext={handleWeekNext}
onTodayClick={handleTodayClick}
/>
<WeekDayBar
currentDate={currentDate}
currentView={currentView}
intlLocale={intlLocale}
weekDays={weekDays}
onDayClick={handleDayClick}
/>
</>
) : (
<SchedulerToolbar
calendarRef={calendarRef}
currentView={currentView}
viewTitle={viewTitle}
onViewChange={handleViewChange}
/>
)}
{isListView && (
<MobileListView
weekDays={weekDays}
events={listEvents}
intlLocale={intlLocale}
onEventClick={handleMobileEventClick}
/>
)}
<div
ref={containerRef}
id="event-calendar"
className="scheduler__calendar"
style={{ height: "calc(100vh - 52px - 90px)" }}
style={{
display: isListView ? "none" : undefined,
}}
/>
{isMobile && <FloatingActionButton onClick={handleFabClick} />}
<EventModal
isOpen={modalState.isOpen}
mode={modalState.mode}

View File

@@ -33,12 +33,14 @@ interface UseSchedulerInitProps {
adapter: EventCalendarAdapter;
visibleCalendarUrlsRef: MutableRefObject<Set<string>>;
davCalendarsRef: MutableRefObject<CalDavCalendar[]>;
initialView?: string;
setCurrentDate: (info: { start: Date; end: Date }) => void;
handleEventClick: (info: unknown) => void;
handleEventDrop: (info: unknown) => void;
handleEventResize: (info: unknown) => void;
handleDateClick: (info: unknown) => void;
handleSelect: (info: unknown) => void;
onEventsLoaded?: () => void;
}
// Helper to get current time as HH:MM string
@@ -56,12 +58,14 @@ export const useSchedulerInit = ({
adapter,
visibleCalendarUrlsRef,
davCalendarsRef,
initialView = "timeGridWeek",
setCurrentDate,
handleEventClick,
handleEventDrop,
handleEventResize,
handleDateClick,
handleSelect,
onEventsLoaded,
}: UseSchedulerInitProps) => {
const { t, i18n } = useTranslation();
const { calendarLocale, firstDayOfWeek, formatDayHeader } = useCalendarLocale();
@@ -78,6 +82,7 @@ export const useSchedulerInit = ({
handleDateClick,
handleSelect,
setCurrentDate,
onEventsLoaded,
});
// Update refs when handlers change (no effect dependencies = no calendar recreation)
@@ -89,6 +94,7 @@ export const useSchedulerInit = ({
handleDateClick,
handleSelect,
setCurrentDate,
onEventsLoaded,
};
});
@@ -100,10 +106,18 @@ export const useSchedulerInit = ({
[TimeGrid, DayGrid, List, Interaction],
{
// View configuration
view: "timeGridWeek",
view: initialView,
// Native toolbar disabled - using custom React toolbar (SchedulerToolbar)
headerToolbar: false,
// Custom views
views: {
timeGridTwoDays: {
type: "timeGridDay",
duration: { days: 2 },
},
},
// Locale & time settings
locale: calendarLocale,
firstDay: firstDayOfWeek,
@@ -246,7 +260,12 @@ export const useSchedulerInit = ({
},
],
// Loading state is handled internally by the calendar
// Notify when events finish loading (used by mobile list view)
loading: (isLoading: boolean) => {
if (!isLoading) {
handlersRef.current.onEventsLoaded?.();
}
},
}
);

View File

@@ -22,6 +22,10 @@
@use "./../features/calendar/components/scheduler/event-modal-sections/InvitationResponseSection";
@use "./../features/calendar/components/scheduler/event-modal-sections/SectionPill";
@use "./../features/calendar/components/scheduler/FreeBusyTimeline";
@use "./../features/calendar/components/scheduler/mobile/WeekDayBar";
@use "./../features/calendar/components/scheduler/mobile/MobileToolbar";
@use "./../features/calendar/components/scheduler/mobile/FloatingActionButton";
@use "./../features/calendar/components/scheduler/mobile/MobileListView";
@use "./../features/resources/components/Resources";
@use "./../features/settings/components/WorkingHoursSettings";
@use "./../pages/index" as *;
@@ -103,6 +107,13 @@ body {
height: 28px;
}
// Scoped to full-screen modals (mobile event modal uses FULL size)
.c__modal--full .c__modal__close button {
@media (max-width: 768px) {
right: 0 !important;
}
}
.c__modal__scroller {
padding-top: 1rem;
}