From 992665f4ea77e818e80daca72c6a59d0b312ab1a Mon Sep 17 00:00:00 2001 From: Shinuwa Date: Thu, 20 Aug 2026 15:48:24 +0200 Subject: [PATCH] Add calendar toolbox with weekly and monthly scheduling --- DESIGN_SYSTEM.md | 32 + docs/STORAGE_SCHEMA.md | 56 + tests/helpers/data-validation.mjs | 83 +- tests/static-app.test.mjs | 4 + tests/static-toolboxes.test.mjs | 151 ++ tests/toolbox-modules.test.mjs | 4 + website/public/data/library.json | 395 +++++- website/public/data/site.json | 159 +++ .../public/static/icons/calendar-month.svg | 96 ++ website/public/static/icons/calendar-week.svg | 96 ++ website/public/static/icons/calendar.svg | 4 + website/public/static/icons/day-name.svg | 60 + website/public/static/icons/day-number.svg | 96 ++ website/public/static/icons/trashcan-one.svg | 40 + website/src/components/AppOverlays.jsx | 1 + website/src/components/ToolboxModals.jsx | 6 +- .../src/features/toolboxes/ToolboxPages.jsx | 5 +- .../toolboxes/modules/CalendarModule.jsx | 1215 +++++++++++++++++ .../toolboxes/modules/CombosModule.jsx | 6 +- .../toolboxes/modules/calendarUtils.js | 266 ++++ .../src/features/toolboxes/modules/index.jsx | 4 +- .../features/toolboxes/modules/textImport.js | 117 ++ .../toolboxes/storage/modules/calendar.js | 123 ++ .../toolboxes/storage/modules/index.js | 2 + .../toolboxes/storage/modules/notepad.js | 12 +- .../toolboxes/storage/modules/shared.js | 1 + .../toolboxes/storage/toolboxStorage.js | 7 + .../toolboxes/toolboxModuleRegistry.js | 11 +- website/src/hooks/useToolboxAlerts.js | 99 ++ website/src/main.jsx | 23 +- website/src/pages/LibraryPage.jsx | 16 +- website/src/styles/_home.scss | 4 - website/src/styles/_icons.scss | 30 + website/src/styles/_responsive.scss | 1 - website/src/styles/toolboxes/_calendar.scss | 1113 +++++++++++++++ website/src/styles/toolboxes/_core.scss | 1 + .../src/styles/toolboxes/_module-shell.scss | 5 + .../src/styles/toolboxes/_task-planner.scss | 10 +- website/src/styles/toolboxes/_timer.scss | 13 +- 39 files changed, 4331 insertions(+), 36 deletions(-) create mode 100644 website/public/static/icons/calendar-month.svg create mode 100644 website/public/static/icons/calendar-week.svg create mode 100644 website/public/static/icons/calendar.svg create mode 100644 website/public/static/icons/day-name.svg create mode 100644 website/public/static/icons/day-number.svg create mode 100644 website/public/static/icons/trashcan-one.svg create mode 100644 website/src/features/toolboxes/modules/CalendarModule.jsx create mode 100644 website/src/features/toolboxes/modules/calendarUtils.js create mode 100644 website/src/features/toolboxes/storage/modules/calendar.js create mode 100644 website/src/hooks/useToolboxAlerts.js create mode 100644 website/src/styles/toolboxes/_calendar.scss diff --git a/DESIGN_SYSTEM.md b/DESIGN_SYSTEM.md index f8c3d66..7ee0e15 100644 --- a/DESIGN_SYSTEM.md +++ b/DESIGN_SYSTEM.md @@ -182,6 +182,38 @@ Alertes timer : - le garde-fou bloque uniquement les comptes a rebours recurrents (`Pattern horaire`, `Intervalle`) si la repetition est inferieure a 5 minutes ; - un `Pattern horaire` comme `X:24:X` cible `hh:24:00` et se repete toutes les heures. +### Calendrier + +Le calendrier est un outil de planification visuelle, distinct du timer. + +Regles UI : + +- l'outil propose une grille hebdomadaire jours x creneaux de 30 minutes ou une grille mensuelle datée avec navigation par mois ; +- les 7 jours de la semaine doivent rester visibles sans scroll horizontal ; seul le scroll vertical sert a parcourir les heures ; +- le formulaire compact est masque par defaut ; il s'ouvre via le bouton `+` du module ou par clic sur une entree ; +- le bouton annuler ferme le formulaire, et desactiver le bouton `+` le ferme aussi quand il sert a creer une entree ; +- l'ajout manuel et la selection de cellules alimentent le meme formulaire compact quand il est ouvert ; +- les champs horaires hebdomadaires restent courts, acceptent un pas de 5 minutes, et les alertes d'echeance utilisent des checkboxes `10 min`, `5 min` et `Au debut et pendant` ; +- les evenements sont affiches comme des blocs colores dans la grille, sans boutons internes et sans dominer la palette globale ; +- quand deux evenements se chevauchent sur un meme jour, ils se partagent la largeur de la colonne ; la V1 gere visuellement jusqu'a deux evenements simultanes ; +- la suppression d'une entree se fait dans le formulaire d'edition ; +- la copie et le collage d'entree utilisent les raccourcis clavier `Ctrl+C` et `Ctrl+V` ; +- le mini-switch repetable est actif par defaut ; une entree non repetable est supprimee quand son echeance de la semaine courante est terminee ; +- les alertes reutilisent le switch a 3 etats du timer : aucune, toolbox visible, globale site ; +- le choix de couleur reprend le pattern de palette compacte du bloc-notes ; +- le choix initial s'affiche dans l'outil lui-meme ; la vue mensuelle affiche le nom du mois et des boutons mois precedent / courant / suivant ; +- en vue mensuelle, la règle mensuelle peut cibler le numero du jour ou le rang du jour de semaine via un switch 2 états compact, indépendamment du choix de répétition. +- en vue mensuelle, les dates de debut et de fin utilisent un champ compact qui ouvre un mini-calendrier avec navigation ; +- en vue mensuelle, le formulaire distingue date de debut et date de fin ; une plage d'un seul jour utilise la meme valeur pour les deux ; +- en vue mensuelle, la répétition propose `Aucune`, `Chaque semaine`, `Chaque mois`; `Chaque semaine` est indisponible pour la règle `Numéro du jour` ; +- en vue mensuelle, la suppression d'une entree repetee distingue suppression de l'occurrence affichee et suppression de toute la serie ; +- en vue mensuelle, un clic-glisse sur plusieurs cellules preconfigure la plage de jours du formulaire ; +- en vue mensuelle, les champs horaires ne sont pas affiches ; les evenements couvrent des journees completes ; +- en vue mensuelle, les evenements multi-jours s'affichent comme une barre horizontale et ne sont pas dupliques dans chaque cellule ; +- les vues hebdomadaire et mensuelle affichent un indicateur discret du moment actuel. +- en vue hebdomadaire, un switch standard peut masquer les longues plages horaires vides ; la tranche masquée reste indiquée dans la grille, pas dans un texte qui déborde de la colonne horaire. +- le calendrier supporte l'import/export texte via le panneau d'ajout, pour les vues hebdomadaire et mensuelle. + ### Combos L'outil Combos affiche des inputs de manettes et clavier sous forme de tokens visuels compacts. diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md index 7b78f20..1222319 100644 --- a/docs/STORAGE_SCHEMA.md +++ b/docs/STORAGE_SCHEMA.md @@ -402,6 +402,62 @@ Stockage compact : - `countdowns[].startMode` et `countdowns[].startTime` sont omis pour les intervalles qui démarrent à la création. - L'activation d'une alerte est bloquée dans l'interface si la fréquence configurée est inférieure à 5 minutes pour les comptes à rebours récurrents (`time_pattern` et `interval`). Le blocage ne dépend pas du temps restant avant la prochaine échéance. +## Outil Calendrier + +Type : `calendar` + +```json +{ + "initialized": true, + "viewMode": "month", + "weekStartsOn": 1, + "events": [ + { + "id": "calendar1", + "title": "Raid", + "startDate": "2026-08-14", + "endDate": "2026-08-16", + "repeat": "monthly", + "monthlyRepeatBy": "weekday", + "excludedOccurrences": ["2026-09-11"], + "alertMode": "site", + "alertOffsetMinutes": [10, 5, 0], + "color": "violet" + } + ] +} +``` + +Stockage compact : + +- `viewMode` vaut `week` ou `month` ; `week` est omis dans ce cas. +- `initialized` indique que le choix du type de calendrier a été validé dans l'outil ; il est conservé pour garder un calendrier vide utilisable. +- `weekStartsOn` vaut `1` par défaut et est omis dans ce cas. +- `events` est omis tant qu'aucun événement valide n'est configuré. +- les événements sans titre ou sans cible de jour/date valide sont supprimés ; `startMinutes` reste requis en hebdomadaire et vaut `0` par défaut en mensuel. +- `dayIndex` vaut `0` pour lundi, `1` pour mardi, jusqu'à `6` pour dimanche. +- en calendrier mensuel, `startDate` et `endDate` sont des dates locales `YYYY-MM-DD` ; `endDate` est omis si la plage dure un seul jour. +- en calendrier mensuel, `monthlyRepeatBy` vaut `monthDay` ou `weekday` ; `monthDay` est la valeur par défaut et est omise dans ce cas. +- en calendrier mensuel, `repeat` vaut `none`, `weekly` ou `monthly` ; `monthly` est la valeur par défaut et est omise dans ce cas. +- `repeat=weekly` est réservé aux entrées mensuelles par `weekday` ; si une entrée par `monthDay` demande `weekly`, elle est normalisée en `none`. +- une entrée mensuelle répétée par `monthDay` n'a pas d'occurrence dans un mois où le jour demandé n'existe pas. +- une entrée mensuelle répétée par `weekday` et `monthly` se répète sur le même rang de jour de semaine que `startDate` ; par exemple le premier lundi du mois. +- `excludedOccurrences` contient des dates locales `YYYY-MM-DD` correspondant aux occurrences supprimées individuellement. +- `startMinutes` et `endMinutes` sont des minutes depuis `00:00` dans la journée ; en hebdomadaire, l'interface saisit les heures au pas de 5 minutes tout en gardant une grille visuelle de 30 minutes ; en mensuel, les champs horaires ne sont pas exposés et `startMinutes` est omis s'il vaut `0`. +- `endMinutes` est conservé s'il produit une fin postérieure au début ; en mensuel, il peut être inférieur à `startMinutes` si la fin est sur un jour ultérieur. +- en hebdomadaire, `repeat` n'est pas stocké dans le format cible : il est déduit de `repeatable` (`weekly` par défaut, `none` si `repeatable=false`). +- en hebdomadaire, `repeatable` vaut `true` par défaut et est omis dans ce cas ; si `false`, l'entrée est supprimée quand son échéance de la semaine courante est terminée. +- `alertMode` vaut `off`, `visible` ou `site`, et est omis si `off`. +- `alertOffsetMinutes` est une liste composée de `10`, `5` ou `0` (`0` = au début et pendant l'événement pour les reprises de session) ; il est omis si l'alerte est désactivée ou si la liste vaut `[10]`. +- `color` vaut `violet`, `gold`, `cyan`, `pink`, `green` ou `gray`, et est omis si `violet`. + +Import/export texte : + +- le format texte commence par `@viewMode: week` ou `@viewMode: month`, puis une ligne `event | ...` par entrée ; +- en hebdomadaire, les champs `day`, `start` et `end` décrivent le jour de semaine et les heures locales (`HH:mm`) ; +- en mensuel, `repeatBy=monthDay` ou `repeatBy=weekday` utilise `startDate`/`endDate` et `repeat=none|weekly|monthly`, par exemple `repeatBy=weekday | repeat=monthly | startDate=2026-08-03 | endDate=2026-08-05` ; +- l'import texte remappe toujours les identifiants d'événements. + ## Outil Task Planner Type : `taskPlanner` diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs index 9584ced..5074726 100644 --- a/tests/helpers/data-validation.mjs +++ b/tests/helpers/data-validation.mjs @@ -329,6 +329,77 @@ export function validateSiteContent(site) { "toolboxes.modules.timer.emptyCountdowns", "toolboxes.modules.timer.renameTitle", "toolboxes.modules.timer.deleteTitle", + "toolboxes.modules.calendar.init.title", + "toolboxes.modules.calendar.init.weekly", + "toolboxes.modules.calendar.init.weeklyDescription", + "toolboxes.modules.calendar.init.monthly", + "toolboxes.modules.calendar.init.monthlyDescription", + "toolboxes.modules.calendar.init.soon", + "toolboxes.modules.calendar.gridLabel", + "toolboxes.modules.calendar.monthGridLabel", + "toolboxes.modules.calendar.titleLabel", + "toolboxes.modules.calendar.titlePlaceholder", + "toolboxes.modules.calendar.dayLabel", + "toolboxes.modules.calendar.startDayLabel", + "toolboxes.modules.calendar.endDayLabel", + "toolboxes.modules.calendar.startLabel", + "toolboxes.modules.calendar.endLabel", + "toolboxes.modules.calendar.alertModeTitle", + "toolboxes.modules.calendar.alertTypeLabel", + "toolboxes.modules.calendar.alertOffTitle", + "toolboxes.modules.calendar.alertVisibleTitle", + "toolboxes.modules.calendar.alertSiteTitle", + "toolboxes.modules.calendar.alertOffsetLabel", + "toolboxes.modules.calendar.alertOffset10", + "toolboxes.modules.calendar.alertOffset5", + "toolboxes.modules.calendar.alertOffset0", + "toolboxes.modules.calendar.alertMessage", + "toolboxes.modules.calendar.resumeAlertMessage", + "toolboxes.modules.calendar.colorLabel", + "toolboxes.modules.calendar.colorViolet", + "toolboxes.modules.calendar.colorGold", + "toolboxes.modules.calendar.colorCyan", + "toolboxes.modules.calendar.colorPink", + "toolboxes.modules.calendar.colorGreen", + "toolboxes.modules.calendar.colorGray", + "toolboxes.modules.calendar.repeatableTitle", + "toolboxes.modules.calendar.repeatLabel", + "toolboxes.modules.calendar.repeatNone", + "toolboxes.modules.calendar.repeatWeekly", + "toolboxes.modules.calendar.repeatMonthly", + "toolboxes.modules.calendar.monthlyRepeatLabel", + "toolboxes.modules.calendar.monthlyRepeatMonthDay", + "toolboxes.modules.calendar.monthlyRepeatWeekday", + "toolboxes.modules.calendar.monthDatePickerLabel", + "toolboxes.modules.calendar.previousMonthTitle", + "toolboxes.modules.calendar.nextMonthTitle", + "toolboxes.modules.calendar.currentMonthTitle", + "toolboxes.modules.calendar.previousShortLabel", + "toolboxes.modules.calendar.nextShortLabel", + "toolboxes.modules.calendar.hideEmptyHoursLabel", + "toolboxes.modules.calendar.importPlaceholder", + "toolboxes.modules.calendar.importOpenButton", + "toolboxes.modules.calendar.exportOpenButton", + "toolboxes.modules.calendar.importModalTitle", + "toolboxes.modules.calendar.exportModalTitle", + "toolboxes.modules.calendar.importButton", + "toolboxes.modules.calendar.exportButton", + "toolboxes.modules.calendar.exportInfo", + "toolboxes.modules.calendar.importSuccess", + "toolboxes.modules.calendar.exportSuccess", + "toolboxes.modules.calendar.importError", + "toolboxes.modules.calendar.addButton", + "toolboxes.modules.calendar.saveButton", + "toolboxes.modules.calendar.cancelButton", + "toolboxes.modules.calendar.editTitle", + "toolboxes.modules.calendar.copyTitle", + "toolboxes.modules.calendar.copiedTitle", + "toolboxes.modules.calendar.pastedTitle", + "toolboxes.modules.calendar.deleteTitle", + "toolboxes.modules.calendar.deleteOccurrenceTitle", + "toolboxes.modules.calendar.deleteSeriesTitle", + "toolboxes.modules.calendar.requiredError", + "toolboxes.modules.calendar.invalidRangeError", "toolboxes.modules.taskPlanner.settingsTitle", "toolboxes.modules.taskPlanner.settingsButtonLabel", "toolboxes.modules.taskPlanner.hideCompletedTitle", @@ -484,7 +555,7 @@ export function validateSiteContent(site) { }); const libraryToolDocs = valueAt(site, "library.toolDocs"); - ["notepad", "checklist", "counters", "images", "imageAnnotation", "links", "calculator", "table", "timer", "taskPlanner", "combos", "equipmentPlanner"].forEach((toolType) => { + ["notepad", "checklist", "counters", "images", "imageAnnotation", "links", "calculator", "table", "timer", "calendar", "taskPlanner", "combos", "equipmentPlanner"].forEach((toolType) => { const docs = libraryToolDocs?.[toolType]; assert.equal(typeof docs, "object", `library.toolDocs.${toolType} must be an object`); assert.ok(Array.isArray(docs.controls), `library.toolDocs.${toolType}.controls must be an array`); @@ -515,9 +586,11 @@ export function validateSiteContent(site) { }); } }); - assert.equal(typeof docs.importFormat, "object", `library.toolDocs.${toolType}.importFormat must be an object`); - assertNonEmptyString({ importFormat: docs.importFormat }, "importFormat.text"); - assertNonEmptyString({ importFormat: docs.importFormat }, "importFormat.example"); + if (docs.importFormat !== undefined) { + assert.equal(typeof docs.importFormat, "object", `library.toolDocs.${toolType}.importFormat must be an object`); + assertNonEmptyString({ importFormat: docs.importFormat }, "importFormat.text"); + assertNonEmptyString({ importFormat: docs.importFormat }, "importFormat.example"); + } }); const aboutLimits = valueAt(site, "about.limits"); @@ -695,7 +768,7 @@ export function validateLibraryData(payload) { assert.deepEqual(new Set(twoColumnIds), moduleIds, "library.toolbox.moduleOrder.two must contain every module exactly once"); assert.equal(twoColumnIds.length, moduleIds.size, "library.toolbox.moduleOrder.two must not contain duplicate modules"); - ["notepad", "checklist", "images", "links", "counters", "combos", "calculator", "table", "timer", "taskPlanner", "equipmentPlanner", "imageAnnotation"].forEach((type) => { + ["notepad", "checklist", "images", "links", "counters", "combos", "calculator", "table", "timer", "calendar", "taskPlanner", "equipmentPlanner", "imageAnnotation"].forEach((type) => { assert.ok(payload.toolbox.modules.some((module) => module.type === type), `library must include a ${type} example`); }); diff --git a/tests/static-app.test.mjs b/tests/static-app.test.mjs index a3f6004..d975c19 100644 --- a/tests/static-app.test.mjs +++ b/tests/static-app.test.mjs @@ -56,6 +56,10 @@ test("vite entrypoint and app shell are wired", async () => { assert.match(modals, /export function CreateToolboxModal/); assert.match(modals, /export function LinkToolboxModal/); assert.match(modals, /export function NotificationToast/); + assert.match(modals, /durationMs = 120000/); + assert.match(modals, /setTimeout\(\(\) => onClose\(id\), durationMs\)/); + assert.match(source, /SHORT_NOTIFICATION_DURATION_MS = 5000/); + assert.match(source, /getNotificationDuration/); assert.match(modals, /useModalScrollLock/); assert.match(imageViewer, /export function ImageViewer/); assert.match(imageViewer, /function openImageInNewTab/); diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs index 9a8d414..96aab94 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -7,6 +7,9 @@ import { applyDueResets, getTaskParentMap, getTaskTreeIds, moveTask } from "../w import { createInput, getInputLabel, normalizeCustomKey, parseHoldSeconds } from "../website/src/features/toolboxes/modules/combosUtils.js"; import { getBoundsFromStartAndSize, getSelectionBounds, moveSelectionCells, parseTsvBlock, pasteCellBlock } from "../website/src/features/toolboxes/modules/tableUtils.js"; import { canToggleAutoRefresh, getCountdownValidationError, isAlertGuarded } from "../website/src/features/toolboxes/modules/timerOptions.js"; +import { getCalendarOccurrences, getCurrentCalendarOccurrence, getMonthEnd, getMonthStart, getNextCalendarAlertTime, getWeekStart } from "../website/src/features/toolboxes/modules/calendarUtils.js"; +import { exportModuleText, importModuleText } from "../website/src/features/toolboxes/modules/textImport.js"; +import { compactCalendarDataForStorage, normalizeCalendarData } from "../website/src/features/toolboxes/storage/modules/calendar.js"; test("toolbox storage, cards and pages are wired", async () => { const source = await readFile("website/src/main.jsx", "utf8"); @@ -17,6 +20,7 @@ test("toolbox storage, cards and pages are wired", async () => { const storageCompactorRegistry = await readFile("website/src/features/toolboxes/storage/modules/index.js", "utf8"); const checklistStorage = await readFile("website/src/features/toolboxes/storage/modules/checklist.js", "utf8"); const timerStorage = await readFile("website/src/features/toolboxes/storage/modules/timer.js", "utf8"); + const calendarStorage = await readFile("website/src/features/toolboxes/storage/modules/calendar.js", "utf8"); const equipmentStorage = await readFile("website/src/features/toolboxes/storage/modules/equipmentPlanner.js", "utf8"); const indexedToolboxesHook = await readFile("website/src/features/toolboxes/storage/useIndexedToolboxes.js", "utf8"); const storageQuota = await readFile("website/src/components/StorageQuota.jsx", "utf8"); @@ -56,6 +60,7 @@ test("toolbox storage, cards and pages are wired", async () => { assert.match(toolboxStorage, /normalizeCalculatorData/); assert.match(toolboxStorage, /normalizeTableData/); assert.match(toolboxStorage, /normalizeTimerData/); + assert.match(toolboxStorage, /normalizeCalendarData/); assert.match(toolboxStorage, /normalizeTaskPlannerData/); assert.match(toolboxStorage, /normalizeEquipmentPlannerData/); assert.match(toolboxStorage, /summarizeEquipmentPlannerData/); @@ -63,7 +68,10 @@ test("toolbox storage, cards and pages are wired", async () => { assert.match(toolboxStorage, /export function compactModuleDataForStorage\(type, value\)/); assert.match(storageCompactorRegistry, /export const MODULE_DATA_COMPACTORS/); assert.match(storageCompactorRegistry, /timer: compactTimerDataForStorage/); + assert.match(storageCompactorRegistry, /calendar: compactCalendarDataForStorage/); assert.match(timerStorage, /scrollResults/); + assert.match(calendarStorage, /normalizeCalendarData/); + assert.match(toolboxStorage, /nextId\("calendar"\)/); assert.match(toolboxCard, /DEFAULT_TOOLBOX_ICON/); assert.match(toolboxCard, /export function ToolboxCard/); assert.match(toolboxCard, /export function ToolboxIconPicker/); @@ -140,6 +148,8 @@ test("toolbox module registry and modules expose expected behavior", async () => const timerModule = await readFile("website/src/features/toolboxes/modules/TimerModule.jsx", "utf8"); const timerParts = await readFile("website/src/features/toolboxes/modules/TimerParts.jsx", "utf8"); const timerOptions = await readFile("website/src/features/toolboxes/modules/timerOptions.js", "utf8"); + const calendarModule = await readFile("website/src/features/toolboxes/modules/CalendarModule.jsx", "utf8"); + const calendarUtils = await readFile("website/src/features/toolboxes/modules/calendarUtils.js", "utf8"); const taskPlannerModule = await readFile("website/src/features/toolboxes/modules/TaskPlannerModule.jsx", "utf8"); const taskPlannerItems = await readFile("website/src/features/toolboxes/modules/TaskPlannerItems.jsx", "utf8"); const taskPlannerUtils = await readFile("website/src/features/toolboxes/modules/taskPlannerUtils.js", "utf8"); @@ -177,6 +187,7 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(moduleRegistry, /table:/); assert.match(moduleRegistry, /table: \{ \.\.\.TOOLBOX_MODULE_DEFINITIONS\.table, Component: TableModule \}/); assert.match(moduleRegistry, /timer:/); + assert.match(moduleRegistry, /calendar:/); assert.match(moduleRegistry, /taskPlanner:/); assert.match(moduleRegistry, /equipmentPlanner:/); assert.match(moduleMetadataRegistry, /Planificateur d'équipements/); @@ -287,6 +298,35 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(timerModule, /useDraftForm/); assert.match(timerModule, /normalizeTimerData/); assert.match(timerModule, /TextExchangeActions/); + assert.match(calendarModule, /export function CalendarModule/); + assert.match(calendarModule, /normalizeCalendarData/); + assert.match(calendarModule, /calendar-mode-choice/); + assert.match(calendarModule, /calendar-week-grid/); + assert.match(calendarModule, /calendar-month-grid/); + assert.match(calendarModule, /getMonthOccurrenceSegments/); + assert.match(calendarModule, /calendar-month-bar/); + assert.match(calendarModule, /MonthDateDropdown/); + assert.match(calendarModule, /MonthEventRepeatSwitch/); + assert.match(calendarModule, /monthlyRepeatBy/); + assert.match(calendarModule, /displayedMonth/); + assert.match(calendarModule, /deleteOccurrence/); + assert.match(calendarModule, /onPointerDown/); + assert.match(calendarModule, /copyEvent/); + assert.match(calendarModule, /calendar-repeat-switch/); + assert.match(calendarModule, /layout-switch calendar-month-repeat-switch/); + assert.match(calendarModule, /WEEK_TIME_INPUT_STEP_MINUTES \* 60/); + assert.match(calendarModule, /durationMinutes < 120/); + assert.match(calendarModule, /is-compact/); + assert.match(calendarModule, /handleKeyDown/); + assert.match(calendarModule, /TextExchangeActions/); + assert.match(calendarModule, /type="calendar"/); + assert.match(calendarModule, /repeatLabel/); + assert.match(calendarModule, /CalendarAlertModeSwitch/); + assert.doesNotMatch(moduleRegistry, /calendarInitOpen/); + assert.doesNotMatch(moduleRegistry, /calendar-init-modal/); + assert.match(calendarUtils, /export function getCalendarOccurrences/); + assert.match(calendarUtils, /CALENDAR_VIEW_MODES = \["week", "month"\]/); + assert.match(calendarUtils, /export function getNextCalendarAlertTime/); assert.match(taskPlannerModule, /export function TaskPlannerModule/); assert.match(taskPlannerModule, /normalizeTaskPlannerData/); assert.match(taskPlannerModule, /applyDueResets/); @@ -397,6 +437,9 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(textImport, /export function importModuleText/); assert.match(textImport, /export function hasTextExchangeContent/); assert.match(textImport, /line\.indexOf\(":"\)/); + assert.match(textImport, /exportCalendarText/); + assert.match(textImport, /importCalendarText/); + assert.match(textImport, /CALENDAR_WEEKDAYS/); assert.match(imageViewer, /image-viewer-media/); assert.match(imageViewer, /image-viewer-image-frame/); assert.match(imageViewer, /image-viewer-marker/); @@ -480,3 +523,111 @@ test("timer options keep alert guards and countdown validation stable", () => { assert.equal(getCountdownValidationError("duration", { duration: { hours: "", minutes: "", seconds: "" } }, {}), "Valeur requise."); assert.equal(getCountdownValidationError("daily_time", { time: { hours: "12", minutes: "30", seconds: "" } }, {}), ""); }); + +test("calendar helpers keep normalization, occurrences and alerts stable", () => { + const data = normalizeCalendarData({ + events: [ + { + id: "h1", + title: "Raid", + dayIndex: 4, + startMinutes: 1080, + endMinutes: 1200, + repeatable: false, + alertMode: "site", + alertOffsetMinutes: [10, 5, 0], + color: "gold" + }, + { title: "", dayIndex: 9, startMinutes: -1 } + ] + }); + assert.equal(data.events.length, 1); + assert.equal(data.initialized, true); + assert.equal(data.events[0].repeat, "none"); + assert.equal(data.events[0].repeatable, false); + const compactCalendar = compactCalendarDataForStorage(data); + assert.equal(compactCalendar.initialized, true); + assert.deepEqual(compactCalendar.events[0], { + id: "h1", + title: "Raid", + dayIndex: 4, + startMinutes: 1080, + endMinutes: 1200, + repeatable: false, + alertMode: "site", + alertOffsetMinutes: [10, 5, 0], + color: "gold" + }); + + const weekStart = getWeekStart(new Date("2026-08-17T12:00:00.000Z"), 1); + const occurrences = getCalendarOccurrences(data.events, weekStart, new Date(weekStart.getTime() + 7 * 24 * 60 * 60 * 1000)); + assert.equal(occurrences.length, 1); + assert.equal(new Date(occurrences[0].startMs).getHours(), 18); + assert.equal(getNextCalendarAlertTime(data.events[0], new Date(2026, 7, 14, 17, 49).getTime()), new Date(2026, 7, 14, 17, 50).getTime()); + assert.equal(getCurrentCalendarOccurrence(data.events[0], new Date(2026, 7, 14, 19, 0).getTime()).event.id, "h1"); + + const monthly = normalizeCalendarData({ + viewMode: "month", + events: [ + { id: "h2", title: "Prime", startDate: "2026-08-14", endDate: "2026-08-16", repeat: "monthly", alertMode: "visible", alertOffsetMinutes: [0] }, + { id: "h3", title: "Premier lundi", startDate: "2026-08-03", endDate: "2026-08-05", repeat: "monthly", monthlyRepeatBy: "weekday" }, + { id: "h6", title: "Hebdo", startDate: "2026-08-03", endDate: "2026-08-05", repeat: "weekly", monthlyRepeatBy: "weekday", excludedOccurrences: ["2026-08-10"] }, + { id: "bad", title: "Sans cible", startMinutes: 720 } + ] + }); + assert.equal(monthly.viewMode, "month"); + assert.equal(monthly.events.length, 3); + assert.equal(monthly.events[0].repeat, "monthly"); + assert.equal(monthly.events[0].monthlyRepeatBy, "monthDay"); + assert.equal(monthly.events[1].monthlyRepeatBy, "weekday"); + assert.equal(monthly.events[2].repeat, "weekly"); + const compactMonthly = compactCalendarDataForStorage(monthly); + assert.equal(compactMonthly.viewMode, "month"); + assert.equal(compactMonthly.events[0].startDate, "2026-08-14"); + assert.equal(compactMonthly.events[0].endDate, "2026-08-16"); + assert.equal("startMinutes" in compactMonthly.events[0], false); + assert.equal(compactMonthly.events[1].monthlyRepeatBy, "weekday"); + assert.equal(compactMonthly.events[2].repeat, "weekly"); + assert.deepEqual(compactMonthly.events[2].excludedOccurrences, ["2026-08-10"]); + + const augustStart = getMonthStart(new Date(2026, 7, 14)); + const augustOccurrences = getCalendarOccurrences(monthly.events, augustStart, getMonthEnd(augustStart)); + assert.equal(augustOccurrences.filter((occurrence) => occurrence.event.id === "h2").length, 1); + assert.equal(augustOccurrences.filter((occurrence) => occurrence.event.id === "h3").length, 1); + assert.equal(augustOccurrences.filter((occurrence) => occurrence.event.id === "h6").length, 4); + assert.equal(new Date(augustOccurrences.find((occurrence) => occurrence.event.id === "h2").endMs).getDate(), 17); + assert.equal(getNextCalendarAlertTime(monthly.events[0], new Date(2026, 7, 13, 23, 59).getTime()), new Date(2026, 7, 14, 0, 0).getTime()); + + const thirtyFirst = normalizeCalendarData({ + viewMode: "month", + events: [{ id: "h4", title: "31", startDate: "2026-08-31", repeat: "monthly" }] + }); + const septemberStart = getMonthStart(new Date(2026, 8, 1)); + assert.equal(getCalendarOccurrences(thirtyFirst.events, septemberStart, getMonthEnd(septemberStart)).length, 0); +}); + +test("calendar text exchange imports and exports weekly and monthly calendars", () => { + let uidIndex = 0; + const context = { + uid: (prefix) => `${prefix}${uidIndex += 1}`, + normalizeCalendarData + }; + const weekly = importModuleText("calendar", "@viewMode: week\nevent | title=Raid | day=vendredi | start=18:00 | end=20:00 | color=gold | alertMode=site | alerts=10,5", context); + assert.equal(weekly.ok, true); + assert.equal(weekly.data.viewMode, "week"); + assert.equal(weekly.data.events[0].dayIndex, 4); + assert.equal(weekly.data.events[0].startMinutes, 1080); + assert.deepEqual(weekly.data.events[0].alertOffsetMinutes, [10, 5]); + assert.match(exportModuleText("calendar", weekly.data), /day=vendredi/); + + const monthly = importModuleText("calendar", "@viewMode: month\nevent | title=Maintenance | repeatBy=weekday | repeat=monthly | startDate=2026-08-03 | endDate=2026-08-05 | color=pink\nevent | title=Event | repeatBy=monthDay | repeat=none | startDate=2026-08-20 | endDate=2026-08-27", context); + assert.equal(monthly.ok, true); + assert.equal(monthly.data.viewMode, "month"); + assert.equal(monthly.data.events[0].monthlyRepeatBy, "weekday"); + assert.equal(monthly.data.events[0].startDate, "2026-08-03"); + assert.equal(monthly.data.events[0].repeat, "monthly"); + assert.equal(monthly.data.events[1].startDate, "2026-08-20"); + assert.equal(monthly.data.events[1].repeat, "none"); + assert.match(exportModuleText("calendar", monthly.data), /startDate=2026-08-03/); + assert.match(exportModuleText("calendar", monthly.data), /repeatBy=monthDay/); +}); diff --git a/tests/toolbox-modules.test.mjs b/tests/toolbox-modules.test.mjs index 0ab2924..fd89646 100644 --- a/tests/toolbox-modules.test.mjs +++ b/tests/toolbox-modules.test.mjs @@ -786,6 +786,10 @@ test("notepad data migrates text, sanitizes html and compacts drawings", () => { assert.equal(normalized.drawings.strokes[0].width, 24); assert.deepEqual(normalized.drawings.strokes[0].points, [{ x: 4, y: 8 }, { x: 12.35, y: 16.79 }]); + const escaped = normalizeNotepadData({ html: "

