Split remaining toolbox modules by responsibility
All checks were successful
Deploy Sokko G / deploy (push) Successful in 16s

This commit is contained in:
Shinuwa 2026-08-25 09:33:15 +02:00
parent 43a9e791c5
commit a0d7e80a1a
25 changed files with 2199 additions and 1992 deletions

View file

@ -1,433 +1,22 @@
// Rôle : fournit l'outil calendrier hebdomadaire/mensuel avec événements, récurrences et alertes.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
// Rôle : orchestre l'outil calendrier et délègue l'état métier à son contrôleur.
import { CalendarContent } from "./CalendarContent.jsx";
import {
CALENDAR_ALERT_MODES,
CALENDAR_COLORS,
WEEK_SLOT_MINUTES,
addDays,
formatLocalDateKey,
getCalendarOccurrences,
getLocalDayIndex,
getMonthEnd,
getMonthGridDays,
getMonthStart,
getWeekDays,
getWeekStart,
minutesToTimeValue,
parseLocalDateKey,
timeValueToMinutes
} from "./calendarUtils.js";
import { WEEK_SLOT_COUNT, getWeekGridLayout } from "./calendarLayout.js";
import {
createDefaultDraft,
getCalendarRepeatMode,
getDateSpanDays,
getDraftSelection,
normalizeDraftAlertOffsets,
normalizeDraftDateKey,
normalizeDraftMonthlyRepeatBy
} from "./calendarDraft.js";
import { CalendarModeChoice } from "./CalendarModeChoice.jsx";
import { useCalendarController } from "./useCalendarController.js";
export function CalendarModule({ toolboxId, moduleId, context, editing = false, setEditing, textExchangeOpen }) {
const data = context.normalizeCalendarData(context.getModuleData(toolboxId, moduleId, { initialized: false, viewMode: "week", events: [] }));
const textContent = context.moduleText?.calendar || {};
const [now, setNow] = useState(() => new Date());
const [displayedMonth, setDisplayedMonth] = useState(() => getMonthStart(new Date()));
const weekStart = useMemo(() => getWeekStart(new Date(), data.weekStartsOn), [data.weekStartsOn]);
const monthStart = useMemo(() => getMonthStart(displayedMonth), [displayedMonth]);
const monthEnd = useMemo(() => getMonthEnd(monthStart), [monthStart]);
const monthGridDays = useMemo(() => getMonthGridDays(monthStart), [monthStart]);
const [selection, setSelection] = useState(null);
const [monthSelection, setMonthSelection] = useState(null);
const [draft, setDraft] = useState(() => createDefaultDraft());
const [activeEventId, setActiveEventId] = useState("");
const [activeOccurrenceDate, setActiveOccurrenceDate] = useState("");
const [copiedEvent, setCopiedEvent] = useState(null);
const [hideEmptyHours, setHideEmptyHours] = useState(false);
const [error, setError] = useState("");
const previousEditingRef = useRef(editing);
const days = useMemo(() => getWeekDays(weekStart), [weekStart]);
const weekEnd = useMemo(() => addDays(weekStart, 7), [weekStart]);
const rangeStart = data.viewMode === "month" ? monthStart : weekStart;
const rangeEnd = data.viewMode === "month" ? monthEnd : weekEnd;
const occurrences = useMemo(() => getCalendarOccurrences(data.events, rangeStart, rangeEnd), [data.events, rangeEnd, rangeStart]);
const draftSelection = data.viewMode === "week" ? getDraftSelection(draft) : null;
const nowMinutes = now.getHours() * 60 + now.getMinutes();
const nowDayIndex = getLocalDayIndex(now);
const formOpen = editing || Boolean(activeEventId);
const weekGridLayout = useMemo(() => {
return getWeekGridLayout(occurrences, data.viewMode === "week" && hideEmptyHours && !formOpen);
}, [data.viewMode, formOpen, hideEmptyHours, occurrences]);
const save = useCallback((nextData) => {
context.setModuleData(toolboxId, moduleId, nextData, "calendar");
}, [context, moduleId, toolboxId]);
function initializeWeek() {
save({ ...data, initialized: true, viewMode: "week", weekStartsOn: 1, events: [] });
}
function initializeMonth() {
save({ ...data, initialized: true, viewMode: "month", weekStartsOn: 1, events: [] });
}
function updateDraft(values) {
setDraft((current) => ({ ...current, ...values }));
setError("");
}
function updateAlertMode(alertMode) {
setDraft((current) => {
const currentOffsets = normalizeDraftAlertOffsets(current.alertOffsetMinutes);
return {
...current,
alertMode,
alertOffsetMinutes: alertMode === "off" ? [] : currentOffsets.length ? currentOffsets : [10]
};
});
setError("");
}
function toggleAlertOffset(offset) {
setDraft((current) => {
const currentOffsets = normalizeDraftAlertOffsets(current.alertOffsetMinutes);
const nextOffsets = currentOffsets.includes(offset)
? currentOffsets.filter((item) => item !== offset)
: [...currentOffsets, offset].sort((a, b) => b - a);
return {
...current,
alertMode: nextOffsets.length ? current.alertMode === "off" ? "visible" : current.alertMode : "off",
alertOffsetMinutes: nextOffsets
};
});
setError("");
}
const resetDraft = useCallback(() => {
setDraft(createDefaultDraft());
setSelection(null);
setMonthSelection(null);
setActiveEventId("");
setActiveOccurrenceDate("");
setError("");
}, []);
function closeForm() {
resetDraft();
setEditing?.(false);
}
function applySelection(dayIndex, startSlot, endSlot = startSlot) {
const orderedStart = Math.min(startSlot, endSlot);
const orderedEnd = Math.max(startSlot, endSlot);
updateDraft({
id: "",
dayIndex,
startTime: minutesToTimeValue(orderedStart * WEEK_SLOT_MINUTES),
endTime: minutesToTimeValue(Math.min(23 * 60 + 59, (Math.min(WEEK_SLOT_COUNT, orderedEnd + 1)) * WEEK_SLOT_MINUTES))
});
}
function applyMonthSelection(startDate, endDate = startDate) {
const orderedStart = startDate <= endDate ? startDate : endDate;
const orderedEnd = startDate <= endDate ? endDate : startDate;
const start = parseLocalDateKey(orderedStart);
setActiveEventId("");
setActiveOccurrenceDate("");
updateDraft({
id: "",
startDate: orderedStart,
endDate: orderedEnd,
dayIndex: start ? getLocalDayIndex(start) : 0
});
}
function pasteEventAtTarget(target) {
if (!copiedEvent) return;
const startMinutes = data.viewMode === "month" ? 0 : target.slotIndex >= 0 ? target.slotIndex * WEEK_SLOT_MINUTES : timeValueToMinutes(draft.startTime);
const durationMinutes = copiedEvent.endMinutes && copiedEvent.endMinutes > copiedEvent.startMinutes ? copiedEvent.endMinutes - copiedEvent.startMinutes : 0;
const nextEvent = {
...copiedEvent,
id: context.uid("calendar"),
startMinutes,
repeatable: copiedEvent.repeatable !== false,
repeat: copiedEvent.repeatable === false ? "none" : getCalendarRepeatMode(data.viewMode)
};
if (data.viewMode === "month") {
const copiedDaySpan = getDateSpanDays(copiedEvent.startDate, copiedEvent.endDate || copiedEvent.startDate);
nextEvent.startDate = target.startDate;
nextEvent.endDate = formatLocalDateKey(addDays(parseLocalDateKey(target.startDate), copiedDaySpan));
nextEvent.dayIndex = getLocalDayIndex(parseLocalDateKey(target.startDate));
nextEvent.monthlyRepeatBy = target.monthlyRepeatBy || copiedEvent.monthlyRepeatBy || "monthDay";
nextEvent.repeat = copiedEvent.repeat || "monthly";
nextEvent.excludedOccurrences = [];
delete nextEvent.monthDay;
} else {
nextEvent.dayIndex = target.dayIndex;
delete nextEvent.monthDay;
delete nextEvent.monthlyRepeatBy;
}
delete nextEvent.endMinutes;
if (data.viewMode === "week" && durationMinutes > 0) nextEvent.endMinutes = Math.min(23 * 60 + 59, startMinutes + durationMinutes);
save({ ...data, initialized: true, events: [...data.events, nextEvent] });
context.notify?.(textContent.pastedTitle || "Événement collé.");
}
function pasteCopiedEventAtDraftSelection() {
if (!copiedEvent) return;
if (data.viewMode === "month") {
if (!parseLocalDateKey(draft.startDate)) return;
pasteEventAtTarget({
startDate: draft.startDate,
monthlyRepeatBy: draft.monthlyRepeatBy || "monthDay",
slotIndex: -1
});
return;
}
const targetSelection = getDraftSelection(draft);
if (targetSelection) pasteEventAtTarget({ dayIndex: targetSelection.dayIndex, slotIndex: targetSelection.startSlot });
}
function handleSlotPointerDown(dayIndex, slotIndex) {
setActiveEventId("");
setActiveOccurrenceDate("");
setSelection({ dayIndex, startSlot: slotIndex, endSlot: slotIndex });
applySelection(dayIndex, slotIndex);
}
function handleSlotPointerEnter(event, dayIndex, slotIndex) {
if (event.buttons !== 1) return;
setSelection((current) => {
if (!current || current.dayIndex !== dayIndex) return current;
applySelection(dayIndex, current.startSlot, slotIndex);
return { ...current, endSlot: slotIndex };
});
}
function handleMonthPointerDown(dateKey) {
setActiveEventId("");
setActiveOccurrenceDate("");
setMonthSelection({ startDate: dateKey, endDate: dateKey });
applyMonthSelection(dateKey);
}
function handleMonthPointerEnter(event, dateKey) {
if (event.buttons !== 1) return;
setMonthSelection((current) => {
if (!current) return current;
applyMonthSelection(current.startDate, dateKey);
return { ...current, endDate: dateKey };
});
}
function editEvent(event, occurrence = null) {
const occurrenceDate = occurrence ? formatLocalDateKey(new Date(occurrence.startMs)) : "";
if (occurrenceDate) setDisplayedMonth(getMonthStart(parseLocalDateKey(occurrenceDate)));
setDraft({
id: event.id,
title: event.title,
dayIndex: event.dayIndex,
startDate: occurrenceDate || event.startDate || formatLocalDateKey(now),
endDate: occurrenceDate && event.startDate ? formatLocalDateKey(addDays(parseLocalDateKey(occurrenceDate), getDateSpanDays(event.startDate, event.endDate || event.startDate))) : event.endDate || event.startDate || formatLocalDateKey(now),
monthlyRepeatBy: event.monthlyRepeatBy || "monthDay",
repeat: event.repeat || "none",
startTime: minutesToTimeValue(event.startMinutes),
endTime: event.endMinutes ? minutesToTimeValue(event.endMinutes) : "",
repeatable: event.repeatable !== false,
alertMode: event.alertMode || "off",
alertOffsetMinutes: event.alertMode === "off" ? [] : normalizeDraftAlertOffsets(event.alertOffsetMinutes),
color: event.color || "violet"
});
setActiveEventId(event.id);
setActiveOccurrenceDate(occurrenceDate);
setError("");
}
function deleteEvent(eventId) {
save({ ...data, initialized: true, events: data.events.filter((event) => event.id !== eventId) });
if (draft.id === eventId || activeEventId === eventId) closeForm();
}
function deleteOccurrence(eventId) {
const event = data.events.find((item) => item.id === eventId);
if (!event || !activeOccurrenceDate || event.repeat === "none") return;
const events = data.events.map((item) => item.id === eventId
? { ...item, excludedOccurrences: [...new Set([...(item.excludedOccurrences || []), activeOccurrenceDate])].sort() }
: item);
save({ ...data, initialized: true, events });
closeForm();
}
function copyEvent(event) {
setCopiedEvent(event);
context.notify?.(textContent.copiedTitle || "Événement copié.");
}
function submitEvent(event) {
event.preventDefault();
const title = draft.title.trim();
const dayIndex = Number(draft.dayIndex);
const startDate = normalizeDraftDateKey(draft.startDate);
const endDate = normalizeDraftDateKey(draft.endDate || draft.startDate);
const startMinutes = data.viewMode === "month" ? 0 : timeValueToMinutes(draft.startTime);
const endMinutes = data.viewMode === "month" ? -1 : draft.endTime ? timeValueToMinutes(draft.endTime) : -1;
const monthlyRepeatBy = normalizeDraftMonthlyRepeatBy(draft.monthlyRepeatBy);
const monthRepeat = monthlyRepeatBy === "monthDay" && draft.repeat === "weekly" ? "none" : ["none", "weekly", "monthly"].includes(draft.repeat) ? draft.repeat : "monthly";
const validWeekTarget = data.viewMode === "week" && dayIndex >= 0 && dayIndex <= 6;
const validMonthTarget = data.viewMode === "month" && startDate && endDate && endDate >= startDate;
if (!title || (!validWeekTarget && !validMonthTarget) || startMinutes < 0) {
setError(textContent.requiredError || "Titre, date et heure de début requis.");
return;
}
if (draft.endTime && data.viewMode === "week" && endMinutes <= startMinutes) {
setError(textContent.invalidRangeError || "La fin doit être après le début.");
return;
}
const alertOffsets = normalizeDraftAlertOffsets(draft.alertOffsetMinutes);
const alertMode = alertOffsets.length && CALENDAR_ALERT_MODES.includes(draft.alertMode) ? draft.alertMode : "off";
const nextEvent = {
id: draft.id || context.uid("calendar"),
title,
startMinutes,
...(data.viewMode === "week" && draft.endTime ? { endMinutes } : {}),
repeatable: draft.repeatable !== false,
repeat: data.viewMode === "month" ? monthRepeat : draft.repeatable === false ? "none" : getCalendarRepeatMode(data.viewMode),
alertMode,
alertOffsetMinutes: alertOffsets.length ? alertOffsets : [10],
color: CALENDAR_COLORS.includes(draft.color) ? draft.color : "violet"
};
if (data.viewMode === "month") {
nextEvent.monthlyRepeatBy = monthlyRepeatBy;
nextEvent.startDate = startDate;
nextEvent.endDate = endDate;
nextEvent.dayIndex = getLocalDayIndex(parseLocalDateKey(startDate));
nextEvent.excludedOccurrences = data.events.find((item) => item.id === draft.id)?.excludedOccurrences || [];
} else {
nextEvent.dayIndex = dayIndex;
}
const events = draft.id
? data.events.map((item) => item.id === draft.id ? nextEvent : item)
: [...data.events, nextEvent];
save({ ...data, initialized: true, events });
closeForm();
}
function handleKeyDown(event) {
if (!event.ctrlKey && !event.metaKey) return;
const target = event.target;
if (target?.closest?.("input, textarea, select, [contenteditable='true']")) return;
const key = event.key.toLowerCase();
if (key === "c") {
const activeEvent = data.events.find((item) => item.id === activeEventId);
if (!activeEvent) return;
event.preventDefault();
copyEvent(activeEvent);
}
if (key === "v") {
if (!copiedEvent) return;
event.preventDefault();
pasteCopiedEventAtDraftSelection();
}
}
useEffect(() => {
const intervalId = window.setInterval(() => setNow(new Date()), 60000);
return () => window.clearInterval(intervalId);
}, []);
useEffect(() => {
const previousEditing = previousEditingRef.current;
previousEditingRef.current = editing;
if (!previousEditing && editing) {
setDraft(createDefaultDraft());
setActiveEventId("");
setSelection(null);
setError("");
}
if (previousEditing && !editing) {
resetDraft();
}
}, [editing, resetDraft]);
useEffect(() => {
if (!data.events.length) return undefined;
function pruneExpiredOneShotEvents() {
const nowMs = Date.now();
const nextEvents = data.events.filter((event) => {
if (event.repeatable !== false) return true;
const occurrence = getCalendarOccurrences([event], rangeStart, rangeEnd)[0];
return !occurrence || occurrence.endMs > nowMs;
});
if (nextEvents.length === data.events.length) return;
save({ ...data, initialized: true, events: nextEvents });
if (activeEventId && !nextEvents.some((event) => event.id === activeEventId)) resetDraft();
}
pruneExpiredOneShotEvents();
const intervalId = window.setInterval(pruneExpiredOneShotEvents, 30000);
return () => window.clearInterval(intervalId);
}, [activeEventId, data, rangeEnd, rangeStart, resetDraft, save]);
const controller = useCalendarController({ data, context, toolboxId, moduleId, editing, setEditing, textContent });
if (!data.initialized) {
return (
<div className="calendar-module">
<section className="calendar-mode-choice" aria-label={textContent.init?.title || "Créer un calendrier"}>
<div className="calendar-mode-grid">
<button type="button" className="calendar-mode-card" onClick={initializeWeek}>
<strong>{textContent.init?.weekly || "Hebdomadaire"}</strong>
<span>{textContent.init?.weeklyDescription || "Grille jours par créneaux de 30 minutes."}</span>
</button>
<button type="button" className="calendar-mode-card" onClick={initializeMonth}>
<strong>{textContent.init?.monthly || "Mensuel"}</strong>
<span>{textContent.init?.monthlyDescription || "Grille du mois courant."}</span>
</button>
</div>
</section>
</div>
<CalendarModeChoice
textContent={textContent}
onInitializeWeek={controller.initializeWeek}
onInitializeMonth={controller.initializeMonth}
/>
);
}
return (
<CalendarContent
data={data}
context={context}
draft={{ ...draft, alertOffsetMinutes: draft.alertMode === "off" ? [] : normalizeDraftAlertOffsets(draft.alertOffsetMinutes) }}
error={error}
activeOccurrenceDate={activeOccurrenceDate}
monthStart={monthStart}
monthGridDays={monthGridDays}
occurrences={occurrences}
textContent={textContent}
activeEventId={activeEventId}
formOpen={formOpen}
draftSelection={draftSelection}
selection={selection}
monthSelection={monthSelection}
days={days}
weekGridLayout={weekGridLayout}
now={now}
nowDayIndex={nowDayIndex}
nowMinutes={nowMinutes}
hideEmptyHours={hideEmptyHours}
textExchangeOpen={textExchangeOpen}
onKeyDown={handleKeyDown}
onSubmit={submitEvent}
onDraftChange={updateDraft}
onAlertModeChange={updateAlertMode}
onAlertOffsetToggle={toggleAlertOffset}
onDeleteEvent={deleteEvent}
onDeleteOccurrence={deleteOccurrence}
onClose={closeForm}
onDisplayedMonthChange={setDisplayedMonth}
onImport={(nextData) => save(nextData)}
onHideEmptyHoursToggle={() => setHideEmptyHours((current) => !current)}
onMonthPointerDown={handleMonthPointerDown}
onMonthPointerEnter={handleMonthPointerEnter}
onMonthPointerUp={() => setMonthSelection(null)}
onSlotPointerDown={handleSlotPointerDown}
onSlotPointerEnter={handleSlotPointerEnter}
onSlotPointerUp={() => setSelection(null)}
onEditEvent={editEvent}
/>
);
return <CalendarContent {...controller.contentProps} textExchangeOpen={textExchangeOpen} />;
}