From 3ce6fea0bd5e9075b5b0cdf8b27c4939b7c5b4c3 Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Wed, 11 Mar 2026 10:43:37 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(front)=20integrate=20mobile=20views?= =?UTF-8?q?=20into=20Scheduler=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire mobile components into the main Scheduler, adapt useSchedulerInit for mobile viewport, update EventModal and MiniCalendar for responsive behavior. --- .../components/left-panel/MiniCalendar.tsx | 8 + .../components/scheduler/EventModal.tsx | 4 +- .../components/scheduler/Scheduler.tsx | 229 ++++++++++++++++-- .../scheduler/hooks/useSchedulerInit.ts | 23 +- .../apps/calendars/src/styles/globals.scss | 11 + 5 files changed, 257 insertions(+), 18 deletions(-) diff --git a/src/frontend/apps/calendars/src/features/calendar/components/left-panel/MiniCalendar.tsx b/src/frontend/apps/calendars/src/features/calendar/components/left-panel/MiniCalendar.tsx index 812e8e3..d1fbebc 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/left-panel/MiniCalendar.tsx +++ b/src/frontend/apps/calendars/src/features/calendar/components/left-panel/MiniCalendar.tsx @@ -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 ( diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/EventModal.tsx b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/EventModal.tsx index a7a14b1..a8ccfc6 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/EventModal.tsx +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/EventModal.tsx @@ -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 = ({ + 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(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([]); + 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) => { + 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 (
- + {isMobile ? ( + <> + + + + ) : ( + + )} + {isListView && ( + + )}
+ {isMobile && } + >; davCalendarsRef: MutableRefObject; + 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?.(); + } + }, } ); diff --git a/src/frontend/apps/calendars/src/styles/globals.scss b/src/frontend/apps/calendars/src/styles/globals.scss index 275639d..733d355 100644 --- a/src/frontend/apps/calendars/src/styles/globals.scss +++ b/src/frontend/apps/calendars/src/styles/globals.scss @@ -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; }