From 659029dd1f4b813281e13fc7a1d72b9749fd9dda Mon Sep 17 00:00:00 2001 From: Sylvain Zimmer Date: Mon, 9 Feb 2026 22:19:20 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(ui)=20improve=20event=20display=20and?= =?UTF-8?q?=20add=20some=20niceties=20to=20the=20modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/scheduler/EventModal.tsx | 1 + .../DescriptionSection.tsx | 2 +- .../event-modal-sections/LocationSection.tsx | 38 +++++-- .../scheduler/hooks/useEventForm.ts | 71 ++++++++---- .../scheduler/hooks/useSchedulerInit.ts | 4 + .../components/scheduler/scheduler-theme.scss | 84 +++++++++++++- .../scheduler/utils/eventContent.ts | 99 +++++++++++++++++ .../scheduler/utils/eventDisplayRules.ts | 103 ++++++++++++++++++ .../src/features/i18n/translations.json | 3 + 9 files changed, 369 insertions(+), 36 deletions(-) create mode 100644 src/frontend/apps/calendars/src/features/calendar/components/scheduler/utils/eventContent.ts create mode 100644 src/frontend/apps/calendars/src/features/calendar/components/scheduler/utils/eventDisplayRules.ts 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 f76c970..f88b32a 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 @@ -229,6 +229,7 @@ export const EventModal = ({ value: cal.url, label: cal.displayName || cal.url, }))} + clearable={false} variant="classic" fullWidth /> diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/event-modal-sections/DescriptionSection.tsx b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/event-modal-sections/DescriptionSection.tsx index d63a451..19ed836 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/event-modal-sections/DescriptionSection.tsx +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/event-modal-sections/DescriptionSection.tsx @@ -34,7 +34,7 @@ export const DescriptionSection = ({ placeholder={t("calendar.event.descriptionPlaceholder")} value={description} onChange={(e) => onChange(e.target.value)} - rows={3} + rows={5} fullWidth variant="classic" hideLabel diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/event-modal-sections/LocationSection.tsx b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/event-modal-sections/LocationSection.tsx index 96bb2be..05ff414 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/event-modal-sections/LocationSection.tsx +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/event-modal-sections/LocationSection.tsx @@ -1,6 +1,8 @@ +import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Input } from "@gouvfr-lasuite/cunningham-react"; +import { Button, Input } from "@gouvfr-lasuite/cunningham-react"; import { SectionRow } from "./SectionRow"; +import { extractUrl } from "../utils/eventDisplayRules"; interface LocationSectionProps { location: string; @@ -18,6 +20,7 @@ export const LocationSection = ({ onToggle, }: LocationSectionProps) => { const { t } = useTranslation(); + const detectedUrl = useMemo(() => extractUrl(location), [location]); return ( - onChange(e.target.value)} - variant="classic" - fullWidth - /> +
+ onChange(e.target.value)} + variant="classic" + fullWidth + /> + {detectedUrl && ( + + )} +
); }; diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/hooks/useEventForm.ts b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/hooks/useEventForm.ts index 664a21b..4805705 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/hooks/useEventForm.ts +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/hooks/useEventForm.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { IcsEvent, IcsAttendee, @@ -11,6 +11,7 @@ import type { } from "ts-ics"; import type { EventCalendarAdapter } from "../../../services/dav/EventCalendarAdapter"; import type { AttachmentMeta, EventFormSectionId } from "../types"; +import { cleanEventForDisplay } from "../utils/eventDisplayRules"; import { formatDateTimeLocal, formatDateLocal, @@ -40,6 +41,8 @@ export const useEventForm = ({ const [location, setLocation] = useState(""); const [startDateTime, setStartDateTime] = useState(""); const [endDateTime, setEndDateTime] = useState(""); + // Stash full datetime strings so toggling all-day off restores the original times + const savedDateTimesRef = useRef({ start: "", end: "" }); const [selectedCalendarUrl, setSelectedCalendarUrl] = useState(calendarUrl); const [isAllDay, setIsAllDay] = useState(false); const [attendees, setAttendees] = useState([]); @@ -60,13 +63,20 @@ export const useEventForm = ({ // Reset form when event changes useEffect(() => { setTitle(event?.summary || ""); - setDescription(event?.description || ""); - setLocation(event?.location || ""); setSelectedCalendarUrl(calendarUrl); setStatus(event?.status || "CONFIRMED"); setVisibility(event?.class || "PUBLIC"); setAvailability(event?.timeTransparent || "OPAQUE"); - setVideoConferenceUrl(event?.url || ""); + + // Clean and deduplicate description / location / url for display + const cleaned = cleanEventForDisplay({ + description: event?.description || "", + location: event?.location || "", + url: event?.url || "", + }); + setDescription(cleaned.description); + setLocation(cleaned.location); + setVideoConferenceUrl(cleaned.url); setAlarms(event?.alarms || []); setAttachments([]); @@ -89,10 +99,10 @@ export const useEventForm = ({ setIsAllDay(eventIsAllDay); const initialExpanded = new Set(); - if (event?.location) initialExpanded.add("location"); - if (event?.description) initialExpanded.add("description"); + if (cleaned.location) initialExpanded.add("location"); + if (cleaned.description) initialExpanded.add("description"); if (event?.recurrenceRule) initialExpanded.add("recurrence"); - if (event?.url) initialExpanded.add("videoConference"); + if (cleaned.url) initialExpanded.add("videoConference"); if (mode === "create") { initialExpanded.add("attendees"); @@ -107,7 +117,10 @@ export const useEventForm = ({ setExpandedSections(initialExpanded); - // Parse start/end dates + // Parse start/end dates and stash full datetime strings for all-day toggle + let initStart = ""; + let initEnd = ""; + if (event?.start?.date) { const startDate = event.start.date instanceof Date @@ -116,15 +129,15 @@ export const useEventForm = ({ const isFakeUtc = Boolean(event.start.local?.timezone); if (eventIsAllDay) { - setStartDateTime(formatDateLocal(startDate, isFakeUtc)); + initStart = formatDateLocal(startDate, isFakeUtc); } else { - setStartDateTime(formatDateTimeLocal(startDate, isFakeUtc)); + initStart = formatDateTimeLocal(startDate, isFakeUtc); } } else { if (eventIsAllDay) { - setStartDateTime(formatDateLocal(new Date())); + initStart = formatDateLocal(new Date()); } else { - setStartDateTime(formatDateTimeLocal(new Date())); + initStart = formatDateTimeLocal(new Date()); } } @@ -138,19 +151,28 @@ export const useEventForm = ({ if (eventIsAllDay) { const displayEndDate = new Date(endDate); displayEndDate.setUTCDate(displayEndDate.getUTCDate() - 1); - setEndDateTime(formatDateLocal(displayEndDate, isFakeUtc)); + initEnd = formatDateLocal(displayEndDate, isFakeUtc); } else { - setEndDateTime(formatDateTimeLocal(endDate, isFakeUtc)); + initEnd = formatDateTimeLocal(endDate, isFakeUtc); } } else { if (eventIsAllDay) { - setEndDateTime(formatDateLocal(new Date())); + initEnd = formatDateLocal(new Date()); } else { const defaultEnd = new Date(); defaultEnd.setHours(defaultEnd.getHours() + 1); - setEndDateTime(formatDateTimeLocal(defaultEnd)); + initEnd = formatDateTimeLocal(defaultEnd); } } + + setStartDateTime(initStart); + setEndDateTime(initEnd); + + // For timed events, stash the full datetime so toggling all-day off + // can restore the original times instead of defaulting to midnight. + if (!eventIsAllDay) { + savedDateTimesRef.current = { start: initStart, end: initEnd }; + } }, [event, calendarUrl, mode, organizer?.email]); const toggleSection = useCallback((sectionId: EventFormSectionId) => { @@ -201,15 +223,23 @@ export const useEventForm = ({ setIsAllDay(newIsAllDay); if (newIsAllDay) { + // Stash current datetimes before stripping the time portion + savedDateTimesRef.current = { start: startDateTime, end: endDateTime }; const start = parseDateTimeLocal(startDateTime); const end = parseDateTimeLocal(endDateTime); setStartDateTime(formatDateLocal(start)); setEndDateTime(formatDateLocal(end)); } else { - const start = parseDateLocal(startDateTime); - const end = parseDateLocal(endDateTime); - setStartDateTime(formatDateTimeLocal(start)); - setEndDateTime(formatDateTimeLocal(end)); + // Restore stashed datetimes to preserve the original time of day + if (savedDateTimesRef.current.start) { + setStartDateTime(savedDateTimesRef.current.start); + setEndDateTime(savedDateTimesRef.current.end); + } else { + const start = parseDateLocal(startDateTime); + const end = parseDateLocal(endDateTime); + setStartDateTime(formatDateTimeLocal(start)); + setEndDateTime(formatDateTimeLocal(end)); + } } }, [startDateTime, endDateTime], @@ -219,6 +249,7 @@ export const useEventForm = ({ // eslint-disable-next-line @typescript-eslint/no-unused-vars const { duration: _duration, ...eventWithoutDuration } = event ?? {}; + if (isAllDay) { const startDate = parseDateLocal(startDateTime); const endDate = parseDateLocal(endDateTime); diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/hooks/useSchedulerInit.ts b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/hooks/useSchedulerInit.ts index ee8c4dc..cf0e88a 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/hooks/useSchedulerInit.ts +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/hooks/useSchedulerInit.ts @@ -19,6 +19,7 @@ import type { CalDavService } from "../../../services/dav/CalDavService"; import type { CalDavCalendar } from "../../../services/dav/types/caldav-service"; import type { EventCalendarEvent, EventCalendarFetchInfo } from "../../../services/dav/types/event-calendar"; import type { CalendarApi } from "../types"; +import { createEventContent, type EventContentInfo } from "../utils/eventContent"; type ECEvent = EventCalendarEvent; @@ -132,6 +133,9 @@ export const useSchedulerInit = ({ handlersRef.current.setCurrentDate(info); }, + // Custom event content — adapts layout to event duration + eventContent: (info: EventContentInfo) => createEventContent(info), + // Event display dayMaxEvents: true, nowIndicator: true, diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/scheduler-theme.scss b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/scheduler-theme.scss index 4b6c3fb..191c37d 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/scheduler-theme.scss +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/scheduler-theme.scss @@ -122,21 +122,95 @@ } // ----------------------------------------------------------------------------- -// 4. Events styling - Rounded corners, no shadow, bold title +// 4. Events styling - Custom duration-aware content (see utils/eventContent.ts) // ----------------------------------------------------------------------------- .ec-event { border-radius: 6px; box-shadow: none; - padding: 8px 10px; + overflow: hidden; + + padding-left: 4px !important; + + // Compact (≤ 30 min): vertically center the single line + &:has(.ec-custom--compact) { + display: flex; + align-items: center; + } + + // Medium / large: small top padding + &:has(.ec-custom--medium), + &:has(.ec-custom--large) { + padding-top: 2px; + } } -.ec-event-title { +.ec-event-body { + overflow: hidden; + width: 100%; +} + +// --- Shared custom-content styles --- +.ec-custom__title { font-weight: 600; } -.ec-event-time { +.ec-custom__details, +.ec-custom__time { font-weight: 400; - opacity: 0.95; + opacity: 0.9; +} + +// --- Compact: ≤ 30 min — single line --- +.ec-custom--compact { + white-space: nowrap; + overflow: hidden; + text-overflow: clip; + font-size: 0.75rem; + line-height: 1.4; +} + +// --- Medium: 45–60 min — two truncated lines --- +.ec-custom--medium { + overflow: hidden; + font-size: 0.8rem; + line-height: 1.30; + + .ec-custom__title, + .ec-custom__details { + white-space: nowrap; + overflow: hidden; + text-overflow: clip; + } +} + +// --- Large: 75 min+ — title wraps up to 2 lines, then details --- +.ec-custom--large { + overflow: hidden; + font-size: 0.85rem; + line-height: 1.35; + + .ec-custom__title { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .ec-custom__details { + white-space: nowrap; + overflow: hidden; + text-overflow: clip; + margin-top: 1px; + } +} + +// --- All-day events --- +.ec-custom--allday { + white-space: nowrap; + overflow: hidden; + text-overflow: clip; + font-size: 0.8rem; + line-height: 1.3; } // ----------------------------------------------------------------------------- diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/utils/eventContent.ts b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/utils/eventContent.ts new file mode 100644 index 0000000..2649abf --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/utils/eventContent.ts @@ -0,0 +1,99 @@ +/** + * Custom event content renderer for the scheduler. + * + * Adapts event display based on duration: + * - 15–30 min → single line: "Title, HH:MM" + * - 45–60 min → two lines: title / time + location + * - 75 min+ → title (up to 2 lines) / time + location + */ + +import type { CalDavExtendedProps } from '../../../services/dav/EventCalendarAdapter'; +import type { + EventCalendarEvent, + EventCalendarContent, +} from '../../../services/dav/types/event-calendar'; +import { cleanEventForDisplay } from './eventDisplayRules'; + +export interface EventContentInfo { + event: EventCalendarEvent; + timeText: string; + view: unknown; +} + +const esc = (s: string): string => + s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + +const fmtTime = (d: Date): string => + `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; + +export const createEventContent = ( + info: EventContentInfo, +): EventCalendarContent => { + const { event } = info; + const title = typeof event.title === 'string' ? event.title : ''; + + // All-day events: just show the title + if (event.allDay) { + return { + html: `
${esc(title)}
`, + }; + } + + const start = + event.start instanceof Date ? event.start : new Date(event.start); + const end = event.end + ? event.end instanceof Date + ? event.end + : new Date(event.end) + : start; + const durationMin = Math.round( + (end.getTime() - start.getTime()) / 60_000, + ); + + const extProps = event.extendedProps as CalDavExtendedProps | undefined; + const cleaned = cleanEventForDisplay({ + description: extProps?.description ?? '', + location: extProps?.location ?? '', + url: extProps?.url ?? '', + }); + const location = cleaned.location; + const time = fmtTime(start); + + // ≤ 30 min — single line: "Title, HH:MM" + if (durationMin <= 30) { + return { + html: + `
` + + `${esc(title)}` + + `, ` + + `${esc(time)}` + + `
`, + }; + } + + const details = location ? `${time}, ${location}` : time; + + // 45–60 min — two single lines + if (durationMin <= 60) { + return { + html: + `
` + + `
${esc(title)}
` + + `
${esc(details)}
` + + `
`, + }; + } + + // 75 min+ — title wraps up to 2 lines, then details + return { + html: + `
` + + `
${esc(title)}
` + + `
${esc(details)}
` + + `
`, + }; +}; diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/utils/eventDisplayRules.ts b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/utils/eventDisplayRules.ts new file mode 100644 index 0000000..8c2eadf --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/utils/eventDisplayRules.ts @@ -0,0 +1,103 @@ +/** + * Event display rules — clean and deduplicate fields before rendering. + * + * Single entry point: cleanEventForDisplay() + */ + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Prefixes stripped from the Location field (case-insensitive). */ +const LOCATION_PREFIXES_TO_STRIP = [ + 'Pour participer à la visioconférence, cliquez sur ce lien : ', +]; + +/** + * Embedded conference block delimited by ~:~ markers. + * Contains a video-conference URL. Providers inject this in descriptions. + */ +const CONFERENCE_BLOCK_RE = + /-::~:~::~:~[:~]*::~:~::-\s*\n.*?(https:\/\/\S+).*?\n.*?-::~:~::~:~[:~]*::~:~::-/s; + +const URL_RE = /https?:\/\/[^\s]+/i; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type EventDisplayFields = { + description: string; + location: string; + url: string; +}; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Clean and deduplicate event fields for display. + * + * Applies in order: + * 1. Trim whitespace on all fields + * 2. Strip known prefixes from location + * 3. Extract embedded conference URL from description → url (if url empty) + * 4. Deduplicate: desc==location → empty desc, + * location==url → empty location, desc==url → empty desc + */ +export const cleanEventForDisplay = ( + raw: EventDisplayFields, +): EventDisplayFields => { + let description = raw.description.trim(); + let location = stripLocationPrefixes(raw.location.trim()); + let url = raw.url.trim(); + + // Extract embedded conference block from description + if (!url) { + const extracted = extractConferenceBlock(description); + if (extracted.url) { + description = extracted.description; + url = extracted.url; + } + } + + // Deduplicate across fields + if (description && description === location) description = ''; + if (location && location === url) location = ''; + if (description && description === url) description = ''; + + return { description, location, url }; +}; + +/** + * Extract the first URL found anywhere in a string. + */ +export const extractUrl = (text: string): string | null => { + const match = text.match(URL_RE); + return match ? match[0] : null; +}; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +const stripLocationPrefixes = (value: string): string => { + for (const prefix of LOCATION_PREFIXES_TO_STRIP) { + if (value.toLowerCase().startsWith(prefix.toLowerCase())) { + return value.slice(prefix.length).trim(); + } + } + return value; +}; + +const extractConferenceBlock = ( + text: string, +): { description: string; url: string | null } => { + const match = text.match(CONFERENCE_BLOCK_RE); + if (!match) return { description: text, url: null }; + return { + description: text.replace(match[0], '').trim(), + url: match[1] ?? null, + }; +}; diff --git a/src/frontend/apps/calendars/src/features/i18n/translations.json b/src/frontend/apps/calendars/src/features/i18n/translations.json index 54fb7a3..5fe2471 100644 --- a/src/frontend/apps/calendars/src/features/i18n/translations.json +++ b/src/frontend/apps/calendars/src/features/i18n/translations.json @@ -113,6 +113,7 @@ "titlePlaceholder": "Event title", "location": "Location", "locationPlaceholder": "Location", + "openLocation": "Open", "description": "Description", "descriptionPlaceholder": "Description", "start": "Start", @@ -738,6 +739,7 @@ "titlePlaceholder": "Titre de l'événement", "location": "Lieu", "locationPlaceholder": "Lieu", + "openLocation": "Ouvrir", "description": "Description", "descriptionPlaceholder": "Description", "start": "Début", @@ -1110,6 +1112,7 @@ "titlePlaceholder": "Evenement titel", "location": "Locatie", "locationPlaceholder": "Locatie", + "openLocation": "Openen", "description": "Beschrijving", "descriptionPlaceholder": "Beschrijving", "start": "Start",