event.stopPropagation()}
- onKeyDown={(event) => event.stopPropagation()}
- >
- {ALERT_MODES.map((option) => {
- const label = labels[option.mode];
- const disabled = Boolean(blocked);
- return (
-
- );
- })}
-
- );
-}
-
-function TimePartsInput({ value, onChange, allowWildcard = false }) {
- function updatePart(part, nextValue) {
- const cleanValue = allowWildcard && /^x+$/i.test(nextValue.trim()) ? "X" : nextValue.replace(/[^\d]/g, "").slice(0, 2);
- onChange({ ...value, [part]: cleanValue });
- }
-
- return (
- event.stopPropagation()}
+ onKeyDown={(event) => event.stopPropagation()}
+ >
+ {ALERT_MODES.map((option) => {
+ const label = labels[option.mode];
+ const disabled = Boolean(blocked);
+ return (
+
+ );
+ })}
+
+ );
+}
+
+export function TimePartsInput({ value, onChange, allowWildcard = false }) {
+ function updatePart(part, nextValue) {
+ const cleanValue = allowWildcard && /^x+$/i.test(nextValue.trim()) ? "X" : nextValue.replace(/[^\d]/g, "").slice(0, 2);
+ onChange({ ...value, [part]: cleanValue });
+ }
+
+ return (
+ Chargement de {label}...
;
+}
+
+function EditableModuleTitle({ value, fallback, onSave, tutorialTarget = "" }) {
+ const inlineEdit = useInlineEdit({
+ value,
+ onCommit: (title) => onSave(title || fallback),
+ blurOnEscape: true
+ });
+
+ return (
+
-
setOpen(false)} />
-
-
-
-
-
-
- {modules.map(([type, module]) => (
-
- ))}
-
-
-
,
- document.body
- )}
- >
- );
-}
-
-export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onUpdateModule, onDelete, onModuleOrderChange }) {
- const twoColumnOrder = useMemo(() => toolbox.moduleOrder?.two || [[], []], [toolbox.moduleOrder?.two]);
- const orderedModules = useMemo(() => getModulesByIds(toolbox.modules, toolbox.moduleOrder?.one || []), [toolbox.modules, toolbox.moduleOrder?.one]);
- const columns = useMemo(() => [
- getModulesByIds(toolbox.modules, twoColumnOrder[0]),
- getModulesByIds(toolbox.modules, twoColumnOrder[1])
- ], [toolbox.modules, twoColumnOrder]);
- const {
- itemReorder,
- getItemProps,
- getGroupBoundaryProps,
- isItemDragging,
- isItemDropTarget,
- isBoundaryDropTarget,
- getDropPlacement
- } = useGroupedReorder({
- namespace: "toolbox-modules",
- items: toolbox.modules,
- getItemId: (module) => module.id,
- getItemGroup: (module) => moduleColumns === 2 ? getModuleColumnId(module.id, twoColumnOrder) : "",
- getEffectiveGroup: (module) => moduleColumns === 2 ? getModuleColumnId(module.id, twoColumnOrder) : "",
- getParentId: () => toolbox.id,
- reorderFeatures: {
- item: {
- groupChange: moduleColumns === 2,
- boundaryDrop: moduleColumns === 2
- }
- },
- onItemMove: (operation) => {
- const nextOrder = moduleColumns === 2
- ? moveModuleInTwoColumnOrder(toolbox.moduleOrder, operation)
- : moveModuleInOneColumnOrder(toolbox.moduleOrder, operation);
- onModuleOrderChange(nextOrder);
- },
- hierarchy: { enabled: true, stickyParents: true }
- });
-
- if (moduleColumns === 1) {
- return (
-
- {orderedModules.map((module) => (
-
- ))}
-
- );
- }
-
- const hasEmptyColumn = toolbox.modules.length > 1 && columns.some((modules) => modules.length === 0);
-
- return (
-
- {columns.map((modules, index) => (
-
-
- {modules.map((module) => (
-
- ))}
-
-
- ))}
-
- );
-}
-
-function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRename, onUpdateModule, onDelete }) {
- const definition = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad;
- const Component = definition.Component;
- const label = definition.label || module.type;
- const [editing, setEditing] = useState(false);
- const [textExchangeOpen, setTextExchangeOpen] = useState(false);
- const hasAddPanel = definition.editable && definition.addable !== false;
- const hasTextExchange = supportsTextExchange(module.type);
- const scrollable = definition.scrollable && module.scrollable === true;
- const className = [
- "module",
- reorder.isItemDragging(module.id) ? "is-dragging" : "",
- reorder.isItemDropTarget(module.id) ? "is-drop-target" : "",
- reorder.getDropPlacement("item", module.id) === "after" ? "drop-after" : "",
- scrollable ? "is-scrollable" : ""
- ].filter(Boolean).join(" ");
- const componentProps = {
- toolboxId: toolbox.id,
- moduleId: module.id,
- context,
- editing,
- setEditing,
- openEditor: () => {
- setTextExchangeOpen(false);
- setEditing(true);
- },
- textExchangeOpen
- };
-
- return (
-
-
-
-
-
-
-
- onRename(module.id, title)} />
-
-
- {definition.scrollable && (
-
- )}
- {hasAddPanel && (
-
- )}
- {hasTextExchange && (
-
- )}
-
-
-
-
- {LAZY_MODULE_TYPES.has(module.type) ? (
- }>
-
-
- ) : (
-
- )}
-
-
- );
-}
-
-function ModuleLoadingFallback({ label }) {
- return
Chargement de {label}...
;
-}
-
-function EditableModuleTitle({ value, fallback, onSave, tutorialTarget = "" }) {
- const inlineEdit = useInlineEdit({
- value,
- onCommit: (title) => onSave(title || fallback),
- blurOnEscape: true
- });
-
- return (
-
- {value}
-
- );
-}
+// Rôle : expose l'API publique du registre et du rendu des modules toolbox.
+export { TOOLBOX_MODULES } from "../toolboxModuleRegistry.js";
+export { AddToolControls } from "./AddToolControls.jsx";
+export { ToolboxModules } from "./ToolboxModules.jsx";
+export { LAZY_MODULE_TYPES, MODULE_COMPONENTS } from "./toolboxModuleComponents.jsx";
diff --git a/website/src/features/toolboxes/modules/toolboxModuleComponents.jsx b/website/src/features/toolboxes/modules/toolboxModuleComponents.jsx
new file mode 100644
index 0000000..e058c73
--- /dev/null
+++ b/website/src/features/toolboxes/modules/toolboxModuleComponents.jsx
@@ -0,0 +1,38 @@
+// Rôle : associe les types d'outils toolbox à leurs composants React, synchrones ou lazy.
+/* eslint-disable react-refresh/only-export-components -- registre de composants toolbox consommé par le shell. */
+import { lazy } from "react";
+import { TOOLBOX_MODULE_DEFINITIONS } from "../toolboxModuleRegistry.js";
+import { CalculatorModule } from "./CalculatorModule.jsx";
+import { ChecklistModule } from "./ChecklistModule.jsx";
+import { CountersModule } from "./CountersModule.jsx";
+import { ImageAnnotationModule } from "./ImageAnnotationModule.jsx";
+import { ImagesModule } from "./ImagesModule.jsx";
+import { LinksModule } from "./LinksModule.jsx";
+import { NotepadModule } from "./NotepadModule.jsx";
+import { TableModule } from "./TableModule.jsx";
+import { TaskPlannerModule } from "./TaskPlannerModule.jsx";
+import { TimerModule } from "./TimerModule.jsx";
+
+const CombosModule = lazy(() => import("./CombosModule.jsx").then((module) => ({ default: module.CombosModule })));
+const CalendarModule = lazy(() => import("./CalendarModule.jsx").then((module) => ({ default: module.CalendarModule })));
+const GoalTreeModule = lazy(() => import("./GoalTreeModule.jsx").then((module) => ({ default: module.GoalTreeModule })));
+const EquipmentPlannerModule = lazy(() => import("./EquipmentPlannerModule.jsx").then((module) => ({ default: module.EquipmentPlannerModule })));
+
+export const MODULE_COMPONENTS = {
+ notepad: { ...TOOLBOX_MODULE_DEFINITIONS.notepad, Component: NotepadModule },
+ checklist: { ...TOOLBOX_MODULE_DEFINITIONS.checklist, Component: ChecklistModule },
+ images: { ...TOOLBOX_MODULE_DEFINITIONS.images, Component: ImagesModule },
+ links: { ...TOOLBOX_MODULE_DEFINITIONS.links, Component: LinksModule },
+ counters: { ...TOOLBOX_MODULE_DEFINITIONS.counters, Component: CountersModule },
+ combos: { ...TOOLBOX_MODULE_DEFINITIONS.combos, Component: CombosModule },
+ calculator: { ...TOOLBOX_MODULE_DEFINITIONS.calculator, Component: CalculatorModule },
+ calendar: { ...TOOLBOX_MODULE_DEFINITIONS.calendar, Component: CalendarModule },
+ table: { ...TOOLBOX_MODULE_DEFINITIONS.table, Component: TableModule },
+ goalTree: { ...TOOLBOX_MODULE_DEFINITIONS.goalTree, Component: GoalTreeModule },
+ timer: { ...TOOLBOX_MODULE_DEFINITIONS.timer, Component: TimerModule },
+ taskPlanner: { ...TOOLBOX_MODULE_DEFINITIONS.taskPlanner, Component: TaskPlannerModule },
+ equipmentPlanner: { ...TOOLBOX_MODULE_DEFINITIONS.equipmentPlanner, Component: EquipmentPlannerModule },
+ imageAnnotation: { ...TOOLBOX_MODULE_DEFINITIONS.imageAnnotation, Component: ImageAnnotationModule }
+};
+
+export const LAZY_MODULE_TYPES = new Set(["combos", "calendar", "goalTree", "equipmentPlanner"]);
diff --git a/website/src/features/toolboxes/modules/useCalendarController.js b/website/src/features/toolboxes/modules/useCalendarController.js
new file mode 100644
index 0000000..4653261
--- /dev/null
+++ b/website/src/features/toolboxes/modules/useCalendarController.js
@@ -0,0 +1,282 @@
+// Rôle : orchestre l'état, les handlers et les effets du module calendrier.
+import { useCallback, useMemo, useRef, useState } from "react";
+import {
+ WEEK_SLOT_MINUTES,
+ addDays,
+ getCalendarOccurrences,
+ getLocalDayIndex,
+ getMonthEnd,
+ getMonthGridDays,
+ getMonthStart,
+ getWeekDays,
+ getWeekStart,
+ minutesToTimeValue,
+ parseLocalDateKey
+} from "./calendarUtils.js";
+import { WEEK_SLOT_COUNT, getWeekGridLayout } from "./calendarLayout.js";
+import {
+ createDefaultDraft,
+ getDraftSelection,
+ normalizeDraftAlertOffsets
+} from "./calendarDraft.js";
+import {
+ buildCalendarEventFromDraft,
+ buildPastedCalendarEvent,
+ getCalendarDraftFromEvent
+} from "./calendarEventActions.js";
+import {
+ useCalendarClock,
+ useCalendarEditingReset,
+ usePruneExpiredCalendarEvents
+} from "./useCalendarLifecycle.js";
+import { useCalendarDraftControls } from "./useCalendarDraftControls.js";
+
+export function useCalendarController({ data, context, toolboxId, moduleId, editing = false, setEditing, textContent }) {
+ 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 { updateAlertMode, updateDraft, toggleAlertOffset } = useCalendarDraftControls({ setDraft, setError });
+
+ 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: [] });
+ }
+
+ 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) {
+ const nextEvent = buildPastedCalendarEvent({ copiedEvent, target, data, draft, context });
+ if (!nextEvent) return;
+ 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 { draft: nextDraft, occurrenceDate } = getCalendarDraftFromEvent({ event, occurrence, now });
+ if (occurrenceDate) setDisplayedMonth(getMonthStart(parseLocalDateKey(occurrenceDate)));
+ setDraft(nextDraft);
+ 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 result = buildCalendarEventFromDraft({ draft, data, context });
+ if (result.error === "required") {
+ setError(textContent.requiredError || "Titre, date et heure de début requis.");
+ return;
+ }
+ if (result.error === "range") {
+ setError(textContent.invalidRangeError || "La fin doit être après le début.");
+ return;
+ }
+ const events = draft.id
+ ? data.events.map((item) => item.id === draft.id ? result.event : item)
+ : [...data.events, result.event];
+ 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();
+ }
+ }
+
+ useCalendarClock(setNow);
+ useCalendarEditingReset({ editing, previousEditingRef, resetDraft, setActiveEventId, setDraft, setError, setSelection });
+ usePruneExpiredCalendarEvents({ activeEventId, data, rangeEnd, rangeStart, resetDraft, save });
+
+ return {
+ initializeWeek,
+ initializeMonth,
+ contentProps: {
+ data,
+ context,
+ draft: { ...draft, alertOffsetMinutes: draft.alertMode === "off" ? [] : normalizeDraftAlertOffsets(draft.alertOffsetMinutes) },
+ error,
+ activeOccurrenceDate,
+ monthStart,
+ monthGridDays,
+ occurrences,
+ textContent,
+ activeEventId,
+ formOpen,
+ draftSelection,
+ selection,
+ monthSelection,
+ days,
+ weekGridLayout,
+ now,
+ nowDayIndex,
+ nowMinutes,
+ hideEmptyHours,
+ 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
+ }
+ };
+}
diff --git a/website/src/features/toolboxes/modules/useCalendarDraftControls.js b/website/src/features/toolboxes/modules/useCalendarDraftControls.js
new file mode 100644
index 0000000..eb873b7
--- /dev/null
+++ b/website/src/features/toolboxes/modules/useCalendarDraftControls.js
@@ -0,0 +1,38 @@
+// Rôle : centralise les mises à jour de draft et d'alertes du calendrier.
+import { normalizeDraftAlertOffsets } from "./calendarDraft.js";
+
+export function useCalendarDraftControls({ setDraft, setError }) {
+ 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("");
+ }
+
+ return { updateAlertMode, updateDraft, toggleAlertOffset };
+}
diff --git a/website/src/features/toolboxes/modules/useCalendarLifecycle.js b/website/src/features/toolboxes/modules/useCalendarLifecycle.js
new file mode 100644
index 0000000..8608a93
--- /dev/null
+++ b/website/src/features/toolboxes/modules/useCalendarLifecycle.js
@@ -0,0 +1,62 @@
+// Rôle : regroupe les effets de cycle de vie du contrôleur calendrier.
+import { useEffect } from "react";
+import { getCalendarOccurrences } from "./calendarUtils.js";
+import { createDefaultDraft } from "./calendarDraft.js";
+
+export function useCalendarClock(setNow) {
+ useEffect(() => {
+ const intervalId = window.setInterval(() => setNow(new Date()), 60000);
+ return () => window.clearInterval(intervalId);
+ }, [setNow]);
+}
+
+export function useCalendarEditingReset({
+ editing,
+ previousEditingRef,
+ resetDraft,
+ setActiveEventId,
+ setDraft,
+ setError,
+ setSelection
+}) {
+ useEffect(() => {
+ const previousEditing = previousEditingRef.current;
+ previousEditingRef.current = editing;
+ if (!previousEditing && editing) {
+ setDraft(createDefaultDraft());
+ setActiveEventId("");
+ setSelection(null);
+ setError("");
+ }
+ if (previousEditing && !editing) {
+ resetDraft();
+ }
+ }, [editing, previousEditingRef, resetDraft, setActiveEventId, setDraft, setError, setSelection]);
+}
+
+export function usePruneExpiredCalendarEvents({
+ activeEventId,
+ data,
+ rangeEnd,
+ rangeStart,
+ resetDraft,
+ save
+}) {
+ 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]);
+}