"&amp;quot; & test

" }); + assert.equal(escaped.text, "\"\" & test"); + assert.equal(escaped.html, "

"" & test

"); + const compactTemporary = compactModuleDataForStorage("notepad", { html: "

Note

", text: "Note", diff --git a/website/public/data/library.json b/website/public/data/library.json index bfef493..d93fd30 100644 --- a/website/public/data/library.json +++ b/website/public/data/library.json @@ -40,6 +40,165 @@ "id": "m9", "type": "timer" }, + { + "id": "md", + "type": "calendar", + "title": "Calendrier hebdomadaire", + "controls": [ + { + "group": "Contrôles", + "icon": "calendar", + "title": "Bouton changement de couleur", + "preview": { + "kind": "color" + } + }, + { + "group": "Contrôles", + "icon": "refresh", + "title": "Répéter les événements", + "preview": { + "kind": "switch", + "icon": "refresh", + "active": true + } + }, + { + "group": "Contrôles", + "icon": "eye-closed", + "title": "Masquer / Afficher les heures inutiles", + "preview": { + "kind": "switch", + "icon": "eye-closed" + } + }, + { + "group": "Contrôles", + "icon": "sound-mute", + "title": "Alerte désactivée", + "preview": { + "kind": "iconButton", + "icon": "sound-mute" + } + }, + { + "group": "Contrôles", + "icon": "sound-min", + "title": "Alerte sur la page", + "preview": { + "kind": "iconButton", + "icon": "sound-min" + } + }, + { + "group": "Contrôles", + "icon": "sound-max", + "title": "Alerte sur le site", + "preview": { + "kind": "iconButton", + "icon": "sound-max" + } + }, + { + "group": "Contrôles", + "icon": "trash", + "title": "Bouton supprimer", + "preview": { + "kind": "iconButton", + "icon": "trash", + "danger": true + } + } + ], + "importFormat": { + "text": "Exemple texte du calendrier hebdomadaire de démonstration.", + "example": "@viewMode: week\nevent | title=Uturi | day=lundi | start=00:15 | end=00:20\nevent | title=Uturi | day=lundi | start=16:00 | end=16:05\nevent | title=Uturi | day=mardi | start=19:00 | end=19:05\nevent | title=Uturi | day=mercredi | start=22:15 | end=22:20\nevent | title=Uturi | day=jeudi | start=00:15 | end=00:20\nevent | title=Uturi | day=vendredi | start=12:00 | end=12:05\nevent | title=Uturi | day=samedi | start=02:00 | end=02:05\nevent | title=Uturi | day=dimanche | start=12:00 | end=12:05\nevent | title=Garmoth | color=pink | day=lundi | start=14:00 | end=14:05\nevent | title=Garmoth | color=pink | day=mardi | start=14:00 | end=14:05\nevent | title=Garmoth | color=pink | day=mercredi | start=14:00 | end=14:05\nevent | title=Garmoth | color=pink | day=jeudi | start=14:00 | end=14:05\nevent | title=Garmoth | color=pink | day=vendredi | start=14:00 | end=14:05\nevent | title=Garmoth | color=pink | day=samedi | start=14:00 | end=14:05\nevent | title=Garmoth | color=pink | day=dimanche | start=14:00 | end=14:05\nevent | title=Garmoth | color=pink | day=lundi | start=23:15 | end=23:20\nevent | title=Garmoth | color=pink | day=mardi | start=23:15 | end=23:20\nevent | title=Garmoth | color=pink | day=mercredi | start=23:15 | end=23:20\nevent | title=Garmoth | color=pink | day=jeudi | start=23:15 | end=23:20\nevent | title=Garmoth | color=pink | day=vendredi | start=23:15 | end=23:20\nevent | title=Garmoth | color=pink | day=dimanche | start=23:15 | end=23:20\nevent | title=Garmoth | color=pink | day=dimanche | start=19:15 | end=19:20\nevent | title=Vell | color=cyan | day=mercredi | start=19:00 | end=19:05\nevent | title=Vell | color=cyan | day=dimanche | start=16:00 | end=16:05\nevent | title=Maintenance | color=gold | day=jeudi | start=10:00 | end=14:00" + } + }, + { + "id": "me", + "type": "calendar", + "title": "Calendrier mensuel", + "controls": [ + { + "group": "Contrôles", + "icon": "calendar", + "title": "Bouton changement de couleur", + "preview": { + "kind": "color" + } + }, + { + "group": "Contrôles", + "icon": "day-number", + "title": "Numéro de jour", + "preview": { + "kind": "iconButton", + "icon": "day-number" + } + }, + { + "group": "Contrôles", + "icon": "day-name", + "title": "Nom de jour", + "preview": { + "kind": "iconButton", + "icon": "day-name" + } + }, + { + "group": "Contrôles", + "icon": "refresh", + "title": "Répéter les événements", + "preview": { + "kind": "switch", + "icon": "refresh", + "active": true + } + }, + { + "group": "Contrôles", + "icon": "sound-mute", + "title": "Alerte désactivée", + "preview": { + "kind": "iconButton", + "icon": "sound-mute" + } + }, + { + "group": "Contrôles", + "icon": "sound-min", + "title": "Alerte sur la page", + "preview": { + "kind": "iconButton", + "icon": "sound-min" + } + }, + { + "group": "Contrôles", + "icon": "sound-max", + "title": "Alerte sur le site", + "preview": { + "kind": "iconButton", + "icon": "sound-max" + } + }, + { + "group": "Contrôles", + "icon": "trash", + "title": "Bouton supprimer", + "preview": { + "kind": "iconButton", + "icon": "trash", + "danger": true + } + } + ], + "importFormat": { + "text": "Exemple texte du calendrier mensuel de démonstration.", + "example": "@viewMode: month\nevent | title=Double EXP 1er Week end du mois | repeatBy=weekday | repeat=none | startDate=2026-08-01 | endDate=2026-08-02\nevent | title=Event de drop rare | color=gold | repeatBy=weekday | repeat=none | startDate=2026-08-18 | endDate=2026-08-25\nevent | title=Maintenance | color=pink | repeatBy=weekday | repeat=weekly | startDate=2026-08-04\nevent | title=Récompenses du 15 | color=green | repeatBy=monthDay | repeat=none | startDate=2026-08-15" + } + }, { "id": "ma", "type": "taskPlanner" @@ -54,8 +213,8 @@ } ], "moduleOrder": { - "one": ["m1", "m2", "m3", "m4", "m5", "m6", "m7", "m8", "m9", "ma", "mb", "mc"], - "two": [["m1", "m2", "m3", "m4", "m5", "m6"], ["m7", "m8", "m9", "ma", "mb", "mc"]] + "one": ["m1", "m2", "m3", "m4", "m5", "m6", "m7", "m8", "m9", "md", "me", "ma", "mb", "mc"], + "two": [["m1", "m2", "m3", "m4", "m5", "m6", "ma"], ["m7", "m8", "m9", "md", "me", "mb", "mc"]] }, "updatedAt": "2026-08-09T07:34:31.425Z", "moduleColumns": 1 @@ -1325,6 +1484,238 @@ } ] }, + "md": { + "initialized": true, + "events": [ + { + "id": "hdl7oex", + "title": "Uturi", + "startMinutes": 15, + "dayIndex": 0, + "endMinutes": 20 + }, + { + "id": "hgdodzy", + "title": "Uturi", + "startMinutes": 960, + "dayIndex": 0, + "endMinutes": 965 + }, + { + "id": "hy95mcm", + "title": "Uturi", + "startMinutes": 1140, + "dayIndex": 1, + "endMinutes": 1145 + }, + { + "id": "hs2tg2h", + "title": "Uturi", + "startMinutes": 1335, + "dayIndex": 2, + "endMinutes": 1340 + }, + { + "id": "hv903h8", + "title": "Uturi", + "startMinutes": 15, + "dayIndex": 3, + "endMinutes": 20 + }, + { + "id": "hanpqlt", + "title": "Uturi", + "startMinutes": 720, + "dayIndex": 4, + "endMinutes": 725 + }, + { + "id": "h0e6ma2", + "title": "Uturi", + "startMinutes": 120, + "dayIndex": 5, + "endMinutes": 125 + }, + { + "id": "hc3ocs0", + "title": "Uturi", + "startMinutes": 720, + "dayIndex": 6, + "endMinutes": 725 + }, + { + "id": "hv1i4lf", + "title": "Garmoth", + "startMinutes": 840, + "dayIndex": 0, + "endMinutes": 845, + "color": "pink" + }, + { + "id": "h1uz1u2", + "title": "Garmoth", + "startMinutes": 840, + "dayIndex": 1, + "endMinutes": 845, + "color": "pink" + }, + { + "id": "hdci41f", + "title": "Garmoth", + "startMinutes": 840, + "dayIndex": 2, + "endMinutes": 845, + "color": "pink" + }, + { + "id": "h08ha51", + "title": "Garmoth", + "startMinutes": 840, + "dayIndex": 3, + "endMinutes": 845, + "color": "pink" + }, + { + "id": "hnz11qv", + "title": "Garmoth", + "startMinutes": 840, + "dayIndex": 4, + "endMinutes": 845, + "color": "pink" + }, + { + "id": "h2dexvq", + "title": "Garmoth", + "startMinutes": 840, + "dayIndex": 5, + "endMinutes": 845, + "color": "pink" + }, + { + "id": "hq9ctue", + "title": "Garmoth", + "startMinutes": 840, + "dayIndex": 6, + "endMinutes": 845, + "color": "pink" + }, + { + "id": "hfq7scf", + "title": "Garmoth", + "startMinutes": 1395, + "dayIndex": 0, + "endMinutes": 1400, + "color": "pink" + }, + { + "id": "hfbeg1y", + "title": "Garmoth", + "startMinutes": 1395, + "dayIndex": 1, + "endMinutes": 1400, + "color": "pink" + }, + { + "id": "h2gfv4o", + "title": "Garmoth", + "startMinutes": 1395, + "dayIndex": 2, + "endMinutes": 1400, + "color": "pink" + }, + { + "id": "hjjzqfz", + "title": "Garmoth", + "startMinutes": 1395, + "dayIndex": 3, + "endMinutes": 1400, + "color": "pink" + }, + { + "id": "h1h2zsd", + "title": "Garmoth", + "startMinutes": 1395, + "dayIndex": 4, + "endMinutes": 1400, + "color": "pink" + }, + { + "id": "ht08lsw", + "title": "Garmoth", + "startMinutes": 1395, + "dayIndex": 6, + "endMinutes": 1400, + "color": "pink" + }, + { + "id": "h8k8cbm", + "title": "Garmoth", + "startMinutes": 1155, + "dayIndex": 6, + "endMinutes": 1160, + "color": "pink" + }, + { + "id": "hzq4r4v", + "title": "Vell", + "startMinutes": 1140, + "dayIndex": 2, + "endMinutes": 1145, + "color": "cyan" + }, + { + "id": "hxq7o9p", + "title": "Vell", + "startMinutes": 960, + "dayIndex": 6, + "endMinutes": 965, + "color": "cyan" + }, + { + "id": "hfkycds", + "title": "Maintenance", + "startMinutes": 600, + "dayIndex": 3, + "endMinutes": 840, + "color": "gold" + } + ] + }, + "me": { + "initialized": true, + "viewMode": "month", + "events": [ + { + "id": "hpnato7", + "title": "Double EXP 1er Week end du mois", + "startDate": "2026-08-01", + "endDate": "2026-08-02", + "monthlyRepeatBy": "weekday" + }, + { + "id": "hdygtpx", + "title": "Event de drop rare", + "startDate": "2026-08-18", + "endDate": "2026-08-25", + "monthlyRepeatBy": "weekday", + "color": "gold" + }, + { + "id": "hy8bjtd", + "title": "Maintenance", + "startDate": "2026-08-04", + "repeat": "weekly", + "monthlyRepeatBy": "weekday", + "color": "pink" + }, + { + "id": "hytouda", + "title": "Récompenses du 15", + "startDate": "2026-08-15", + "color": "green" + } + ] + }, "ma": { "lastResetAt": 1786226400000, "hideCompleted": true, diff --git a/website/public/data/site.json b/website/public/data/site.json index 4cccfe7..13b9bc6 100644 --- a/website/public/data/site.json +++ b/website/public/data/site.json @@ -884,6 +884,90 @@ "example": "@activeTab: countdown\nlap | label=Fosse 110 | elapsedMs=1046475\nlap | label=Fosse 55 | elapsedMs=50019\ncountdown | label=Fin de vague infernale | type=time_pattern | alertMode=off | pattern=X:55:X\ncountdown | label=Boss Mondial | type=daily_time | alertMode=off | time=14:00:00\ncountdown | label=Legion | type=interval | alertMode=off | autoRefresh=true | intervalMs=1800000 | startMode=now\ncountdown | label=Durée | type=duration | alertMode=off | durationMs=1800000" } }, + "calendar": { + "controls": [ + { + "group": "Événement", + "icon": "calendar", + "title": "Sélection de créneaux", + "preview": { + "kind": "iconButton", + "icon": "calendar" + } + }, + { + "group": "Événement", + "icon": "copy", + "title": "Copier un événement", + "preview": { + "kind": "iconButton", + "icon": "copy" + } + }, + { + "group": "Alertes", + "icon": "sound-max", + "title": "Alerte site", + "preview": { + "kind": "iconButton", + "icon": "sound-max" + } + }, + { + "group": "Événement", + "icon": "trash", + "title": "Supprimer", + "preview": { + "kind": "iconButton", + "icon": "trash", + "danger": true + } + } + ], + "features": [ + { + "title": "Vue hebdomadaire", + "text": "Une grille jours par créneaux de 30 minutes pour planifier les événements de session.", + "details": [ + "Les heures peuvent être saisies à 5 minutes près.", + "La grille reste lisible dans la toolbox complète et dans le panneau latéral.", + "Les plages vides peuvent être masquées sans retirer l'indicateur de tranche horaire." + ] + }, + { + "title": "Vue mensuelle", + "text": "Une grille semaines par jours affiche les numéros du mois courant sans nommer le mois.", + "details": [ + "Les événements mensuels peuvent cibler un numéro du jour ou un jour de semaine.", + "Les événements multi-jours s'affichent en barre horizontale.", + "Les jours hors mois courant ne sont pas sélectionnables." + ] + }, + { + "title": "Création rapide", + "text": "Un événement peut être saisi au formulaire ou prérempli en sélectionnant des cellules.", + "details": [ + "Un événement peut être ponctuel ou avoir une durée.", + "Dans un calendrier hebdomadaire, les événements se répètent automatiquement chaque semaine.", + "Un événement copié peut être collé sur un autre créneau en conservant durée, couleur et alerte.", + "La couleur permet de distinguer rapidement les catégories d'événements." + ] + }, + { + "title": "Alertes", + "text": "Les alertes utilisent les mêmes modes que le timer, avec un délai de 5 ou 10 minutes.", + "details": [ + "Aucune alerte garde l'événement silencieux.", + "Alerte visible prévient seulement quand la toolbox concernée est ouverte.", + "Alerte site fonctionne sur toute l'application et signale aussi une session déjà en cours une seule fois." + ] + } + ], + "importFormat": { + "text": "Le calendrier s'échange en lignes texte : une meta @viewMode puis une ligne event par entrée.", + "example": "@viewMode: week\nevent | title=Raid hebdo | day=vendredi | start=18:00 | end=20:00 | color=gold | alertMode=site | alerts=10,5\n\n@viewMode: month\nevent | title=Week-end Double EXP | repeatBy=monthDay | repeat=monthly | startDate=2026-08-07 | endDate=2026-08-09\nevent | title=Maintenance | repeatBy=weekday | repeat=weekly | startDate=2026-08-06 | color=pink" + } + }, "taskPlanner": { "controls": [ { @@ -1792,6 +1876,81 @@ "renameTitle": "Renommer", "deleteTitle": "Supprimer" }, + "calendar": { + "init": { + "title": "Créer un calendrier", + "weekly": "Hebdomadaire", + "weeklyDescription": "Grille jours par créneaux de 30 minutes.", + "monthly": "Mensuel", + "monthlyDescription": "Grille mensuelle datée avec navigation.", + "soon": "À venir" + }, + "gridLabel": "Calendrier hebdomadaire", + "monthGridLabel": "Calendrier mensuel", + "titleLabel": "Titre", + "titlePlaceholder": "Raid, reset, event...", + "dayLabel": "Jour", + "startDayLabel": "Date début", + "endDayLabel": "Date fin", + "startLabel": "Début", + "endLabel": "Fin", + "alertModeTitle": "Mode d'alerte", + "alertTypeLabel": "Type d'alertes", + "alertOffTitle": "Pas d'alerte", + "alertVisibleTitle": "Alerte quand la toolbox est affichée", + "alertSiteTitle": "Alerte sur tout le site", + "alertOffsetLabel": "Alerte", + "alertOffset10": "10 min avant", + "alertOffset5": "5 min avant", + "alertOffset0": "Au début et pendant", + "alertMessage": "{label} commence bientôt dans {toolbox}.", + "resumeAlertMessage": "{label} est en cours dans {toolbox}.", + "hideEmptyHoursLabel": "Masquer les heures vides", + "colorLabel": "Couleur", + "colorViolet": "Violet", + "colorGold": "Or", + "colorCyan": "Cyan", + "colorPink": "Rose", + "colorGreen": "Vert", + "colorGray": "Gris", + "repeatableTitle": "Répétable", + "repeatLabel": "Répétition", + "repeatNone": "Aucune", + "repeatWeekly": "Chaque semaine", + "repeatMonthly": "Chaque mois", + "monthlyRepeatLabel": "Règle mensuelle", + "monthlyRepeatMonthDay": "Numéro du jour", + "monthlyRepeatWeekday": "Jour de semaine", + "monthDatePickerLabel": "Choisir une date", + "previousMonthTitle": "Mois précédent", + "nextMonthTitle": "Mois suivant", + "currentMonthTitle": "Mois courant", + "previousShortLabel": "Prec.", + "nextShortLabel": "Suiv.", + "importPlaceholder": "@viewMode: week\nevent | title=Raid hebdo | day=vendredi | start=18:00 | end=20:00 | color=gold | alertMode=site | alerts=10,5\n\n@viewMode: month\nevent | title=Maintenance | repeatBy=weekday | repeat=monthly | startDate=2026-08-03 | endDate=2026-08-05 | color=pink", + "importOpenButton": "Importer du texte", + "exportOpenButton": "Exporter en format texte", + "importModalTitle": "Importer un calendrier", + "exportModalTitle": "Exporter un calendrier", + "importButton": "Importer le texte", + "exportButton": "Copier le texte", + "exportInfo": "Format calendrier texte compatible avec les vues hebdomadaire et mensuelle.", + "importSuccess": "Import calendrier terminé.", + "exportSuccess": "Export calendrier copié.", + "importError": "Format de calendrier invalide.", + "addButton": "Ajouter", + "saveButton": "Enregistrer", + "cancelButton": "Annuler", + "editTitle": "Modifier", + "copyTitle": "Copier", + "copiedTitle": "Événement copié.", + "pastedTitle": "Événement collé.", + "deleteTitle": "Supprimer", + "deleteOccurrenceTitle": "Supprimer cette occurrence", + "deleteSeriesTitle": "Supprimer toutes les répétitions", + "requiredError": "Titre, date et heure de début requis.", + "invalidRangeError": "La fin doit être après le début." + }, "taskPlanner": { "importPlaceholder": "@resetTime: 06:00\n# Raid\n- daily Préparer la route\n @description: Notes rapides\n - unique Sous-tâche", "importOpenButton": "Importer du texte", diff --git a/website/public/static/icons/calendar-month.svg b/website/public/static/icons/calendar-month.svg new file mode 100644 index 0000000..f617ed0 --- /dev/null +++ b/website/public/static/icons/calendar-month.svg @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + diff --git a/website/public/static/icons/calendar-week.svg b/website/public/static/icons/calendar-week.svg new file mode 100644 index 0000000..b58d33d --- /dev/null +++ b/website/public/static/icons/calendar-week.svg @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + diff --git a/website/public/static/icons/calendar.svg b/website/public/static/icons/calendar.svg new file mode 100644 index 0000000..df2c10a --- /dev/null +++ b/website/public/static/icons/calendar.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/website/public/static/icons/day-name.svg b/website/public/static/icons/day-name.svg new file mode 100644 index 0000000..a802ac5 --- /dev/null +++ b/website/public/static/icons/day-name.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + diff --git a/website/public/static/icons/day-number.svg b/website/public/static/icons/day-number.svg new file mode 100644 index 0000000..b58d33d --- /dev/null +++ b/website/public/static/icons/day-number.svg @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + diff --git a/website/public/static/icons/trashcan-one.svg b/website/public/static/icons/trashcan-one.svg new file mode 100644 index 0000000..3458806 --- /dev/null +++ b/website/public/static/icons/trashcan-one.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + diff --git a/website/src/components/AppOverlays.jsx b/website/src/components/AppOverlays.jsx index 4f804e7..3ac7d6a 100644 --- a/website/src/components/AppOverlays.jsx +++ b/website/src/components/AppOverlays.jsx @@ -78,6 +78,7 @@ export function AppOverlays({ key={notification.id} id={notification.id} message={notification.message} + durationMs={notification.durationMs} onClose={dismissNotification} /> ))} diff --git a/website/src/components/ToolboxModals.jsx b/website/src/components/ToolboxModals.jsx index b5d96eb..6bfb20d 100644 --- a/website/src/components/ToolboxModals.jsx +++ b/website/src/components/ToolboxModals.jsx @@ -24,11 +24,11 @@ export function ConfirmModal({ title, message, confirmLabel = "Confirmer", cance ); } -export function NotificationToast({ id, message, onClose }) { +export function NotificationToast({ id, message, durationMs = 120000, onClose }) { useEffect(() => { - const timeoutId = window.setTimeout(() => onClose(id), 120000); + const timeoutId = window.setTimeout(() => onClose(id), durationMs); return () => window.clearTimeout(timeoutId); - }, [id, onClose]); + }, [durationMs, id, onClose]); return (
diff --git a/website/src/features/toolboxes/ToolboxPages.jsx b/website/src/features/toolboxes/ToolboxPages.jsx index b6242f0..e8c9d9f 100644 --- a/website/src/features/toolboxes/ToolboxPages.jsx +++ b/website/src/features/toolboxes/ToolboxPages.jsx @@ -15,6 +15,7 @@ import { clampQty, hostnameFromUrl, normalizeCalculatorData, + normalizeCalendarData, normalizeChecklistData, normalizeCombosData, normalizeCountersData, @@ -191,6 +192,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto normalizeCountersData, normalizeEquipmentPlannerData, normalizeCalculatorData, + normalizeCalendarData, normalizeImageAnnotationData, normalizeNotepadData, normalizeTableData, @@ -220,7 +222,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto setShowTutorial(shouldShowTutorial(toolbox, pendingTutorialToolboxId)); }, [pendingTutorialToolboxId, toolbox, toolboxId, toolboxTutorial]); - function addModule(type) { + function addModule(type, initialData = null) { if (!type) return; const module = { id: uid("mod"), type }; const modules = [...toolbox.modules, module]; @@ -229,6 +231,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto modules, moduleOrder: addModuleToOrder(toolbox.moduleOrder, modules, module.id) }); + if (initialData) updateModuleData(toolbox.id, module.id, initialData, type); } function createImageAnnotationModule(dataUrl) { diff --git a/website/src/features/toolboxes/modules/CalendarModule.jsx b/website/src/features/toolboxes/modules/CalendarModule.jsx new file mode 100644 index 0000000..0eb13b9 --- /dev/null +++ b/website/src/features/toolboxes/modules/CalendarModule.jsx @@ -0,0 +1,1215 @@ +// Rôle : fournit l'outil calendrier hebdomadaire/mensuel avec événements, récurrences et alertes. +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Icon } from "../../../components/Icon.jsx"; +import { Tooltip } from "../../../components/Tooltip.jsx"; +import { TextExchangeActions } from "./TextExchangeActions.jsx"; +import { + CALENDAR_ALERT_MODES, + CALENDAR_ALERT_OFFSETS, + CALENDAR_COLORS, + CALENDAR_MONTHLY_REPEAT_BY, + WEEK_SLOT_MINUTES, + WEEK_TIME_INPUT_STEP_MINUTES, + addDays, + addMonths, + formatLocalDateKey, + getCalendarOccurrences, + getLocalDayIndex, + getMonthEnd, + getMonthGridDays, + getMonthStart, + getWeekDays, + getWeekStart, + minutesToTimeValue, + pad2, + parseLocalDateKey, + timeValueToMinutes +} from "./calendarUtils.js"; +import { ALERT_MODES } from "./timerOptions.js"; + +const DAY_HOUR_COUNT = 24; +const HOUR_HALF_SLOT_COUNT = 2; +const WEEK_SLOT_COUNT = DAY_HOUR_COUNT * HOUR_HALF_SLOT_COUNT; +const WEEK_HOUR_ROW_HEIGHT = 27; +const WEEK_HIDDEN_ROW_HEIGHT = 20; +const WEEK_COMPACT_EVENT_HEIGHT = WEEK_HOUR_ROW_HEIGHT / HOUR_HALF_SLOT_COUNT; +const WEEKDAY_LABELS = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"]; + +const CALENDAR_COLOR_VALUES = { + violet: "#8b5cf6", + gold: "#f6c453", + cyan: "#22d3ee", + pink: "#d946ef", + green: "#22c55e", + gray: "#7d879f" +}; + +function createDefaultDraft(date = new Date()) { + const dayIndex = getLocalDayIndex(date); + const dateKey = formatLocalDateKey(date); + const startMinutes = Math.min(23 * 60 + 30, Math.ceil((date.getHours() * 60 + date.getMinutes()) / WEEK_SLOT_MINUTES) * WEEK_SLOT_MINUTES); + return { + id: "", + title: "", + dayIndex, + endDayIndex: dayIndex, + startDate: dateKey, + endDate: dateKey, + monthlyRepeatBy: "monthDay", + repeat: "monthly", + startTime: minutesToTimeValue(startMinutes), + endTime: minutesToTimeValue(Math.min(23 * 60 + 59, startMinutes + WEEK_SLOT_MINUTES)), + repeatable: true, + alertMode: "off", + alertOffsetMinutes: [], + color: "violet" + }; +} + +function formatDayLabel(day) { + return day.toLocaleDateString(undefined, { weekday: "long" }); +} + +function formatHourLabel(hourIndex) { + const minutes = hourIndex * 60; + return `${pad2(Math.floor(minutes / 60))}:${pad2(minutes % 60)}`; +} + +function getSelectionRange(selection) { + if (!selection) return null; + const startSlot = Math.min(selection.startSlot, selection.endSlot); + const endSlot = Math.max(selection.startSlot, selection.endSlot); + return { dayIndex: selection.dayIndex, startSlot, endSlot }; +} + +function isSlotSelected(selection, dayIndex, slotIndex) { + const range = getSelectionRange(selection); + return Boolean(range && range.dayIndex === dayIndex && slotIndex >= range.startSlot && slotIndex <= range.endSlot); +} + +function getMonthSelectionRange(selection) { + if (!selection) return null; + const startDate = selection.startDate <= selection.endDate ? selection.startDate : selection.endDate; + const endDate = selection.startDate <= selection.endDate ? selection.endDate : selection.startDate; + return { startDate, endDate }; +} + +function isMonthDateSelected(selection, dateKey) { + const range = getMonthSelectionRange(selection); + return Boolean(range && dateKey >= range.startDate && dateKey <= range.endDate); +} + +function getDraftSelection(draft) { + const dayIndex = Number(draft.dayIndex); + const startMinutes = timeValueToMinutes(draft.startTime); + const endMinutes = draft.endTime ? timeValueToMinutes(draft.endTime) : startMinutes + WEEK_SLOT_MINUTES; + if (dayIndex < 0 || dayIndex > 6 || startMinutes < 0 || endMinutes <= startMinutes) return null; + const startSlot = Math.floor(startMinutes / WEEK_SLOT_MINUTES); + const endSlot = Math.max(startSlot, Math.ceil(endMinutes / WEEK_SLOT_MINUTES) - 1); + return { + dayIndex, + startSlot: Math.max(0, Math.min(WEEK_SLOT_COUNT - 1, startSlot)), + endSlot: Math.max(0, Math.min(WEEK_SLOT_COUNT - 1, endSlot)) + }; +} + +function getCalendarRepeatMode() { + return "weekly"; +} + +function formatMonthTitle(dateLike) { + return new Date(dateLike).toLocaleDateString(undefined, { month: "long", year: "numeric" }); +} + +function formatMonthDateLabel(dateKey, mode) { + const date = parseLocalDateKey(dateKey); + if (!date) return ""; + const weekday = WEEKDAY_LABELS[getLocalDayIndex(date)]; + const dayLabel = date.toLocaleDateString(undefined, { day: "2-digit", month: "short", year: "numeric" }); + if (mode !== "weekday") return dayLabel; + const ordinal = Math.floor((date.getDate() - 1) / 7) + 1; + return `S${pad2(ordinal)} - ${weekday}`; +} + +function getWeekdayPickerRows(monthDate) { + const monthGridDays = getMonthGridDays(monthDate).filter((day) => day.inMonth); + return Array.from({ length: 5 }, (_, rowIndex) => { + const ordinal = rowIndex + 1; + return { + ordinal, + days: WEEKDAY_LABELS.map((_, dayIndex) => { + const matches = monthGridDays.filter((day) => day.dayIndex === dayIndex); + return matches[rowIndex] || null; + }) + }; + }); +} + +function normalizeDraftDateKey(value) { + const date = parseLocalDateKey(value); + return date ? formatLocalDateKey(date) : ""; +} + +function getDateSpanDays(startDateKey, endDateKey) { + const start = parseLocalDateKey(startDateKey); + const end = parseLocalDateKey(endDateKey); + if (!start || !end || end < start) return 0; + return Math.round((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000)); +} + +function normalizeDraftAlertOffsets(value) { + const rawOffsets = Array.isArray(value) ? value : [value]; + const offsets = [...new Set(rawOffsets.map(Number).filter((offset) => CALENDAR_ALERT_OFFSETS.includes(offset)))]; + return offsets.sort((a, b) => b - a); +} + +function occurrencesOverlap(first, second) { + const firstEnd = first.endMs > first.startMs ? first.endMs : first.startMs + WEEK_SLOT_MINUTES * 60000; + const secondEnd = second.endMs > second.startMs ? second.endMs : second.startMs + WEEK_SLOT_MINUTES * 60000; + return first.startMs < secondEnd && second.startMs < firstEnd; +} + +function layoutConcurrentOccurrences(dayOccurrences) { + const laneEndMs = [0, 0]; + return dayOccurrences + .map((occurrence, index, all) => { + const lane = laneEndMs[0] <= occurrence.startMs ? 0 : 1; + laneEndMs[lane] = occurrence.endMs > occurrence.startMs ? occurrence.endMs : occurrence.startMs + WEEK_SLOT_MINUTES * 60000; + const overlaps = all.some((item, itemIndex) => itemIndex !== index && occurrencesOverlap(occurrence, item)); + return { ...occurrence, lane, laneCount: overlaps ? 2 : 1 }; + }); +} + +function eventMinutesRange(occurrence) { + const startMinutes = Math.max(0, Math.min(24 * 60 - 1, Number(occurrence.event.startMinutes) || 0)); + const endMinutes = occurrence.event.endMinutes && occurrence.event.endMinutes > startMinutes + ? occurrence.event.endMinutes + : startMinutes + WEEK_SLOT_MINUTES; + return { + startMinutes, + endMinutes: Math.max(startMinutes + WEEK_SLOT_MINUTES, Math.min(24 * 60, endMinutes)) + }; +} + +function getWeekGridLayout(occurrences, hideEmptyHours) { + const occupiedHours = Array.from({ length: DAY_HOUR_COUNT }, () => false); + occurrences.forEach((occurrence) => { + const { startMinutes, endMinutes } = eventMinutesRange(occurrence); + for (let hourIndex = 0; hourIndex < DAY_HOUR_COUNT; hourIndex += 1) { + const hourStart = hourIndex * 60; + const hourEnd = hourStart + 60; + if (startMinutes < hourEnd && endMinutes > hourStart) occupiedHours[hourIndex] = true; + } + }); + + const rows = []; + let hourIndex = 0; + while (hourIndex < DAY_HOUR_COUNT) { + if (!hideEmptyHours || occupiedHours[hourIndex]) { + rows.push({ type: "hour", hourIndex }); + hourIndex += 1; + continue; + } + + const runStart = hourIndex; + while (hourIndex < DAY_HOUR_COUNT && !occupiedHours[hourIndex]) hourIndex += 1; + const runEnd = hourIndex - 1; + if (runEnd - runStart + 1 >= 3) { + rows.push({ type: "hour", hourIndex: runStart }); + rows.push({ type: "hidden", startHour: runStart + 1, endHour: runEnd - 1 }); + rows.push({ type: "hour", hourIndex: runEnd }); + } else { + for (let visibleHour = runStart; visibleHour <= runEnd; visibleHour += 1) rows.push({ type: "hour", hourIndex: visibleHour }); + } + } + + const hourTopPx = {}; + const hiddenRanges = []; + let topPx = 0; + rows.forEach((row) => { + row.topPx = topPx; + if (row.type === "hidden") { + hiddenRanges.push(row); + topPx += WEEK_HIDDEN_ROW_HEIGHT; + return; + } + hourTopPx[row.hourIndex] = topPx; + topPx += WEEK_HOUR_ROW_HEIGHT; + }); + + return { + rows, + hourTopPx, + hiddenRanges, + totalHeightPx: topPx, + gridTemplateRows: rows.map((row) => `${row.type === "hidden" ? WEEK_HIDDEN_ROW_HEIGHT : WEEK_HOUR_ROW_HEIGHT}px`).join(" ") + }; +} + +function getWeekMinutePosition(layout, minutes) { + const boundedMinutes = Math.max(0, Math.min(24 * 60, minutes)); + const hourIndex = Math.min(DAY_HOUR_COUNT - 1, Math.floor(boundedMinutes / 60)); + const visibleHourTop = layout.hourTopPx[hourIndex]; + if (visibleHourTop !== undefined) { + return visibleHourTop + ((boundedMinutes - hourIndex * 60) / 60) * WEEK_HOUR_ROW_HEIGHT; + } + + const hiddenRange = layout.hiddenRanges.find((range) => hourIndex >= range.startHour && hourIndex <= range.endHour); + if (hiddenRange) { + const rangeStartMinutes = hiddenRange.startHour * 60; + const rangeMinutes = (hiddenRange.endHour - hiddenRange.startHour + 1) * 60; + return hiddenRange.topPx + ((boundedMinutes - rangeStartMinutes) / rangeMinutes) * WEEK_HIDDEN_ROW_HEIGHT; + } + + return layout.totalHeightPx; +} + +function getMonthOccurrenceSegments(days, occurrences) { + const laneUsage = days.map(() => [false, false, false]); + const segments = occurrences.flatMap((occurrence) => { + const coveredIndexes = days + .map((day, index) => { + const dayStart = day.date.getTime(); + const dayEnd = addDays(day.date, 1).getTime(); + const occurrenceEnd = Math.max(occurrence.endMs, occurrence.startMs + WEEK_SLOT_MINUTES * 60000); + return day.inMonth && occurrence.startMs < dayEnd && occurrenceEnd > dayStart ? index : -1; + }) + .filter((index) => index >= 0); + if (!coveredIndexes.length) return []; + + const lane = [0, 1, 2].find((candidate) => coveredIndexes.every((index) => !laneUsage[index][candidate])) ?? 2; + coveredIndexes.forEach((index) => { + laneUsage[index][lane] = true; + }); + + const segments = []; + let segmentStart = coveredIndexes[0]; + let previous = coveredIndexes[0]; + coveredIndexes.slice(1).forEach((index) => { + const sameWeekRow = Math.floor(index / WEEKDAY_LABELS.length) === Math.floor(previous / WEEKDAY_LABELS.length); + if (index === previous + 1 && sameWeekRow) { + previous = index; + return; + } + segments.push({ occurrence, lane, startIndex: segmentStart, endIndex: previous }); + segmentStart = index; + previous = index; + }); + segments.push({ occurrence, lane, startIndex: segmentStart, endIndex: previous }); + return segments; + }); + return segments.map((segment) => { + const laneCount = Math.max(1, ...segments + .filter((item) => item.startIndex <= segment.endIndex && item.endIndex >= segment.startIndex) + .map((item) => item.lane + 1)); + return { ...segment, laneCount }; + }); +} + +export function CalendarModule({ toolboxId, moduleId, context, editing = false, setEditing }) { + 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 = CALENDAR_MONTHLY_REPEAT_BY.includes(draft.monthlyRepeatBy) ? draft.monthlyRepeatBy : "monthDay"; + 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]); + + if (!data.initialized) { + return ( +
+
+
+ + +
+
+
+ ); + } + + const selectedAlertOffsets = draft.alertMode === "off" ? [] : normalizeDraftAlertOffsets(draft.alertOffsetMinutes); + + return ( +
+ {formOpen &&
+
+ + updateDraft({ color })} /> + {draft.id &&
+ {data.viewMode === "month" && draft.repeat !== "none" && activeOccurrenceDate && ( + + )} + +
} +
+
+ {data.viewMode === "month" && ( +
+ {textContent.monthlyRepeatLabel || "Règle mensuelle"} + updateDraft({ + monthlyRepeatBy, + repeat: monthlyRepeatBy === "monthDay" && draft.repeat === "weekly" ? "none" : draft.repeat + })} + /> +
+ )} +
+ {data.viewMode === "month" ? textContent.startDayLabel || "Date début" : textContent.dayLabel || "Jour"} + {data.viewMode === "month" ? ( + updateDraft({ startDate: value, endDate: (draft.endDate && draft.endDate >= value) ? draft.endDate : value })} + /> + ) : ( + + )} +
+ {data.viewMode === "month" && ( +
+ {textContent.endDayLabel || "Date fin"} + updateDraft({ endDate: value >= draft.startDate ? value : draft.startDate })} + /> +
+ )} + {data.viewMode === "week" && ( + <> + + + + )} + {data.viewMode === "month" ? ( +
+ {textContent.repeatLabel || "Répétition"} + updateDraft({ repeat })} /> +
+ ) :
+ {textContent.repeatableTitle || "Répétable"} +