(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,
label: cal.displayName || cal.url,
}))}
clearable={false}
variant="classic"
fullWidth
/>

View File

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

View File

@@ -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 (
<SectionRow
@@ -28,15 +31,30 @@ export const LocationSection = ({
isExpanded={isExpanded}
onToggle={onToggle}
>
<Input
label={t("calendar.event.location")}
hideLabel
value={location}
placeholder={t("calendar.event.locationPlaceholder")}
onChange={(e) => onChange(e.target.value)}
variant="classic"
fullWidth
/>
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<Input
label={t("calendar.event.location")}
hideLabel
value={location}
placeholder={t("calendar.event.locationPlaceholder")}
onChange={(e) => onChange(e.target.value)}
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>
);
};

View File

@@ -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<IcsAttendee[]>([]);
@@ -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<EventFormSectionId>();
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);

View File

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

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 {
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: 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",
"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",