(ui) improve event display and add some niceties to the modal

This commit is contained in:
Sylvain Zimmer
2026-02-09 22:19:20 +01:00
parent 3051100f8a
commit 659029dd1f
9 changed files with 369 additions and 36 deletions

View File

@@ -229,6 +229,7 @@ export const EventModal = ({
value: cal.url, value: cal.url,
label: cal.displayName || cal.url, label: cal.displayName || cal.url,
}))} }))}
clearable={false}
variant="classic" variant="classic"
fullWidth fullWidth
/> />

View File

@@ -34,7 +34,7 @@ export const DescriptionSection = ({
placeholder={t("calendar.event.descriptionPlaceholder")} placeholder={t("calendar.event.descriptionPlaceholder")}
value={description} value={description}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
rows={3} rows={5}
fullWidth fullWidth
variant="classic" variant="classic"
hideLabel hideLabel

View File

@@ -1,6 +1,8 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next"; 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 { SectionRow } from "./SectionRow";
import { extractUrl } from "../utils/eventDisplayRules";
interface LocationSectionProps { interface LocationSectionProps {
location: string; location: string;
@@ -18,6 +20,7 @@ export const LocationSection = ({
onToggle, onToggle,
}: LocationSectionProps) => { }: LocationSectionProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const detectedUrl = useMemo(() => extractUrl(location), [location]);
return ( return (
<SectionRow <SectionRow
@@ -28,15 +31,30 @@ export const LocationSection = ({
isExpanded={isExpanded} isExpanded={isExpanded}
onToggle={onToggle} onToggle={onToggle}
> >
<Input <div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
label={t("calendar.event.location")} <Input
hideLabel label={t("calendar.event.location")}
value={location} hideLabel
placeholder={t("calendar.event.locationPlaceholder")} value={location}
onChange={(e) => onChange(e.target.value)} placeholder={t("calendar.event.locationPlaceholder")}
variant="classic" onChange={(e) => onChange(e.target.value)}
fullWidth variant="classic"
/> fullWidth
/>
{detectedUrl && (
<Button
size="small"
color="neutral"
variant="tertiary"
icon={<span className="material-icons">open_in_new</span>}
href={detectedUrl}
target="_blank"
rel="noopener noreferrer"
>
{t("calendar.event.openLocation", "Open")}
</Button>
)}
</div>
</SectionRow> </SectionRow>
); );
}; };

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { import type {
IcsEvent, IcsEvent,
IcsAttendee, IcsAttendee,
@@ -11,6 +11,7 @@ import type {
} from "ts-ics"; } from "ts-ics";
import type { EventCalendarAdapter } from "../../../services/dav/EventCalendarAdapter"; import type { EventCalendarAdapter } from "../../../services/dav/EventCalendarAdapter";
import type { AttachmentMeta, EventFormSectionId } from "../types"; import type { AttachmentMeta, EventFormSectionId } from "../types";
import { cleanEventForDisplay } from "../utils/eventDisplayRules";
import { import {
formatDateTimeLocal, formatDateTimeLocal,
formatDateLocal, formatDateLocal,
@@ -40,6 +41,8 @@ export const useEventForm = ({
const [location, setLocation] = useState(""); const [location, setLocation] = useState("");
const [startDateTime, setStartDateTime] = useState(""); const [startDateTime, setStartDateTime] = useState("");
const [endDateTime, setEndDateTime] = 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 [selectedCalendarUrl, setSelectedCalendarUrl] = useState(calendarUrl);
const [isAllDay, setIsAllDay] = useState(false); const [isAllDay, setIsAllDay] = useState(false);
const [attendees, setAttendees] = useState<IcsAttendee[]>([]); const [attendees, setAttendees] = useState<IcsAttendee[]>([]);
@@ -60,13 +63,20 @@ export const useEventForm = ({
// Reset form when event changes // Reset form when event changes
useEffect(() => { useEffect(() => {
setTitle(event?.summary || ""); setTitle(event?.summary || "");
setDescription(event?.description || "");
setLocation(event?.location || "");
setSelectedCalendarUrl(calendarUrl); setSelectedCalendarUrl(calendarUrl);
setStatus(event?.status || "CONFIRMED"); setStatus(event?.status || "CONFIRMED");
setVisibility(event?.class || "PUBLIC"); setVisibility(event?.class || "PUBLIC");
setAvailability(event?.timeTransparent || "OPAQUE"); 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 || []); setAlarms(event?.alarms || []);
setAttachments([]); setAttachments([]);
@@ -89,10 +99,10 @@ export const useEventForm = ({
setIsAllDay(eventIsAllDay); setIsAllDay(eventIsAllDay);
const initialExpanded = new Set<EventFormSectionId>(); const initialExpanded = new Set<EventFormSectionId>();
if (event?.location) initialExpanded.add("location"); if (cleaned.location) initialExpanded.add("location");
if (event?.description) initialExpanded.add("description"); if (cleaned.description) initialExpanded.add("description");
if (event?.recurrenceRule) initialExpanded.add("recurrence"); if (event?.recurrenceRule) initialExpanded.add("recurrence");
if (event?.url) initialExpanded.add("videoConference"); if (cleaned.url) initialExpanded.add("videoConference");
if (mode === "create") { if (mode === "create") {
initialExpanded.add("attendees"); initialExpanded.add("attendees");
@@ -107,7 +117,10 @@ export const useEventForm = ({
setExpandedSections(initialExpanded); 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) { if (event?.start?.date) {
const startDate = const startDate =
event.start.date instanceof Date event.start.date instanceof Date
@@ -116,15 +129,15 @@ export const useEventForm = ({
const isFakeUtc = Boolean(event.start.local?.timezone); const isFakeUtc = Boolean(event.start.local?.timezone);
if (eventIsAllDay) { if (eventIsAllDay) {
setStartDateTime(formatDateLocal(startDate, isFakeUtc)); initStart = formatDateLocal(startDate, isFakeUtc);
} else { } else {
setStartDateTime(formatDateTimeLocal(startDate, isFakeUtc)); initStart = formatDateTimeLocal(startDate, isFakeUtc);
} }
} else { } else {
if (eventIsAllDay) { if (eventIsAllDay) {
setStartDateTime(formatDateLocal(new Date())); initStart = formatDateLocal(new Date());
} else { } else {
setStartDateTime(formatDateTimeLocal(new Date())); initStart = formatDateTimeLocal(new Date());
} }
} }
@@ -138,19 +151,28 @@ export const useEventForm = ({
if (eventIsAllDay) { if (eventIsAllDay) {
const displayEndDate = new Date(endDate); const displayEndDate = new Date(endDate);
displayEndDate.setUTCDate(displayEndDate.getUTCDate() - 1); displayEndDate.setUTCDate(displayEndDate.getUTCDate() - 1);
setEndDateTime(formatDateLocal(displayEndDate, isFakeUtc)); initEnd = formatDateLocal(displayEndDate, isFakeUtc);
} else { } else {
setEndDateTime(formatDateTimeLocal(endDate, isFakeUtc)); initEnd = formatDateTimeLocal(endDate, isFakeUtc);
} }
} else { } else {
if (eventIsAllDay) { if (eventIsAllDay) {
setEndDateTime(formatDateLocal(new Date())); initEnd = formatDateLocal(new Date());
} else { } else {
const defaultEnd = new Date(); const defaultEnd = new Date();
defaultEnd.setHours(defaultEnd.getHours() + 1); 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]); }, [event, calendarUrl, mode, organizer?.email]);
const toggleSection = useCallback((sectionId: EventFormSectionId) => { const toggleSection = useCallback((sectionId: EventFormSectionId) => {
@@ -201,15 +223,23 @@ export const useEventForm = ({
setIsAllDay(newIsAllDay); setIsAllDay(newIsAllDay);
if (newIsAllDay) { if (newIsAllDay) {
// Stash current datetimes before stripping the time portion
savedDateTimesRef.current = { start: startDateTime, end: endDateTime };
const start = parseDateTimeLocal(startDateTime); const start = parseDateTimeLocal(startDateTime);
const end = parseDateTimeLocal(endDateTime); const end = parseDateTimeLocal(endDateTime);
setStartDateTime(formatDateLocal(start)); setStartDateTime(formatDateLocal(start));
setEndDateTime(formatDateLocal(end)); setEndDateTime(formatDateLocal(end));
} else { } else {
const start = parseDateLocal(startDateTime); // Restore stashed datetimes to preserve the original time of day
const end = parseDateLocal(endDateTime); if (savedDateTimesRef.current.start) {
setStartDateTime(formatDateTimeLocal(start)); setStartDateTime(savedDateTimesRef.current.start);
setEndDateTime(formatDateTimeLocal(end)); setEndDateTime(savedDateTimesRef.current.end);
} else {
const start = parseDateLocal(startDateTime);
const end = parseDateLocal(endDateTime);
setStartDateTime(formatDateTimeLocal(start));
setEndDateTime(formatDateTimeLocal(end));
}
} }
}, },
[startDateTime, endDateTime], [startDateTime, endDateTime],
@@ -219,6 +249,7 @@ export const useEventForm = ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { duration: _duration, ...eventWithoutDuration } = event ?? {}; const { duration: _duration, ...eventWithoutDuration } = event ?? {};
if (isAllDay) { if (isAllDay) {
const startDate = parseDateLocal(startDateTime); const startDate = parseDateLocal(startDateTime);
const endDate = parseDateLocal(endDateTime); const endDate = parseDateLocal(endDateTime);

View File

@@ -19,6 +19,7 @@ import type { CalDavService } from "../../../services/dav/CalDavService";
import type { CalDavCalendar } from "../../../services/dav/types/caldav-service"; import type { CalDavCalendar } from "../../../services/dav/types/caldav-service";
import type { EventCalendarEvent, EventCalendarFetchInfo } from "../../../services/dav/types/event-calendar"; import type { EventCalendarEvent, EventCalendarFetchInfo } from "../../../services/dav/types/event-calendar";
import type { CalendarApi } from "../types"; import type { CalendarApi } from "../types";
import { createEventContent, type EventContentInfo } from "../utils/eventContent";
type ECEvent = EventCalendarEvent; type ECEvent = EventCalendarEvent;
@@ -132,6 +133,9 @@ export const useSchedulerInit = ({
handlersRef.current.setCurrentDate(info); handlersRef.current.setCurrentDate(info);
}, },
// Custom event content — adapts layout to event duration
eventContent: (info: EventContentInfo) => createEventContent(info),
// Event display // Event display
dayMaxEvents: true, dayMaxEvents: true,
nowIndicator: true, nowIndicator: true,

View File

@@ -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 { .ec-event {
border-radius: 6px; border-radius: 6px;
box-shadow: none; 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; font-weight: 600;
} }
.ec-event-time { .ec-custom__details,
.ec-custom__time {
font-weight: 400; 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: 4560 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;
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------

View File

@@ -0,0 +1,99 @@
/**
* Custom event content renderer for the scheduler.
*
* Adapts event display based on duration:
* - 1530 min → single line: "Title, HH:MM"
* - 4560 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
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: `<div class="ec-custom ec-custom--allday"><span class="ec-custom__title">${esc(title)}</span></div>`,
};
}
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:
`<div class="ec-custom ec-custom--compact">` +
`<span class="ec-custom__title">${esc(title)}</span>` +
`<span class="ec-custom__sep">, </span>` +
`<span class="ec-custom__time">${esc(time)}</span>` +
`</div>`,
};
}
const details = location ? `${time}, ${location}` : time;
// 4560 min — two single lines
if (durationMin <= 60) {
return {
html:
`<div class="ec-custom ec-custom--medium">` +
`<div class="ec-custom__title">${esc(title)}</div>` +
`<div class="ec-custom__details">${esc(details)}</div>` +
`</div>`,
};
}
// 75 min+ — title wraps up to 2 lines, then details
return {
html:
`<div class="ec-custom ec-custom--large">` +
`<div class="ec-custom__title">${esc(title)}</div>` +
`<div class="ec-custom__details">${esc(details)}</div>` +
`</div>`,
};
};

View File

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

View File

@@ -113,6 +113,7 @@
"titlePlaceholder": "Event title", "titlePlaceholder": "Event title",
"location": "Location", "location": "Location",
"locationPlaceholder": "Location", "locationPlaceholder": "Location",
"openLocation": "Open",
"description": "Description", "description": "Description",
"descriptionPlaceholder": "Description", "descriptionPlaceholder": "Description",
"start": "Start", "start": "Start",
@@ -738,6 +739,7 @@
"titlePlaceholder": "Titre de l'événement", "titlePlaceholder": "Titre de l'événement",
"location": "Lieu", "location": "Lieu",
"locationPlaceholder": "Lieu", "locationPlaceholder": "Lieu",
"openLocation": "Ouvrir",
"description": "Description", "description": "Description",
"descriptionPlaceholder": "Description", "descriptionPlaceholder": "Description",
"start": "Début", "start": "Début",
@@ -1110,6 +1112,7 @@
"titlePlaceholder": "Evenement titel", "titlePlaceholder": "Evenement titel",
"location": "Locatie", "location": "Locatie",
"locationPlaceholder": "Locatie", "locationPlaceholder": "Locatie",
"openLocation": "Openen",
"description": "Beschrijving", "description": "Beschrijving",
"descriptionPlaceholder": "Beschrijving", "descriptionPlaceholder": "Beschrijving",
"start": "Start", "start": "Start",