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: "
"&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 (
+
+
+
+
+ {textContent.init?.weekly || "Hebdomadaire"}
+ {textContent.init?.weeklyDescription || "Grille jours par créneaux de 30 minutes."}
+
+
+ {textContent.init?.monthly || "Mensuel"}
+ {textContent.init?.monthlyDescription || "Grille du mois courant."}
+
+
+
+
+ );
+ }
+
+ const selectedAlertOffsets = draft.alertMode === "off" ? [] : normalizeDraftAlertOffsets(draft.alertOffsetMinutes);
+
+ return (
+
+ {formOpen &&
}
+ {editing && (
+
+ save(nextData)} />
+
+ )}
+
+ {data.viewMode === "week" && (
+
+ {textContent.hideEmptyHoursLabel || "Masquer les heures vides"}
+ setHideEmptyHours((current) => !current)}
+ aria-pressed={hideEmptyHours}
+ title={textContent.hideEmptyHoursLabel || "Masquer les heures vides"}
+ >
+
+
+
+
+ )}
+
+ {data.viewMode === "month" ? (
+ <>
+
+ setDisplayedMonth(getMonthStart(addMonths(monthStart, -1)))} title={textContent.previousMonthTitle || "Mois précédent"} aria-label={textContent.previousMonthTitle || "Mois précédent"}>{textContent.previousShortLabel || "Prec."}
+ {formatMonthTitle(monthStart)}
+ setDisplayedMonth(getMonthStart(new Date()))}>{textContent.currentMonthTitle || "Mois courant"}
+ setDisplayedMonth(getMonthStart(addMonths(monthStart, 1)))} title={textContent.nextMonthTitle || "Mois suivant"} aria-label={textContent.nextMonthTitle || "Mois suivant"}>{textContent.nextShortLabel || "Suiv."}
+
+
setMonthSelection(null)}
+ onEditEvent={editEvent}
+ />
+ >
+ ) :
+
+ {days.map((day) => {formatDayLabel(day)}
)}
+
+ {weekGridLayout.rows.map((row) => (
+ row.type === "hidden"
+ ? ...
+ : {formatHourLabel(row.hourIndex)}
+ ))}
+
+ {days.map((day, dayIndex) => {
+ const dayOccurrences = layoutConcurrentOccurrences(occurrences.filter((occurrence) => occurrence.event.dayIndex === dayIndex));
+ const isToday = dayIndex === nowDayIndex;
+ return (
+
+ {weekGridLayout.rows.map((row) => (
+ row.type === "hidden" ? (
+
+ {dayIndex === 3 && {`${formatHourLabel(row.startHour)} - ${formatHourLabel(row.endHour + 1)}`} }
+
+ ) : (
+
+ {Array.from({ length: HOUR_HALF_SLOT_COUNT }, (_, halfIndex) => {
+ const slotIndex = row.hourIndex * HOUR_HALF_SLOT_COUNT + halfIndex;
+ const slotLabel = minutesToTimeValue(slotIndex * WEEK_SLOT_MINUTES);
+ return (
+ handleSlotPointerDown(dayIndex, slotIndex)}
+ onPointerEnter={(event) => handleSlotPointerEnter(event, dayIndex, slotIndex)}
+ onPointerUp={() => setSelection(null)}
+ aria-label={`${formatDayLabel(day)} ${slotLabel}`}
+ />
+ );
+ })}
+
+ )
+ ))}
+
+ {dayOccurrences.map((occurrence) => (
+ editEvent(occurrence.event)}
+ />
+ ))}
+
+ {isToday && (
+
+ {minutesToTimeValue(nowMinutes)}
+
+ )}
+
+ );
+ })}
+ }
+
+ );
+}
+
+function MonthDateDropdown({ value, displayedMonth, minDate = "", mode, textContent, label, align = "start", onChange, onMonthChange }) {
+ const safeValue = normalizeDraftDateKey(value) || formatLocalDateKey(displayedMonth);
+ return (
+
+
+
+ {formatMonthDateLabel(safeValue, mode)}
+
+
+
+
+ );
+}
+
+function MonthDatePicker({ value, displayedMonth, minDate, mode, textContent, onChange, onMonthChange }) {
+ const days = getMonthGridDays(displayedMonth);
+ const weekdayRows = getWeekdayPickerRows(displayedMonth);
+ const minDateKey = normalizeDraftDateKey(minDate);
+ return (
+
+ {mode === "weekday" ? (
+ <>
+ {weekdayRows.flatMap((row) => [
+
{`S${pad2(row.ordinal)}`} ,
+ ...row.days.map((day, dayIndex) => {
+ const dateKey = day ? formatLocalDateKey(day.date) : "";
+ const disabled = !day || (minDateKey && dateKey < minDateKey);
+ return (
+
onChange(dateKey)}
+ aria-pressed={value === dateKey}
+ >
+ {WEEKDAY_LABELS[dayIndex]}
+
+ );
+ })
+ ])}
+ >
+ ) : (
+ <>
+
+ onMonthChange(getMonthStart(addMonths(displayedMonth, -1)))} aria-label={textContent.previousMonthTitle || "Mois précédent"} title={textContent.previousMonthTitle || "Mois précédent"}>{textContent.previousShortLabel || "Prec."}
+ {formatMonthTitle(displayedMonth)}
+ onMonthChange(getMonthStart(addMonths(displayedMonth, 1)))} aria-label={textContent.nextMonthTitle || "Mois suivant"} title={textContent.nextMonthTitle || "Mois suivant"}>{textContent.nextShortLabel || "Suiv."}
+
+ {WEEKDAY_LABELS.map((day) =>
{day.slice(0, 3)} )}
+ {days.map((day) => {
+ const dateKey = formatLocalDateKey(day.date);
+ const disabled = !day.inMonth || (minDateKey && dateKey < minDateKey);
+ return (
+
onChange(dateKey)}
+ aria-pressed={value === dateKey}
+ >
+ {day.dayNumber}
+
+ );
+ })}
+ >
+ )}
+
+ );
+}
+
+function MonthlyRepeatSwitch({ value, textContent, onChange }) {
+ const options = [
+ { value: "monthDay", icon: "day-number", label: textContent.monthlyRepeatMonthDay || "Numéro du jour" },
+ { value: "weekday", icon: "day-name", label: textContent.monthlyRepeatWeekday || "Jour de semaine" }
+ ];
+
+ return (
+
+ {options.map((option) => (
+ onChange(option.value)}
+ aria-pressed={value === option.value}
+ aria-label={option.label}
+ title={option.label}
+ >
+
+
+ ))}
+
+ );
+}
+
+function MonthEventRepeatSwitch({ value, monthlyRepeatBy, textContent, onChange }) {
+ const options = [
+ { value: "none", icon: "close", label: textContent.repeatNone || "Aucune" },
+ { value: "weekly", icon: "calendar-week", label: textContent.repeatWeekly || "Chaque semaine", disabled: monthlyRepeatBy === "monthDay" },
+ { value: "monthly", icon: "calendar-month", label: textContent.repeatMonthly || "Chaque mois" }
+ ];
+ const safeValue = monthlyRepeatBy === "monthDay" && value === "weekly" ? "none" : value;
+
+ return (
+
+ {options.map((option) => (
+ onChange(option.value)}
+ aria-pressed={safeValue === option.value}
+ aria-label={option.label}
+ title={option.label}
+ >
+
+
+ ))}
+
+ );
+}
+
+function CalendarAlertOffsets({ selectedOffsets, textContent, onToggle }) {
+ const labels = {
+ 10: textContent.alertOffset10 || "10 min avant",
+ 5: textContent.alertOffset5 || "5 min avant",
+ 0: textContent.alertOffset0 || "Au début"
+ };
+
+ return (
+
+ {textContent.alertOffsetLabel || "Alerte"}
+ {CALENDAR_ALERT_OFFSETS.map((offset) => (
+
+ onToggle(offset)} />
+ {labels[offset]}
+
+ ))}
+
+ );
+}
+
+function CalendarColorPicker({ value, textContent, onChange }) {
+ const label = textContent.colorLabel || "Couleur";
+ return (
+
+
{label}
+
+
+ {CALENDAR_COLORS.map((color) => (
+ onChange(color)}
+ />
+ ))}
+
+
+ );
+}
+
+function CalendarOccurrence({ occurrence, layout, active, onEdit }) {
+ const durationMinutes = Math.max(WEEK_SLOT_MINUTES, (occurrence.endMs - occurrence.startMs) / 60000 || WEEK_SLOT_MINUTES);
+ const startMinutes = occurrence.event.startMinutes;
+ const timeLabel = `${minutesToTimeValue(occurrence.event.startMinutes)}${occurrence.event.endMinutes ? ` - ${minutesToTimeValue(occurrence.event.endMinutes)}` : ""}`;
+ const compact = durationMinutes < 120;
+ const top = getWeekMinutePosition(layout, startMinutes);
+ const bottom = getWeekMinutePosition(layout, Math.min(24 * 60, startMinutes + durationMinutes));
+ const height = compact ? WEEK_COMPACT_EVENT_HEIGHT : Math.max(WEEK_COMPACT_EVENT_HEIGHT, bottom - top);
+
+ return (
+
+ {occurrence.event.title} {timeLabel} >}
+ onClick={onEdit}
+ >
+ {occurrence.event.title}
+ {!compact && {timeLabel} }
+
+
+ );
+}
+
+function CalendarMonthGrid({ days, occurrences, textContent, activeEventId, selectedRange, dragSelection, todayDate, nowLabel, onPointerDown, onPointerEnter, onPointerUp, onEditEvent }) {
+ const monthBars = getMonthOccurrenceSegments(days, occurrences);
+ const weekRowCount = Math.ceil(days.length / WEEKDAY_LABELS.length);
+ return (
+
+ {WEEKDAY_LABELS.map((day) => {day}
)}
+ {days.map((day) => {
+ const dateKey = formatLocalDateKey(day.date);
+ const isToday = todayDate === dateKey;
+ return (
+ day.inMonth && onPointerDown(dateKey)}
+ onPointerEnter={(event) => day.inMonth && onPointerEnter(event, dateKey)}
+ onPointerUp={() => day.inMonth && onPointerUp()}
+ onKeyDown={(event) => {
+ if (!day.inMonth || (event.key !== "Enter" && event.key !== " ")) return;
+ event.preventDefault();
+ onPointerDown(dateKey);
+ onPointerUp();
+ }}
+ aria-label={day.inMonth ? `${textContent.dayLabel || "Jour"} ${day.dayNumber}` : undefined}
+ >
+ {day.inMonth && (
+ <>
+ {day.dayNumber}
+ {isToday && {nowLabel} }
+ >
+ )}
+
+ );
+ })}
+
+ {monthBars.map((segment, index) => (
+ {
+ event.stopPropagation();
+ onEditEvent(segment.occurrence.event, segment.occurrence);
+ }}
+ />
+ ))}
+
+
+ );
+}
+
+function CalendarMonthBar({ segment, active, onEdit }) {
+ const gridRow = Math.floor(segment.startIndex / WEEKDAY_LABELS.length) + 2;
+ const startColumn = segment.startIndex % WEEKDAY_LABELS.length + 1;
+ const endColumn = segment.endIndex % WEEKDAY_LABELS.length + 2;
+ const laneCount = Math.max(1, Math.min(3, segment.laneCount || 1));
+ const barHeight = laneCount === 1 ? 28 : laneCount === 2 ? 23 : 18;
+ const barGap = laneCount === 3 ? 3 : 4;
+ const startLabel = new Date(segment.occurrence.startMs).toLocaleDateString(undefined, { day: "2-digit", month: "short", year: "numeric" });
+ const endDate = addDays(new Date(segment.occurrence.endMs), -1);
+ const endLabel = endDate.toLocaleDateString(undefined, { day: "2-digit", month: "short", year: "numeric" });
+ const dateLabel = startLabel === endLabel ? startLabel : `${startLabel} - ${endLabel}`;
+
+ return (
+
+ {segment.occurrence.event.title} {dateLabel} >}
+ onPointerDown={(event) => event.stopPropagation()}
+ onClick={onEdit}
+ >
+ {segment.occurrence.event.title}
+
+
+ );
+}
+
+function CalendarAlertModeSwitch({ mode, textContent, onChange }) {
+ const labels = {
+ off: textContent.alertOffTitle || "Pas d'alerte",
+ visible: textContent.alertVisibleTitle || "Alerte quand la toolbox est affichée",
+ site: textContent.alertSiteTitle || "Alerte sur tout le site"
+ };
+
+ return (
+
+ {ALERT_MODES.map((option) => (
+ onChange(option.mode)}
+ aria-pressed={mode === option.mode}
+ aria-label={labels[option.mode]}
+ title={labels[option.mode]}
+ >
+
+
+ ))}
+
+ );
+}
diff --git a/website/src/features/toolboxes/modules/CombosModule.jsx b/website/src/features/toolboxes/modules/CombosModule.jsx
index 89d7498..7de0c92 100644
--- a/website/src/features/toolboxes/modules/CombosModule.jsx
+++ b/website/src/features/toolboxes/modules/CombosModule.jsx
@@ -442,9 +442,6 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) {
{(editing || editingComboId) && (
- {editing && !editingComboId && (
-
save(nextData)} />
- )}
{textContent.deviceLabel || "Périphérique"}
@@ -592,6 +589,9 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) {
+ {editing && !editingComboId && (
+
save(nextData)} />
+ )}
)}
diff --git a/website/src/features/toolboxes/modules/calendarUtils.js b/website/src/features/toolboxes/modules/calendarUtils.js
new file mode 100644
index 0000000..bc8bb0e
--- /dev/null
+++ b/website/src/features/toolboxes/modules/calendarUtils.js
@@ -0,0 +1,266 @@
+// Rôle : regroupe les calculs hebdomadaires/mensuels, occurrences et alertes du calendrier.
+export const CALENDAR_VIEW_MODES = ["week", "month"];
+export const CALENDAR_MONTHLY_REPEAT_BY = ["monthDay", "weekday"];
+export const CALENDAR_REPEAT_MODES = ["none", "daily", "weekly", "monthly"];
+export const CALENDAR_MONTH_REPEAT_MODES = ["none", "weekly", "monthly"];
+export const CALENDAR_ALERT_MODES = ["off", "visible", "site"];
+export const CALENDAR_ALERT_OFFSETS = [10, 5, 0];
+export const CALENDAR_COLORS = ["violet", "gold", "cyan", "pink", "green", "gray"];
+export const WEEK_SLOT_MINUTES = 30;
+export const WEEK_TIME_INPUT_STEP_MINUTES = 5;
+export const WEEK_SLOT_COUNT = 48;
+export const DAY_MS = 24 * 60 * 60 * 1000;
+export const MINUTE_MS = 60 * 1000;
+export const WEEK_DAY_COUNT = 7;
+
+export function pad2(value) {
+ return String(value).padStart(2, "0");
+}
+
+export function formatLocalDateKey(dateLike) {
+ const date = new Date(dateLike);
+ if (Number.isNaN(date.getTime())) return "";
+ return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
+}
+
+export function parseLocalDateKey(value) {
+ const match = String(value || "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
+ if (!match) return null;
+ const year = Number(match[1]);
+ const month = Number(match[2]) - 1;
+ const day = Number(match[3]);
+ const date = new Date(year, month, day);
+ if (date.getFullYear() !== year || date.getMonth() !== month || date.getDate() !== day) return null;
+ return date;
+}
+
+export function minutesToTimeValue(minutes) {
+ const normalized = Math.max(0, Math.min(23 * 60 + 59, Number(minutes) || 0));
+ return `${pad2(Math.floor(normalized / 60))}:${pad2(normalized % 60)}`;
+}
+
+export function timeValueToMinutes(value) {
+ const match = String(value || "").match(/^(\d{1,2}):(\d{2})$/);
+ if (!match) return -1;
+ const hours = Number(match[1]);
+ const minutes = Number(match[2]);
+ if (!Number.isInteger(hours) || !Number.isInteger(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return -1;
+ return hours * 60 + minutes;
+}
+
+export function startOfLocalDay(dateLike) {
+ const date = new Date(dateLike);
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate());
+}
+
+export function addDays(dateLike, days) {
+ const date = new Date(dateLike);
+ date.setDate(date.getDate() + days);
+ return date;
+}
+
+export function addMonths(dateLike, months) {
+ const date = new Date(dateLike);
+ date.setMonth(date.getMonth() + months);
+ return date;
+}
+
+export function getLocalDayIndex(dateLike) {
+ return (new Date(dateLike).getDay() + 6) % WEEK_DAY_COUNT;
+}
+
+export function getWeekStart(dateLike, weekStartsOn = 1) {
+ const day = startOfLocalDay(dateLike);
+ const current = day.getDay();
+ const offset = (current - weekStartsOn + 7) % 7;
+ return addDays(day, -offset);
+}
+
+export function getWeekDays(weekStart) {
+ return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index));
+}
+
+export function getMonthStart(dateLike) {
+ const date = new Date(dateLike);
+ return new Date(date.getFullYear(), date.getMonth(), 1);
+}
+
+export function getMonthEnd(dateLike) {
+ const date = getMonthStart(dateLike);
+ date.setMonth(date.getMonth() + 1);
+ return date;
+}
+
+export function getDaysInMonth(dateLike) {
+ return new Date(new Date(dateLike).getFullYear(), new Date(dateLike).getMonth() + 1, 0).getDate();
+}
+
+export function getMonthGridDays(dateLike) {
+ const monthStart = getMonthStart(dateLike);
+ const monthEnd = getMonthEnd(dateLike);
+ const gridStart = getWeekStart(monthStart, 1);
+ const gridEnd = addDays(getWeekStart(addDays(monthEnd, -1), 1), 7);
+ const days = [];
+ for (let cursor = new Date(gridStart); cursor < gridEnd; cursor = addDays(cursor, 1)) {
+ const date = new Date(cursor);
+ days.push({
+ date,
+ dayNumber: date.getDate(),
+ dayIndex: getLocalDayIndex(date),
+ inMonth: date.getMonth() === monthStart.getMonth() && date.getFullYear() === monthStart.getFullYear()
+ });
+ }
+ return days;
+}
+
+export function getDateForWeekDay(weekStart, dayIndex, minutes = 0) {
+ const date = addDays(weekStart, Math.max(0, Math.min(WEEK_DAY_COUNT - 1, Number(dayIndex) || 0)));
+ date.setHours(0, Math.max(0, Number(minutes) || 0), 0, 0);
+ return date;
+}
+
+export function getDateForMonthDay(monthDate, monthDay, minutes = 0) {
+ const day = Number(monthDay);
+ const monthStart = getMonthStart(monthDate);
+ if (!Number.isInteger(day) || day < 1 || day > getDaysInMonth(monthStart)) return null;
+ const date = new Date(monthStart.getFullYear(), monthStart.getMonth(), day);
+ date.setHours(0, Math.max(0, Number(minutes) || 0), 0, 0);
+ return date;
+}
+
+function getWeekdayOrdinalInMonth(dateLike) {
+ const date = new Date(dateLike);
+ return Math.floor((date.getDate() - 1) / 7) + 1;
+}
+
+function getMonthlyWeekdayDate(monthDate, sourceDate, minutes = 0) {
+ const source = new Date(sourceDate);
+ const ordinal = getWeekdayOrdinalInMonth(source);
+ const dayIndex = getLocalDayIndex(source);
+ const monthStart = getMonthStart(monthDate);
+ const firstDayOffset = (dayIndex - getLocalDayIndex(monthStart) + WEEK_DAY_COUNT) % WEEK_DAY_COUNT;
+ const date = addDays(monthStart, firstDayOffset + (ordinal - 1) * WEEK_DAY_COUNT);
+ if (date.getMonth() !== monthStart.getMonth()) return null;
+ date.setHours(0, Math.max(0, Number(minutes) || 0), 0, 0);
+ return date;
+}
+
+function getMonthlyDateRange(event, monthDate) {
+ const sourceStart = parseLocalDateKey(event.startDate);
+ const sourceEnd = parseLocalDateKey(event.endDate || event.startDate);
+ if (!sourceStart || !sourceEnd || sourceEnd < sourceStart) return null;
+ const spanDays = Math.max(0, Math.round((startOfLocalDay(sourceEnd).getTime() - startOfLocalDay(sourceStart).getTime()) / DAY_MS));
+ const start = event.monthlyRepeatBy === "weekday"
+ ? getMonthlyWeekdayDate(monthDate, sourceStart, event.startMinutes)
+ : getDateForMonthDay(monthDate, sourceStart.getDate(), event.startMinutes);
+ if (!start) return null;
+ const end = addDays(start, spanDays + 1);
+ return { start, end };
+}
+
+function getDatedEventRange(event) {
+ const start = parseLocalDateKey(event.startDate);
+ const endSource = parseLocalDateKey(event.endDate || event.startDate);
+ if (!start || !endSource || endSource < start) return null;
+ start.setHours(0, Math.max(0, Number(event.startMinutes) || 0), 0, 0);
+ const end = addDays(endSource, 1);
+ end.setHours(0, 0, 0, 0);
+ return { start, end };
+}
+
+export function getEventDurationMs(event) {
+ const startMinutes = Number(event.startMinutes);
+ const endMinutes = Number(event.endMinutes);
+ return Number.isFinite(startMinutes) && Number.isFinite(endMinutes) && endMinutes > startMinutes ? (endMinutes - startMinutes) * MINUTE_MS : 0;
+}
+
+export function getEventEndMs(event, startMs) {
+ const durationMs = getEventDurationMs(event);
+ return durationMs > 0 ? startMs + durationMs : startMs;
+}
+
+export function getCalendarAlertOffsets(event) {
+ const rawOffsets = Array.isArray(event?.alertOffsetMinutes) ? event.alertOffsetMinutes : [event?.alertOffsetMinutes];
+ const offsets = [...new Set(rawOffsets.map(Number).filter((offset) => CALENDAR_ALERT_OFFSETS.includes(offset)))];
+ return offsets.length ? offsets.sort((a, b) => b - a) : [10];
+}
+
+function getWeeklyOccurrenceStart(event, weekStart, offset) {
+ if (event.repeat === "none") return getDateForWeekDay(weekStart, event.dayIndex, event.startMinutes);
+ return getDateForWeekDay(addDays(weekStart, offset * 7), event.dayIndex, event.startMinutes);
+}
+
+export function getCalendarOccurrences(events, rangeStart, rangeEnd) {
+ const baseWeekStart = getWeekStart(rangeStart);
+ const startMs = rangeStart.getTime();
+ const endMs = rangeEnd.getTime();
+ return events.flatMap((event) => {
+ const durationMs = getEventDurationMs(event);
+ const candidates = [];
+
+ if (event.startDate) {
+ const baseRange = getDatedEventRange(event);
+ if (baseRange) {
+ const spanDays = Math.max(0, Math.round((baseRange.end.getTime() - baseRange.start.getTime()) / DAY_MS) - 1);
+ if (event.repeat === "monthly") {
+ for (let cursor = getMonthStart(rangeStart); cursor < rangeEnd; cursor = addMonths(cursor, 1)) {
+ const range = getMonthlyDateRange(event, cursor);
+ if (range) candidates.push(range);
+ }
+ } else if (event.repeat === "weekly") {
+ const firstOffset = Math.floor((getWeekStart(rangeStart).getTime() - getWeekStart(baseRange.start).getTime()) / (7 * DAY_MS)) - 1;
+ const weekCount = Math.ceil((rangeEnd.getTime() - rangeStart.getTime()) / (7 * DAY_MS)) + 3;
+ for (let index = Math.max(-1, firstOffset); index < firstOffset + weekCount; index += 1) {
+ const start = addDays(baseRange.start, index * 7);
+ const end = addDays(start, spanDays + 1);
+ candidates.push({ start, end });
+ }
+ } else {
+ candidates.push(baseRange);
+ }
+ }
+ } else if (event.repeat === "none") {
+ candidates.push(getWeeklyOccurrenceStart(event, baseWeekStart, 0));
+ } else {
+ candidates.push(getWeeklyOccurrenceStart(event, baseWeekStart, -1), getWeeklyOccurrenceStart(event, baseWeekStart, 0), getWeeklyOccurrenceStart(event, baseWeekStart, 1));
+ }
+
+ return candidates
+ .map((candidate) => candidate instanceof Date ? { start: candidate, end: null } : candidate)
+ .filter((candidate) => candidate?.start && !Number.isNaN(candidate.start.getTime()))
+ .filter((candidate) => !event.excludedOccurrences?.includes?.(formatLocalDateKey(candidate.start)))
+ .map((candidate) => {
+ const occurrenceStartMs = candidate.start.getTime();
+ const occurrenceEndMs = candidate.end && !Number.isNaN(candidate.end.getTime()) ? candidate.end.getTime() : 0;
+ return {
+ id: `${event.id}:${occurrenceStartMs}`,
+ event,
+ startMs: occurrenceStartMs,
+ endMs: occurrenceEndMs > occurrenceStartMs ? occurrenceEndMs : durationMs > 0 ? occurrenceStartMs + durationMs : occurrenceStartMs
+ };
+ })
+ .filter((occurrence) => {
+ const visibleEndMs = occurrence.endMs || occurrence.startMs;
+ return visibleEndMs >= startMs && occurrence.startMs < endMs;
+ });
+ }).sort((a, b) => a.startMs - b.startMs);
+}
+
+export function getNextCalendarAlertTime(event, nowMs) {
+ if (!event || event.alertMode === "off") return 0;
+ const rangeStart = addDays(new Date(nowMs), -1);
+ const rangeEnd = addDays(new Date(nowMs), 370);
+ const alert = getCalendarOccurrences([event], rangeStart, rangeEnd)
+ .flatMap((occurrence) => getCalendarAlertOffsets(event).map((offset) => occurrence.startMs - offset * MINUTE_MS))
+ .filter((alertMs) => alertMs >= nowMs)
+ .sort((a, b) => a - b)[0];
+ return alert || 0;
+}
+
+export function getCurrentCalendarOccurrence(event, nowMs) {
+ if (!event?.endMinutes && !event?.startDate) return null;
+ const rangeStart = addDays(new Date(nowMs), -1);
+ const rangeEnd = addDays(new Date(nowMs), 1);
+ return getCalendarOccurrences([event], rangeStart, rangeEnd)
+ .find((occurrence) => occurrence.startMs <= nowMs && occurrence.endMs > nowMs) || null;
+}
diff --git a/website/src/features/toolboxes/modules/index.jsx b/website/src/features/toolboxes/modules/index.jsx
index 4b926ab..f9e112d 100644
--- a/website/src/features/toolboxes/modules/index.jsx
+++ b/website/src/features/toolboxes/modules/index.jsx
@@ -8,6 +8,7 @@ import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { lockBodyScroll } from "../../../utils/bodyScrollLock.js";
import { TOOLBOX_MODULE_DEFINITIONS, TOOLBOX_MODULES } from "../toolboxModuleRegistry.js";
import { CalculatorModule } from "./CalculatorModule.jsx";
+import { CalendarModule } from "./CalendarModule.jsx";
import { ChecklistModule } from "./ChecklistModule.jsx";
import { CombosModule } from "./CombosModule.jsx";
import { CountersModule } from "./CountersModule.jsx";
@@ -28,6 +29,7 @@ const MODULE_COMPONENTS = {
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 },
timer: { ...TOOLBOX_MODULE_DEFINITIONS.timer, Component: TimerModule },
taskPlanner: { ...TOOLBOX_MODULE_DEFINITIONS.taskPlanner, Component: TaskPlannerModule },
@@ -352,7 +354,7 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
-
+
);
diff --git a/website/src/features/toolboxes/modules/textImport.js b/website/src/features/toolboxes/modules/textImport.js
index 2390d83..d183c79 100644
--- a/website/src/features/toolboxes/modules/textImport.js
+++ b/website/src/features/toolboxes/modules/textImport.js
@@ -1,4 +1,5 @@
// Rôle : parse et sérialise les formats texte/TSV d'échange des outils toolbox.
+import { CALENDAR_ALERT_MODES, CALENDAR_ALERT_OFFSETS, CALENDAR_COLORS, CALENDAR_MONTHLY_REPEAT_BY, CALENDAR_MONTH_REPEAT_MODES, minutesToTimeValue, timeValueToMinutes } from "./calendarUtils.js";
import { cellAddress, columnIndexToName } from "./tableFormulaEngine.js";
import { getCountdownTargetMs, getDailyTargetMs, getIntervalAnchorMs } from "./timerUtils.js";
@@ -27,6 +28,7 @@ const SUPPORTED_TEXT_EXCHANGE_TYPES = new Set([
"calculator",
"table",
"timer",
+ "calendar",
"taskPlanner",
"equipmentPlanner",
"images",
@@ -36,6 +38,12 @@ const COMBO_DEVICES = new Set(["playstation", "xbox", "switch", "n64", "keyboard
const COMBO_INPUT_KINDS = new Set(["button", "direction", "key", "mouse"]);
const TIMER_TYPES = new Set(["duration", "daily_time", "time_pattern", "interval"]);
const ALERT_MODES = new Set(["off", "visible", "site"]);
+const CALENDAR_ALERT_MODE_SET = new Set(CALENDAR_ALERT_MODES);
+const CALENDAR_ALERT_OFFSET_SET = new Set(CALENDAR_ALERT_OFFSETS);
+const CALENDAR_COLOR_SET = new Set(CALENDAR_COLORS);
+const CALENDAR_MONTHLY_REPEAT_BY_SET = new Set(CALENDAR_MONTHLY_REPEAT_BY);
+const CALENDAR_MONTH_REPEAT_MODE_SET = new Set(CALENDAR_MONTH_REPEAT_MODES);
+const CALENDAR_WEEKDAYS = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"];
const IMAGE_DATA_URL_PATTERN = /^data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=\s]+$/i;
export function supportsTextExchange(type) {
@@ -51,6 +59,7 @@ export function hasTextExchangeContent(type, data) {
if (type === "calculator") return Boolean(data?.entries?.length || data?.scrollResults);
if (type === "table") return Boolean(Object.keys(data?.cells || {}).length || Object.keys(data?.rowLabels || {}).length || Object.keys(data?.columnLabels || {}).length);
if (type === "timer") return Boolean(data?.stopwatch?.laps?.length || data?.countdowns?.length || data?.stopwatch?.elapsedMs);
+ if (type === "calendar") return Boolean(data?.events?.length);
if (type === "taskPlanner") return Boolean(data?.tasks?.length);
if (type === "equipmentPlanner") return Boolean(data?.equipments?.length);
if (type === "images") return Boolean(data?.images?.length);
@@ -67,6 +76,7 @@ export function exportModuleText(type, data) {
if (type === "calculator") return exportCalculatorText(data);
if (type === "table") return exportTableTsv(data);
if (type === "timer") return exportTimerText(data);
+ if (type === "calendar") return exportCalendarText(data);
if (type === "taskPlanner") return exportTaskPlannerText(data);
if (type === "equipmentPlanner") return exportEquipmentPlannerText(data);
if (type === "images") return exportImagesText(data);
@@ -87,6 +97,7 @@ export function importModuleText(type, text, context) {
else if (type === "calculator") data = importCalculatorText(value, context);
else if (type === "table") data = importTableTsv(value);
else if (type === "timer") data = importTimerText(value, context);
+ else if (type === "calendar") data = importCalendarText(value, context);
else if (type === "taskPlanner") data = importTaskPlannerText(value, context);
else if (type === "equipmentPlanner") data = importEquipmentPlannerText(value, context);
else if (type === "images") data = importImagesText(value, context);
@@ -644,6 +655,112 @@ function importTimerText(text, context) {
return context.normalizeTimerData(data);
}
+function parseCalendarDayIndex(value) {
+ const normalized = String(value || "").trim().toLowerCase();
+ const weekdayIndex = CALENDAR_WEEKDAYS.indexOf(normalized);
+ if (weekdayIndex >= 0) return weekdayIndex;
+ const parsed = parseInteger(normalized, -1);
+ return parsed >= 0 && parsed <= 6 ? parsed : -1;
+}
+
+function parseCalendarAlertOffsets(value) {
+ return String(value || "")
+ .split(",")
+ .map((item) => parseInteger(item.trim(), Number.NaN))
+ .filter((offset, index, offsets) => CALENDAR_ALERT_OFFSET_SET.has(offset) && offsets.indexOf(offset) === index)
+ .sort((a, b) => b - a);
+}
+
+function formatCalendarEventMeta(event, viewMode) {
+ const alertMode = CALENDAR_ALERT_MODE_SET.has(event.alertMode) ? event.alertMode : "off";
+ const meta = {
+ title: event.title,
+ color: event.color && event.color !== "violet" ? event.color : "",
+ repeatable: event.repeatable === false ? "false" : "",
+ alertMode: alertMode !== "off" ? alertMode : "",
+ alerts: alertMode !== "off" ? (event.alertOffsetMinutes || [10]).join(",") : ""
+ };
+ if (viewMode === "month") {
+ const monthlyRepeatBy = CALENDAR_MONTHLY_REPEAT_BY_SET.has(event.monthlyRepeatBy) ? event.monthlyRepeatBy : "monthDay";
+ if (monthlyRepeatBy === "weekday") {
+ return {
+ ...meta,
+ repeatBy: "weekday",
+ repeat: event.repeat || "none",
+ startDate: event.startDate,
+ endDate: event.endDate && event.endDate !== event.startDate ? event.endDate : ""
+ };
+ }
+ return {
+ ...meta,
+ repeatBy: "monthDay",
+ repeat: event.repeat || "none",
+ startDate: event.startDate,
+ endDate: event.endDate && event.endDate !== event.startDate ? event.endDate : ""
+ };
+ }
+ return {
+ ...meta,
+ day: CALENDAR_WEEKDAYS[event.dayIndex] || "",
+ start: minutesToTimeValue(event.startMinutes),
+ end: event.endMinutes ? minutesToTimeValue(event.endMinutes) : ""
+ };
+}
+
+function exportCalendarText(data) {
+ const viewMode = data?.viewMode === "month" ? "month" : "week";
+ const lines = [`@viewMode: ${viewMode}`];
+ if (data?.weekStartsOn === 0) lines.push("@weekStartsOn: 0");
+ (data?.events || []).forEach((event) => {
+ lines.push(`event | ${formatMeta(formatCalendarEventMeta(event, viewMode))}`);
+ });
+ return lines.join("\n");
+}
+
+function importCalendarText(text, context) {
+ const trimmed = String(text || "").trim();
+ if (trimmed.startsWith("{")) {
+ return context.normalizeCalendarData(JSON.parse(trimmed));
+ }
+
+ const data = { initialized: true, viewMode: "week", weekStartsOn: 1, events: [] };
+ splitLines(text).forEach((line, index) => {
+ const cleanLine = line.trim();
+ if (!cleanLine) return;
+ const meta = parseMetaLine(cleanLine);
+ if (meta) {
+ if (meta.key === "viewMode") data.viewMode = meta.value === "month" ? "month" : "week";
+ if (meta.key === "weekStartsOn") data.weekStartsOn = parseInteger(meta.value, 1) === 0 ? 0 : 1;
+ return;
+ }
+
+ const { title, meta: lineMeta } = parsePipeParts(cleanLine);
+ if (title !== "event") throw lineError(index, `ligne calendrier invalide: ${cleanLine}`);
+ const event = {
+ id: context.uid("calendar"),
+ title: lineMeta.title || "",
+ repeatable: lineMeta.repeatable !== "false",
+ alertMode: CALENDAR_ALERT_MODE_SET.has(lineMeta.alertMode) ? lineMeta.alertMode : "off",
+ alertOffsetMinutes: parseCalendarAlertOffsets(lineMeta.alerts),
+ color: CALENDAR_COLOR_SET.has(lineMeta.color) ? lineMeta.color : "violet"
+ };
+ if (data.viewMode === "month") {
+ const monthlyRepeatBy = CALENDAR_MONTHLY_REPEAT_BY_SET.has(lineMeta.repeatBy) ? lineMeta.repeatBy : "monthDay";
+ event.startMinutes = 0;
+ event.monthlyRepeatBy = monthlyRepeatBy;
+ event.repeat = CALENDAR_MONTH_REPEAT_MODE_SET.has(lineMeta.repeat) ? lineMeta.repeat : "none";
+ event.startDate = lineMeta.startDate || "";
+ event.endDate = lineMeta.endDate || event.startDate;
+ } else {
+ event.dayIndex = parseCalendarDayIndex(lineMeta.day);
+ event.startMinutes = timeValueToMinutes(lineMeta.start);
+ if (lineMeta.end) event.endMinutes = timeValueToMinutes(lineMeta.end);
+ }
+ data.events.push(event);
+ });
+ return context.normalizeCalendarData(data);
+}
+
function exportImagesText(data) {
return (data.images || []).flatMap((image) => [
image.label ? `# ${image.label}` : "#",
diff --git a/website/src/features/toolboxes/storage/modules/calendar.js b/website/src/features/toolboxes/storage/modules/calendar.js
new file mode 100644
index 0000000..4e3ad45
--- /dev/null
+++ b/website/src/features/toolboxes/storage/modules/calendar.js
@@ -0,0 +1,123 @@
+// Rôle : normalise et compacte les événements du calendrier hebdomadaire et mensuel.
+import { CALENDAR_ALERT_MODES, CALENDAR_ALERT_OFFSETS, CALENDAR_COLORS, CALENDAR_MONTHLY_REPEAT_BY, CALENDAR_MONTH_REPEAT_MODES, CALENDAR_VIEW_MODES, formatLocalDateKey, getLocalDayIndex, parseLocalDateKey, WEEK_DAY_COUNT } from "../../modules/calendarUtils.js";
+import { uid } from "./shared.js";
+
+const VIEW_MODES = new Set(CALENDAR_VIEW_MODES);
+const ALERT_MODES = new Set(CALENDAR_ALERT_MODES);
+const ALERT_OFFSETS = new Set(CALENDAR_ALERT_OFFSETS);
+const COLORS = new Set(CALENDAR_COLORS);
+const MONTHLY_REPEAT_BY = new Set(CALENDAR_MONTHLY_REPEAT_BY);
+const MONTH_REPEAT_MODES = new Set(CALENDAR_MONTH_REPEAT_MODES);
+
+function normalizeDayIndex(value) {
+ const parsed = Number(value);
+ return Number.isInteger(parsed) && parsed >= 0 && parsed < WEEK_DAY_COUNT ? parsed : -1;
+}
+
+function normalizeMinuteOfDay(value) {
+ const parsed = Number(value);
+ return Number.isInteger(parsed) && parsed >= 0 && parsed < 24 * 60 ? parsed : -1;
+}
+
+function normalizeDateKey(value) {
+ const date = parseLocalDateKey(value);
+ return date ? formatLocalDateKey(date) : "";
+}
+
+function normalizeExcludedOccurrences(value) {
+ if (!Array.isArray(value)) return [];
+ return [...new Set(value.map(normalizeDateKey).filter(Boolean))].sort();
+}
+
+function normalizeAlertOffsets(value) {
+ const rawOffsets = Array.isArray(value) ? value : [value];
+ const offsets = [...new Set(rawOffsets.map(Number).filter((offset) => ALERT_OFFSETS.has(offset)))];
+ return offsets.length ? offsets.sort((a, b) => b - a) : [10];
+}
+
+export function normalizeCalendarData(data) {
+ const viewMode = VIEW_MODES.has(data?.viewMode) ? data.viewMode : "week";
+ const events = (Array.isArray(data?.events) ? data.events : [])
+ .map((event) => {
+ const title = String(event?.title || "").trim();
+ const dayIndex = normalizeDayIndex(event?.dayIndex);
+ const startDate = normalizeDateKey(event?.startDate);
+ const endDate = normalizeDateKey(event?.endDate || event?.startDate);
+ const parsedStartMinutes = normalizeMinuteOfDay(event?.startMinutes);
+ const startMinutes = viewMode === "month" && parsedStartMinutes < 0 ? 0 : parsedStartMinutes;
+ const monthlyRepeatBy = MONTHLY_REPEAT_BY.has(event?.monthlyRepeatBy) ? event.monthlyRepeatBy : "monthDay";
+ const monthRepeat = MONTH_REPEAT_MODES.has(event?.repeat) ? event.repeat : event?.repeatable === false ? "none" : "monthly";
+ const hasValidDateTarget = viewMode === "month"
+ ? startDate && endDate && endDate >= startDate
+ : dayIndex >= 0;
+ if (!title || !hasValidDateTarget || startMinutes < 0) return null;
+
+ const endMinutes = normalizeMinuteOfDay(event?.endMinutes);
+ const alertMode = ALERT_MODES.has(event?.alertMode) ? event.alertMode : "off";
+ const alertOffsetMinutes = normalizeAlertOffsets(event?.alertOffsetMinutes);
+ const color = COLORS.has(event?.color) ? event.color : "violet";
+ const repeatable = viewMode === "month" ? monthRepeat !== "none" : event?.repeatable !== false;
+ const normalized = {
+ id: event?.id || uid("calendar"),
+ title,
+ startMinutes,
+ repeat: viewMode === "month" ? monthlyRepeatBy === "monthDay" && monthRepeat === "weekly" ? "none" : monthRepeat : repeatable ? "weekly" : "none",
+ repeatable,
+ alertMode,
+ alertOffsetMinutes,
+ color
+ };
+ if (viewMode === "month") {
+ normalized.monthlyRepeatBy = monthlyRepeatBy;
+ normalized.startDate = startDate;
+ normalized.endDate = endDate;
+ normalized.dayIndex = dayIndex >= 0 ? dayIndex : getLocalDayIndex(parseLocalDateKey(startDate));
+ normalized.excludedOccurrences = normalizeExcludedOccurrences(event?.excludedOccurrences);
+ } else {
+ normalized.dayIndex = dayIndex;
+ }
+ if (endMinutes > startMinutes) normalized.endMinutes = endMinutes;
+ return normalized;
+ })
+ .filter(Boolean);
+
+ return {
+ initialized: data?.initialized === true || events.length > 0,
+ viewMode,
+ weekStartsOn: Number(data?.weekStartsOn) === 0 ? 0 : 1,
+ events
+ };
+}
+
+export function compactCalendarDataForStorage(value) {
+ const normalized = normalizeCalendarData(value);
+ const compact = {};
+ if (normalized.initialized) compact.initialized = true;
+ if (normalized.viewMode !== "week") compact.viewMode = normalized.viewMode;
+ if (normalized.weekStartsOn !== 1) compact.weekStartsOn = normalized.weekStartsOn;
+ if (normalized.events.length) {
+ compact.events = normalized.events.map((event) => {
+ const compactEvent = {
+ id: event.id,
+ title: event.title
+ };
+ if (normalized.viewMode !== "month" || event.startMinutes !== 0) compactEvent.startMinutes = event.startMinutes;
+ if (normalized.viewMode === "month") {
+ compactEvent.startDate = event.startDate;
+ if (event.endDate !== event.startDate) compactEvent.endDate = event.endDate;
+ if (event.repeat !== "monthly") compactEvent.repeat = event.repeat;
+ if (event.monthlyRepeatBy !== "monthDay") compactEvent.monthlyRepeatBy = event.monthlyRepeatBy;
+ if (event.excludedOccurrences.length) compactEvent.excludedOccurrences = event.excludedOccurrences;
+ } else {
+ compactEvent.dayIndex = event.dayIndex;
+ }
+ if (normalized.viewMode !== "month" && event.endMinutes) compactEvent.endMinutes = event.endMinutes;
+ if (normalized.viewMode !== "month" && event.repeatable === false) compactEvent.repeatable = false;
+ if (event.alertMode !== "off") compactEvent.alertMode = event.alertMode;
+ if (event.alertMode !== "off" && (event.alertOffsetMinutes.length !== 1 || event.alertOffsetMinutes[0] !== 10)) compactEvent.alertOffsetMinutes = event.alertOffsetMinutes;
+ if (event.color !== "violet") compactEvent.color = event.color;
+ return compactEvent;
+ });
+ }
+ return Object.keys(compact).length ? compact : null;
+}
diff --git a/website/src/features/toolboxes/storage/modules/index.js b/website/src/features/toolboxes/storage/modules/index.js
index 0da827b..56cd870 100644
--- a/website/src/features/toolboxes/storage/modules/index.js
+++ b/website/src/features/toolboxes/storage/modules/index.js
@@ -1,5 +1,6 @@
// Rôle : expose le registre strict des compacteurs par type d'outil toolbox.
import { compactCalculatorDataForStorage } from "./calculator.js";
+import { compactCalendarDataForStorage } from "./calendar.js";
import { compactChecklistDataForStorage } from "./checklist.js";
import { compactCombosDataForStorage } from "./combos.js";
import { compactCountersDataForStorage } from "./counters.js";
@@ -20,6 +21,7 @@ export const MODULE_DATA_COMPACTORS = {
counters: compactCountersDataForStorage,
combos: compactCombosDataForStorage,
calculator: compactCalculatorDataForStorage,
+ calendar: compactCalendarDataForStorage,
table: compactTableDataForStorage,
timer: compactTimerDataForStorage,
taskPlanner: compactTaskPlannerDataForStorage,
diff --git a/website/src/features/toolboxes/storage/modules/notepad.js b/website/src/features/toolboxes/storage/modules/notepad.js
index 1c70db9..354b33a 100644
--- a/website/src/features/toolboxes/storage/modules/notepad.js
+++ b/website/src/features/toolboxes/storage/modules/notepad.js
@@ -15,13 +15,23 @@ const NOTEPAD_RGB_COLOR_MAP = {
};
function escapeHtml(value) {
- return String(value || "")
+ return decodeBasicEntitiesDeep(value)
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
+function decodeBasicEntitiesDeep(value) {
+ let current = String(value || "");
+ for (let index = 0; index < 5; index += 1) {
+ const next = decodeBasicEntities(current);
+ if (next === current) return next;
+ current = next;
+ }
+ return current;
+}
+
function decodeBasicEntities(value) {
return String(value || "")
.replace(/ /gi, " ")
diff --git a/website/src/features/toolboxes/storage/modules/shared.js b/website/src/features/toolboxes/storage/modules/shared.js
index a614f80..cdd9d07 100644
--- a/website/src/features/toolboxes/storage/modules/shared.js
+++ b/website/src/features/toolboxes/storage/modules/shared.js
@@ -9,6 +9,7 @@ export const ID_PREFIXES = {
counter: "c",
combo: "o",
calc: "r",
+ calendar: "h",
marker: "k",
timer: "z",
task: "a",
diff --git a/website/src/features/toolboxes/storage/toolboxStorage.js b/website/src/features/toolboxes/storage/toolboxStorage.js
index 8d25b2c..a80204f 100644
--- a/website/src/features/toolboxes/storage/toolboxStorage.js
+++ b/website/src/features/toolboxes/storage/toolboxStorage.js
@@ -4,6 +4,7 @@ import { MODULE_DATA_COMPACTORS } from "./modules/index.js";
import { ID_PREFIXES, uid } from "./modules/shared.js";
export { normalizeCalculatorData } from "./modules/calculator.js";
+export { normalizeCalendarData } from "./modules/calendar.js";
export { clampQty, normalizeChecklistData } from "./modules/checklist.js";
export { normalizeCombosData } from "./modules/combos.js";
export { normalizeCountersData } from "./modules/counters.js";
@@ -250,6 +251,12 @@ function remapModuleDataForExport(type, data, nextId) {
return remapped;
}
+ if (type === "calendar") {
+ const remapped = { ...compact };
+ if (compact.events) remapped.events = compact.events.map((event) => ({ ...event, id: nextId("calendar") }));
+ return remapped;
+ }
+
if (type === "taskPlanner") {
const taskIdMap = new Map();
compact.tasks?.forEach((task) => taskIdMap.set(task.id, nextId("task")));
diff --git a/website/src/features/toolboxes/toolboxModuleRegistry.js b/website/src/features/toolboxes/toolboxModuleRegistry.js
index 420c57b..19c7a47 100644
--- a/website/src/features/toolboxes/toolboxModuleRegistry.js
+++ b/website/src/features/toolboxes/toolboxModuleRegistry.js
@@ -78,6 +78,15 @@ export const TOOLBOX_MODULE_DEFINITIONS = {
editable: true,
libraryCategory: "timeRoutines"
},
+ calendar: {
+ label: "Calendrier",
+ defaultTitle: "Calendrier",
+ mode: "Agenda hebdomadaire",
+ icon: "calendar",
+ editable: true,
+ scrollable: true,
+ libraryCategory: "timeRoutines"
+ },
taskPlanner: {
label: "Planificateur de tâches",
defaultTitle: "Planificateur de tâches",
@@ -115,7 +124,7 @@ export const LIBRARY_CATEGORY_DEFINITIONS = [
{ key: "notesTracking", types: ["notepad", "checklist", "counters"] },
{ key: "references", types: ["images", "imageAnnotation", "links"] },
{ key: "calculationData", types: ["calculator", "table"] },
- { key: "timeRoutines", types: ["timer", "taskPlanner"] },
+ { key: "timeRoutines", types: ["timer", "calendar", "taskPlanner"] },
{ key: "buildsCommands", types: ["combos", "equipmentPlanner"] }
];
diff --git a/website/src/hooks/useToolboxAlerts.js b/website/src/hooks/useToolboxAlerts.js
new file mode 100644
index 0000000..440ceb5
--- /dev/null
+++ b/website/src/hooks/useToolboxAlerts.js
@@ -0,0 +1,99 @@
+// Rôle : surveille globalement les alertes internes des outils timer et calendrier.
+import { useEffect, useRef } from "react";
+import { getCalendarAlertOffsets, getCurrentCalendarOccurrence, getNextCalendarAlertTime } from "../features/toolboxes/modules/calendarUtils.js";
+import { getCountdownTargetMs } from "../features/toolboxes/modules/timerUtils.js";
+import { moduleStorageKey } from "../features/toolboxes/storage/toolboxStorage.js";
+
+function getVisibleToolboxIds(route, drawerGameId, links) {
+ const visibleIds = new Set();
+ const toolboxMatch = route.match(/^\/toolbox\/([^/]+)/);
+ if (toolboxMatch?.[1]) visibleIds.add(toolboxMatch[1]);
+ const drawerToolboxId = drawerGameId ? links[drawerGameId] : "";
+ if (drawerToolboxId) visibleIds.add(drawerToolboxId);
+ return visibleIds;
+}
+
+function isAlertAllowed(alertMode, toolboxId, visibleToolboxIds) {
+ if (alertMode === "off") return false;
+ if (alertMode === "visible") return visibleToolboxIds.has(toolboxId);
+ return alertMode === "site";
+}
+
+function formatAlertMessage(template, toolbox, label) {
+ return template
+ .replaceAll("{label}", label)
+ .replaceAll("{toolbox}", toolbox.name);
+}
+
+export function useToolboxAlerts({ toolboxes, moduleData, links, route, drawerGameId, normalizeTimerData, normalizeCalendarData, textContent, notify }) {
+ const previousTargetsRef = useRef(new Map());
+ const resumedCalendarEventsRef = useRef(new Set());
+ const lastTickRef = useRef(Date.now());
+
+ useEffect(() => {
+ const visibleToolboxIds = getVisibleToolboxIds(route, drawerGameId, links);
+ const timerAlertTemplate = textContent?.timer?.alertMessage || "Compte à rebours terminé : {label}";
+ const calendarAlertTemplate = textContent?.calendar?.alertMessage || "Événement à venir : {label}";
+ const calendarResumeTemplate = textContent?.calendar?.resumeAlertMessage || "Événement en cours : {label}";
+
+ function tick() {
+ const nowMs = Date.now();
+ const lastTickMs = lastTickRef.current;
+ const nextTargets = new Map();
+
+ toolboxes.forEach((toolbox) => {
+ toolbox.modules.forEach((module) => {
+ if (module.type === "timer") {
+ const data = normalizeTimerData(moduleData[moduleStorageKey(toolbox.id, module.id)]);
+
+ data.countdowns.forEach((countdown) => {
+ if (!isAlertAllowed(countdown.alertMode, toolbox.id, visibleToolboxIds)) return;
+
+ const key = `${toolbox.id}:${module.id}:${countdown.id}`;
+ const targetMs = getCountdownTargetMs(countdown, nowMs);
+ const previousTargetMs = previousTargetsRef.current.get(key) || targetMs;
+ nextTargets.set(key, targetMs);
+
+ if (!targetMs || previousTargetMs <= lastTickMs || previousTargetMs > nowMs) return;
+ notify(formatAlertMessage(timerAlertTemplate, toolbox, countdown.label));
+ });
+ }
+
+ if (module.type === "calendar") {
+ const data = normalizeCalendarData(moduleData[moduleStorageKey(toolbox.id, module.id)]);
+
+ data.events.forEach((event) => {
+ if (!isAlertAllowed(event.alertMode, toolbox.id, visibleToolboxIds)) return;
+
+ const key = `${toolbox.id}:${module.id}:${event.id}`;
+ getCalendarAlertOffsets(event).forEach((offset) => {
+ const alertKey = `${key}:alert:${offset}`;
+ const alertMs = getNextCalendarAlertTime({ ...event, alertOffsetMinutes: offset }, nowMs);
+ const previousAlertMs = previousTargetsRef.current.get(alertKey) || alertMs;
+ if (alertMs) nextTargets.set(alertKey, alertMs);
+
+ if (alertMs && previousAlertMs > lastTickMs && previousAlertMs <= nowMs) {
+ notify(formatAlertMessage(calendarAlertTemplate, toolbox, event.title));
+ }
+ });
+
+ const currentOccurrence = getCurrentCalendarOccurrence(event, nowMs);
+ if (!currentOccurrence) return;
+ const resumeKey = `${key}:${currentOccurrence.startMs}`;
+ if (resumedCalendarEventsRef.current.has(resumeKey)) return;
+ resumedCalendarEventsRef.current.add(resumeKey);
+ notify(formatAlertMessage(calendarResumeTemplate, toolbox, event.title));
+ });
+ }
+ });
+ });
+
+ previousTargetsRef.current = nextTargets;
+ lastTickRef.current = nowMs;
+ }
+
+ tick();
+ const intervalId = window.setInterval(tick, 500);
+ return () => window.clearInterval(intervalId);
+ }, [drawerGameId, links, moduleData, normalizeCalendarData, normalizeTimerData, notify, route, textContent, toolboxes]);
+}
diff --git a/website/src/main.jsx b/website/src/main.jsx
index f2b46f3..6809e01 100644
--- a/website/src/main.jsx
+++ b/website/src/main.jsx
@@ -9,12 +9,20 @@ import { useIndexedToolboxes } from "./features/toolboxes/storage/useIndexedTool
import { useToolboxActions } from "./features/toolboxes/useToolboxActions.js";
import { useAppData } from "./hooks/useAppData.js";
import { useGameFilters } from "./hooks/useGameFilters.js";
-import { useTimerAlerts } from "./hooks/useTimerAlerts.js";
+import { useToolboxAlerts } from "./hooks/useToolboxAlerts.js";
import { RouteContent } from "./router/RouteContent.jsx";
import { useHashRoute } from "./router/hashRouter.js";
-import { normalizeTimerData } from "./features/toolboxes/storage/toolboxStorage.js";
+import { normalizeCalendarData, normalizeTimerData } from "./features/toolboxes/storage/toolboxStorage.js";
const ToolboxDrawer = lazy(() => import("./features/toolboxes/ToolboxPages.jsx").then((module) => ({ default: module.ToolboxDrawer })));
+const DEFAULT_NOTIFICATION_DURATION_MS = 120000;
+const SHORT_NOTIFICATION_DURATION_MS = 5000;
+
+function getNotificationDuration(message, options = {}) {
+ if (Number.isFinite(options.durationMs)) return Math.max(1000, Number(options.durationMs));
+ const normalized = String(message || "").toLowerCase();
+ return /\bcopi[ée]|\bcoll[ée]|presse-papiers/.test(normalized) ? SHORT_NOTIFICATION_DURATION_MS : DEFAULT_NOTIFICATION_DURATION_MS;
+}
function App() {
const route = useHashRoute();
@@ -31,9 +39,9 @@ function App() {
const handleStorageError = useCallback((message) => setStorageError(message), []);
const store = useIndexedToolboxes(handleStorageError);
- const notify = useCallback((message) => {
+ const notify = useCallback((message, options = {}) => {
setNotifications((current) => [
- { id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, message },
+ { id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, message, durationMs: getNotificationDuration(message, options) },
...current
].slice(0, 5));
}, []);
@@ -44,7 +52,7 @@ function App() {
useEffect(() => {
function handleNotification(event) {
- if (event.detail?.message) notify(event.detail.message);
+ if (event.detail?.message) notify(event.detail.message, event.detail);
}
window.addEventListener("sokkog:notify", handleNotification);
@@ -70,14 +78,15 @@ function App() {
setImage
});
- useTimerAlerts({
+ useToolboxAlerts({
toolboxes: store.toolboxes,
moduleData: store.moduleData,
links: store.links,
route,
drawerGameId,
normalizeTimerData,
- textContent: siteContent?.toolboxes?.modules?.timer,
+ normalizeCalendarData,
+ textContent: siteContent?.toolboxes?.modules,
notify
});
diff --git a/website/src/pages/LibraryPage.jsx b/website/src/pages/LibraryPage.jsx
index 9059046..64ec36d 100644
--- a/website/src/pages/LibraryPage.jsx
+++ b/website/src/pages/LibraryPage.jsx
@@ -6,6 +6,7 @@ import {
clampQty,
hostnameFromUrl,
normalizeCalculatorData,
+ normalizeCalendarData,
normalizeChecklistData,
normalizeCombosData,
normalizeCountersData,
@@ -31,6 +32,7 @@ const MODULE_COMPONENTS = {
counters: lazy(() => import("../features/toolboxes/modules/CountersModule.jsx").then((module) => ({ default: module.CountersModule }))),
combos: lazy(() => import("../features/toolboxes/modules/CombosModule.jsx").then((module) => ({ default: module.CombosModule }))),
calculator: lazy(() => import("../features/toolboxes/modules/CalculatorModule.jsx").then((module) => ({ default: module.CalculatorModule }))),
+ calendar: lazy(() => import("../features/toolboxes/modules/CalendarModule.jsx").then((module) => ({ default: module.CalendarModule }))),
table: lazy(() => import("../features/toolboxes/modules/TableModule.jsx").then((module) => ({ default: module.TableModule }))),
timer: lazy(() => import("../features/toolboxes/modules/TimerModule.jsx").then((module) => ({ default: module.TimerModule }))),
taskPlanner: lazy(() => import("../features/toolboxes/modules/TaskPlannerModule.jsx").then((module) => ({ default: module.TaskPlannerModule }))),
@@ -94,12 +96,15 @@ function getToolDescription(siteContent, definition) {
}
function getLibraryCategories(modules, content) {
- const modulesByType = new Map((modules || []).map((module) => [module.type, module]));
+ const modulesByType = new Map();
+ (modules || []).forEach((module) => {
+ modulesByType.set(module.type, [...(modulesByType.get(module.type) || []), module]);
+ });
return LIBRARY_CATEGORY_DEFINITIONS.map((category) => ({
...category,
title: content.categories?.[category.key] || category.key,
description: content.categoryDescriptions?.[category.key] || "",
- modules: category.types.map((type) => modulesByType.get(type)).filter(Boolean)
+ modules: category.types.flatMap((type) => modulesByType.get(type) || [])
})).filter((category) => category.modules.length);
}
@@ -149,6 +154,7 @@ export function LibraryPage({ siteContent, actions }) {
normalizeCountersData,
normalizeEquipmentPlannerData,
normalizeCalculatorData,
+ normalizeCalendarData,
normalizeImageAnnotationData,
normalizeNotepadData,
normalizeTableData,
@@ -245,7 +251,11 @@ export function LibraryPage({ siteContent, actions }) {
sourceData={libraryPayload.modules?.[module.id] || {}}
description={getToolDescription(siteContent, getToolboxModuleDefinition(module.type))}
categoryTitle={activeCategory.title}
- docs={content.toolDocs?.[module.type]}
+ docs={{
+ ...(content.toolDocs?.[module.type] || {}),
+ ...(module.controls ? { controls: module.controls } : {}),
+ ...(module.importFormat ? { importFormat: module.importFormat } : {})
+ }}
featureHeading={content.featureHeading}
advancedHeading={content.advancedHeading}
advancedCopyLabel={content.copyExampleLabel}
diff --git a/website/src/styles/_home.scss b/website/src/styles/_home.scss
index 0189b44..1630e4b 100644
--- a/website/src/styles/_home.scss
+++ b/website/src/styles/_home.scss
@@ -613,12 +613,8 @@ span {
}
.library-doc-nav {
- position: sticky;
- top: 0;
display: grid;
- max-height: calc(100vh - var(--space-10));
gap: var(--space-4);
- overflow: auto;
padding: var(--space-4);
scroll-margin-top: var(--space-5);
}
diff --git a/website/src/styles/_icons.scss b/website/src/styles/_icons.scss
index 607f048..e63640e 100644
--- a/website/src/styles/_icons.scss
+++ b/website/src/styles/_icons.scss
@@ -112,6 +112,31 @@
-webkit-mask-image: url("/static/icons/clock.svg");
}
+.ui-icon-calendar {
+ mask-image: url("/static/icons/calendar.svg");
+ -webkit-mask-image: url("/static/icons/calendar.svg");
+}
+
+.ui-icon-calendar-month {
+ mask-image: url("/static/icons/calendar-month.svg");
+ -webkit-mask-image: url("/static/icons/calendar-month.svg");
+}
+
+.ui-icon-calendar-week {
+ mask-image: url("/static/icons/calendar-week.svg");
+ -webkit-mask-image: url("/static/icons/calendar-week.svg");
+}
+
+.ui-icon-day-number {
+ mask-image: url("/static/icons/day-number.svg");
+ -webkit-mask-image: url("/static/icons/day-number.svg");
+}
+
+.ui-icon-day-name {
+ mask-image: url("/static/icons/day-name.svg");
+ -webkit-mask-image: url("/static/icons/day-name.svg");
+}
+
.ui-icon-stopwatch {
mask-image: url("/static/icons/stopwatch.svg");
-webkit-mask-image: url("/static/icons/stopwatch.svg");
@@ -362,6 +387,11 @@
-webkit-mask-image: url("/static/icons/trashcan.svg");
}
+.ui-icon-trashcan-one {
+ mask-image: url("/static/icons/trashcan-one.svg");
+ -webkit-mask-image: url("/static/icons/trashcan-one.svg");
+}
+
.game-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
diff --git a/website/src/styles/_responsive.scss b/website/src/styles/_responsive.scss
index 669dd31..0e8093c 100644
--- a/website/src/styles/_responsive.scss
+++ b/website/src/styles/_responsive.scss
@@ -243,7 +243,6 @@
}
.library-doc-nav {
- position: static;
max-height: none;
}
diff --git a/website/src/styles/toolboxes/_calendar.scss b/website/src/styles/toolboxes/_calendar.scss
new file mode 100644
index 0000000..54dd5e3
--- /dev/null
+++ b/website/src/styles/toolboxes/_calendar.scss
@@ -0,0 +1,1113 @@
+// Rôle : gère l’outil calendrier hebdomadaire/mensuel, ses grilles et ses événements.
+.calendar-module {
+ display: grid;
+ gap: 14px;
+ min-width: 0;
+ max-width: 100%;
+ box-sizing: border-box;
+ padding: var(--space-4);
+}
+
+.calendar-mode-choice {
+ display: grid;
+ padding-top: 2px;
+}
+
+.calendar-mode-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.calendar-mode-card {
+ display: grid;
+ gap: 6px;
+ min-height: 92px;
+ align-content: center;
+ padding: 12px;
+ border: 1px solid rgba(246, 196, 83, 0.18);
+ border-radius: var(--radius-md);
+ background: rgba(16, 20, 38, 0.56);
+ color: var(--color-text-primary);
+ text-align: left;
+}
+
+.calendar-mode-card:hover:not(:disabled) {
+ border-color: rgba(246, 196, 83, 0.42);
+ background: rgba(139, 92, 246, 0.14);
+}
+
+.calendar-mode-card span {
+ color: var(--color-text-secondary);
+ font-size: var(--font-size-sm);
+}
+
+.calendar-mode-card.is-disabled {
+ cursor: not-allowed;
+ opacity: 0.58;
+}
+
+.calendar-event-form {
+ display: grid;
+ gap: var(--space-3);
+ width: 100%;
+ max-width: 100%;
+ box-sizing: border-box;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+}
+
+.calendar-form-row {
+ display: grid;
+ align-items: end;
+ gap: 8px 10px;
+ min-width: 0;
+ max-width: 100%;
+}
+
+.calendar-form-main-row {
+ grid-template-columns: minmax(0, 1fr) 42px auto;
+}
+
+.calendar-form-time-row {
+ grid-template-columns: minmax(112px, 136px) repeat(2, minmax(86px, 104px)) minmax(70px, auto) minmax(62px, auto);
+ justify-content: start;
+}
+
+.calendar-event-form-month .calendar-form-time-row {
+ grid-template-columns: repeat(2, minmax(96px, 116px)) repeat(2, minmax(86px, 104px)) minmax(70px, auto) minmax(66px, auto);
+}
+
+.calendar-form-month-row {
+ grid-template-columns: max-content repeat(2, minmax(198px, 226px)) minmax(136px, auto);
+ justify-content: start;
+}
+
+.calendar-event-form label,
+.calendar-day-field,
+.calendar-color-picker,
+.calendar-repeat-field,
+.calendar-month-event-repeat-field,
+.calendar-month-repeat-field,
+.calendar-alert-type-field {
+ display: grid;
+ gap: 5px;
+ min-width: 0;
+}
+
+.calendar-event-form label span,
+.calendar-day-field > span,
+.calendar-color-picker > span,
+.calendar-repeat-field > span,
+.calendar-month-event-repeat-field > span,
+.calendar-month-repeat-field > span,
+.calendar-alert-type-field > span,
+.calendar-alert-offsets legend {
+ color: var(--color-text-secondary);
+ font-size: var(--font-size-xs);
+ font-weight: 900;
+ text-transform: uppercase;
+}
+
+.calendar-event-form input:not([type="checkbox"]),
+.calendar-event-form select {
+ width: 100%;
+ min-width: 0;
+ min-height: 36px;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ background-color: rgba(15, 23, 42, 0.64);
+ color: var(--color-text-primary);
+}
+
+.calendar-event-form select {
+ appearance: none;
+ -webkit-appearance: none;
+ overflow: hidden;
+ padding-right: 34px;
+ text-overflow: ellipsis;
+ background-image:
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23c4b5fd' viewBox='-6.5 0 32 32'%3E%3Cpath d='M18.813 11.406l-7.906 9.906c-.75.906-1.906.906-2.625 0L.376 11.406c-.75-.938-.375-1.656.781-1.656h16.875c1.188 0 1.531.719.781 1.656z'/%3E%3C/svg%3E"),
+ linear-gradient(135deg, rgba(139, 92, 246, 0.04), rgba(31, 41, 78, 0.08)),
+ linear-gradient(rgba(21, 26, 48, 0.38), rgba(21, 26, 48, 0.38));
+ background-position:
+ calc(100% - 10px) 50%,
+ 0 0,
+ 0 0;
+ background-size:
+ 11px 11px,
+ auto,
+ auto;
+ background-repeat: no-repeat;
+}
+
+.calendar-event-form select:hover,
+.calendar-event-form select:focus {
+ border-color: rgba(196, 181, 253, 0.22);
+ background-image:
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23f6c453' viewBox='-6.5 0 32 32'%3E%3Cpath d='M18.813 11.406l-7.906 9.906c-.75.906-1.906.906-2.625 0L.376 11.406c-.75-.938-.375-1.656.781-1.656h16.875c1.188 0 1.531.719.781 1.656z'/%3E%3C/svg%3E"),
+ linear-gradient(135deg, rgba(246, 196, 83, 0.06), rgba(139, 92, 246, 0.08)),
+ linear-gradient(rgba(21, 26, 48, 0.58), rgba(21, 26, 48, 0.58));
+ color: var(--color-text-primary);
+}
+
+.calendar-event-form select option {
+ background: #11182f;
+ color: var(--color-text-primary);
+}
+
+.calendar-month-date-dropdown {
+ position: relative;
+ min-width: 0;
+}
+
+.calendar-month-date-trigger {
+ display: grid;
+ grid-template-columns: 18px minmax(0, 1fr) 14px;
+ align-items: center;
+ gap: 6px;
+ width: 100%;
+ min-height: 36px;
+ padding: 0 8px;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ background-color: rgba(15, 23, 42, 0.64);
+ color: var(--color-text-primary);
+ font-weight: 900;
+ font-variant-numeric: tabular-nums;
+ text-align: left;
+}
+
+.calendar-month-date-trigger span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.calendar-month-date-trigger:hover,
+.calendar-month-date-dropdown:focus-within .calendar-month-date-trigger {
+ border-color: rgba(196, 181, 253, 0.22);
+ background-color: rgba(21, 26, 48, 0.58);
+}
+
+.calendar-month-date-trigger .ui-icon {
+ width: 15px;
+ height: 15px;
+ color: var(--color-text-secondary);
+}
+
+.calendar-month-date-trigger .ui-icon-chevron-down {
+ width: 11px;
+ height: 11px;
+ justify-self: end;
+ color: var(--color-text-muted);
+}
+
+.calendar-time-field input {
+ padding-inline: 10px 4px;
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+}
+
+.calendar-month-date-picker {
+ position: absolute;
+ top: calc(100% + 6px);
+ left: 0;
+ z-index: 20;
+ display: grid;
+ grid-template-columns: repeat(7, minmax(0, 1fr));
+ gap: 3px;
+ min-width: 0;
+ padding: 4px;
+ border: 1px solid rgba(150, 165, 205, 0.16);
+ border-radius: var(--radius-sm);
+ background: rgba(8, 11, 24, 0.98);
+ box-shadow: 0 18px 38px rgba(0, 0, 0, 0.34);
+ opacity: 0;
+ pointer-events: none;
+ transform: translateY(-4px);
+ transition:
+ opacity var(--duration-fast) var(--ease-standard),
+ transform var(--duration-fast) var(--ease-standard);
+}
+
+.calendar-month-date-picker {
+ width: min(300px, calc(100vw - 48px));
+}
+
+.calendar-month-date-picker.is-weekday-mode {
+ grid-template-columns: 34px repeat(7, minmax(48px, 1fr));
+ width: min(420px, calc(100vw - 48px));
+}
+
+.calendar-month-date-dropdown:focus-within .calendar-month-date-picker {
+ opacity: 1;
+ pointer-events: auto;
+ transform: translateY(0);
+}
+
+.calendar-month-date-dropdown.is-aligned-end .calendar-month-date-picker {
+ right: 0;
+ left: auto;
+}
+
+.calendar-month-date-picker button {
+ display: grid;
+ place-items: center;
+ min-width: 0;
+ min-height: 22px;
+ padding: 0;
+ border: 1px solid transparent;
+ border-radius: var(--radius-sm);
+ background: rgba(16, 20, 38, 0.58);
+ color: var(--color-text-secondary);
+ font-size: 11px;
+ font-weight: 800;
+ font-variant-numeric: tabular-nums;
+}
+
+.calendar-month-date-picker-header {
+ grid-column: 1 / -1;
+ display: grid;
+ grid-template-columns: 56px minmax(0, 1fr) 56px;
+ align-items: center;
+ gap: 4px;
+}
+
+.calendar-month-date-picker-header strong {
+ min-width: 0;
+ color: var(--color-text-primary);
+ overflow: hidden;
+ font-size: 12px;
+ font-weight: 900;
+ text-align: center;
+ text-overflow: ellipsis;
+ text-transform: capitalize;
+ white-space: nowrap;
+}
+
+.calendar-month-date-picker-header button {
+ min-height: 24px;
+ padding-inline: 6px;
+ font-size: 10px;
+}
+
+.calendar-month-date-picker-day {
+ display: grid;
+ place-items: center;
+ min-height: 18px;
+ color: var(--color-text-muted);
+ overflow: hidden;
+ font-size: 9px;
+ font-weight: 900;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.calendar-month-date-picker-week {
+ display: grid;
+ place-items: center;
+ min-height: 24px;
+ color: var(--color-accent-gold);
+ font-size: 10px;
+ font-weight: 900;
+ font-variant-numeric: tabular-nums;
+}
+
+.calendar-month-date-picker.is-weekday-mode button {
+ min-height: 26px;
+ padding-inline: 3px;
+ overflow: hidden;
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.calendar-month-date-picker button.is-outside,
+.calendar-month-date-picker button:disabled {
+ opacity: 0.34;
+ cursor: not-allowed;
+}
+
+.calendar-month-date-picker button:hover:not(:disabled),
+.calendar-month-date-picker button.active {
+ border-color: rgba(246, 196, 83, 0.58);
+ background: rgba(139, 92, 246, 0.3);
+ color: var(--color-text-primary);
+}
+
+.calendar-alert-offsets {
+ display: grid;
+ grid-template-columns: repeat(2, max-content);
+ align-items: center;
+ gap: 5px 8px;
+ min-width: 0;
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
+
+.calendar-alert-offsets legend {
+ grid-column: 1 / -1;
+ width: 100%;
+ padding: 0;
+}
+
+.calendar-alert-offsets label {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ min-height: 24px;
+ color: var(--color-text-secondary);
+ font-size: 11px;
+ font-weight: 800;
+ white-space: nowrap;
+}
+
+.calendar-alert-offsets input {
+ flex: 0 0 auto;
+ width: 20px;
+ min-width: 20px;
+ max-width: 20px;
+ height: 20px;
+ min-height: 20px;
+ max-height: 20px;
+}
+
+.calendar-alert-mode-switch {
+ align-self: start;
+ justify-self: start;
+}
+
+.calendar-alert-type-field {
+ align-self: start;
+ justify-self: start;
+}
+
+.calendar-color-picker {
+ position: relative;
+ align-self: end;
+}
+
+.calendar-color-toggle {
+ width: 32px;
+ min-width: 32px;
+ min-height: 32px;
+}
+
+.calendar-repeat-switch {
+ align-self: end;
+ justify-self: start;
+}
+
+.calendar-repeat-switch .ui-icon {
+ width: 16px;
+ height: 16px;
+}
+
+.calendar-month-repeat-field {
+ align-self: end;
+ justify-self: start;
+}
+
+.calendar-month-repeat-switch {
+ align-self: end;
+ grid-template-columns: repeat(2, 30px);
+ width: max-content;
+ padding: 3px;
+}
+
+.calendar-month-repeat-switch button {
+ width: 30px;
+ min-width: 30px;
+ min-height: 28px;
+}
+
+.calendar-month-repeat-switch .ui-icon {
+ width: 22px;
+ height: 22px;
+}
+
+.calendar-month-event-repeat-switch {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 3px;
+ min-height: 36px;
+ padding: 3px;
+ border: 1px solid rgba(150, 165, 205, 0.14);
+ border-radius: var(--radius-pill);
+ background: rgba(11, 14, 27, 0.72);
+}
+
+.calendar-month-event-repeat-switch button {
+ display: grid;
+ place-items: center;
+ min-width: 0;
+ min-height: 28px;
+ padding: 0;
+ border: 0;
+ border-radius: var(--radius-pill);
+ background: transparent;
+ color: var(--color-text-secondary);
+ overflow: hidden;
+ font-size: 11px;
+ font-weight: 900;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.calendar-month-event-repeat-switch .ui-icon {
+ width: 19px;
+ height: 19px;
+}
+
+.calendar-month-event-repeat-switch button.active {
+ background: rgba(139, 92, 246, 0.72);
+ color: var(--color-text-primary);
+}
+
+.calendar-month-event-repeat-switch button:disabled {
+ opacity: 0.35;
+ cursor: not-allowed;
+}
+
+.calendar-form-delete-button {
+ width: 34px;
+ min-width: 34px;
+ min-height: 32px;
+ padding: 0;
+}
+
+.calendar-delete-actions {
+ display: flex;
+ align-items: end;
+ gap: 6px;
+}
+
+.calendar-color-menu {
+ grid-template-columns: repeat(3, 34px);
+}
+
+.calendar-color-picker .calendar-color-menu {
+ right: 0;
+ left: auto;
+ transform: translateY(-4px);
+}
+
+.calendar-color-picker:hover .calendar-color-menu,
+.calendar-color-picker:focus-within .calendar-color-menu {
+ transform: translateY(0);
+}
+
+.calendar-color-picker .calendar-color-menu::before,
+.calendar-color-picker .calendar-color-menu::after {
+ right: 9px;
+ left: auto;
+}
+
+.calendar-form-alert-row {
+ grid-template-columns: minmax(210px, auto) auto minmax(0, 1fr);
+ align-items: start;
+ column-gap: 20px;
+}
+
+.calendar-form-alert-row .calendar-form-actions {
+ align-self: end;
+}
+
+.calendar-exchange-panel .text-import-actions {
+ justify-content: flex-end;
+ padding-top: 12px;
+}
+
+.calendar-form-actions {
+ display: flex;
+ align-items: end;
+ justify-content: end;
+ flex-wrap: wrap;
+ gap: 8px;
+ min-width: 0;
+}
+
+.calendar-form-actions button {
+ min-height: 34px;
+ min-width: 0;
+ padding-inline: 10px;
+}
+
+.calendar-form-error {
+ margin: 0;
+}
+
+.calendar-week-options {
+ display: inline-flex;
+ align-items: center;
+ justify-self: end;
+ gap: 10px;
+ min-width: 0;
+ padding: 2px 0;
+ color: var(--color-text-muted);
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.calendar-month-toolbar {
+ display: grid;
+ grid-template-columns: 64px minmax(0, 1fr) auto 64px;
+ align-items: center;
+ gap: 8px;
+}
+
+.calendar-month-toolbar strong {
+ min-width: 0;
+ color: var(--color-text-primary);
+ overflow: hidden;
+ font-size: 15px;
+ font-weight: 900;
+ text-align: center;
+ text-overflow: ellipsis;
+ text-transform: capitalize;
+ white-space: nowrap;
+}
+
+.calendar-month-toolbar button {
+ min-height: 32px;
+ min-width: 0;
+ padding-inline: 9px;
+}
+
+.calendar-hide-hours-switch {
+ justify-self: end;
+}
+
+.calendar-hide-hours-switch .ui-icon {
+ width: 16px;
+ height: 16px;
+}
+
+.calendar-week-grid {
+ display: grid;
+ grid-template-columns: 42px repeat(7, minmax(0, 1fr));
+ width: 100%;
+ max-width: 100%;
+ box-sizing: border-box;
+ min-width: 0;
+ overflow: visible;
+ border: 1px solid rgba(150, 165, 205, 0.13);
+ border-radius: var(--radius-md);
+ background: rgba(5, 7, 17, 0.22);
+}
+
+.calendar-time-header,
+.calendar-day-header {
+ position: sticky;
+ top: 0;
+ z-index: 4;
+ min-height: 40px;
+ border-bottom: 1px solid rgba(150, 165, 205, 0.14);
+ background: rgba(10, 13, 27, 0.96);
+}
+
+.calendar-time-header {
+ left: 0;
+ z-index: 5;
+}
+
+.calendar-day-header {
+ display: grid;
+ place-items: center;
+ min-width: 0;
+ padding: 0 2px;
+ color: var(--color-text-secondary);
+ overflow: hidden;
+ font-size: 12px;
+ font-weight: 900;
+ text-overflow: ellipsis;
+ text-transform: capitalize;
+ white-space: nowrap;
+}
+
+.calendar-time-column {
+ position: sticky;
+ left: 0;
+ z-index: 3;
+ display: grid;
+ border-right: 1px solid rgba(150, 165, 205, 0.12);
+ background: rgba(10, 13, 27, 0.92);
+}
+
+.calendar-time-column span {
+ display: block;
+ min-width: 0;
+ padding: 2px 4px 0 0;
+ border-top: 1px solid rgba(150, 165, 205, 0.18);
+ color: var(--color-text-muted);
+ font-size: 11px;
+ font-weight: 800;
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+}
+
+.calendar-time-column .calendar-hidden-hours-label {
+ display: grid;
+ place-items: center;
+ min-width: 0;
+ padding: 0;
+ border-top: 1px solid rgba(246, 196, 83, 0.18);
+ border-bottom: 1px solid rgba(246, 196, 83, 0.12);
+ background:
+ repeating-linear-gradient(
+ -45deg,
+ rgba(246, 196, 83, 0.04) 0 4px,
+ rgba(246, 196, 83, 0.1) 4px 5px
+ ),
+ rgba(246, 196, 83, 0.035);
+ color: rgba(246, 196, 83, 0.78);
+ font-size: 9px;
+ font-weight: 900;
+ line-height: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.calendar-day-column {
+ position: relative;
+ display: grid;
+ min-width: 0;
+ border-right: 1px solid rgba(150, 165, 205, 0.08);
+}
+
+.calendar-day-column.is-today {
+ background: rgba(246, 196, 83, 0.025);
+}
+
+.calendar-hour-cell {
+ display: grid;
+ grid-template-rows: repeat(2, minmax(0, 1fr));
+ min-width: 0;
+ border-top: 1px solid rgba(150, 165, 205, 0.3);
+}
+
+.calendar-hidden-hours-cell {
+ display: grid;
+ place-items: center;
+ min-width: 0;
+ border-top: 1px solid rgba(246, 196, 83, 0.16);
+ border-bottom: 1px solid rgba(246, 196, 83, 0.08);
+ background:
+ repeating-linear-gradient(
+ -45deg,
+ rgba(246, 196, 83, 0.02) 0 5px,
+ rgba(246, 196, 83, 0.075) 5px 6px
+ ),
+ rgba(246, 196, 83, 0.025);
+}
+
+.calendar-hidden-hours-cell span {
+ max-width: calc(100% - 8px);
+ padding: 1px 7px;
+ border: 1px solid rgba(246, 196, 83, 0.32);
+ border-radius: var(--radius-pill);
+ background: rgba(10, 13, 27, 0.82);
+ color: rgba(246, 196, 83, 0.86);
+ overflow: hidden;
+ font-size: 9px;
+ font-weight: 900;
+ line-height: 1.1;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.calendar-slot {
+ min-width: 0;
+ min-height: 0;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ cursor: crosshair;
+}
+
+.calendar-slot + .calendar-slot {
+ border-top: 1px dotted rgba(150, 165, 205, 0.12);
+}
+
+.calendar-slot:hover,
+.calendar-slot.is-form-selected {
+ background: rgba(246, 196, 83, 0.09);
+}
+
+.calendar-slot.is-selected {
+ background: rgba(139, 92, 246, 0.18);
+}
+
+.calendar-events-layer {
+ position: absolute;
+ inset: 0 2px;
+ pointer-events: none;
+}
+
+.calendar-now-line {
+ position: absolute;
+ right: 0;
+ left: 0;
+ z-index: 3;
+ height: 0;
+ border-top: 1px solid rgba(246, 196, 83, 0.9);
+ pointer-events: none;
+}
+
+.calendar-now-line::before {
+ position: absolute;
+ top: -4px;
+ left: -4px;
+ width: 7px;
+ height: 7px;
+ border-radius: var(--radius-pill);
+ background: var(--color-accent-gold);
+ box-shadow: 0 0 12px rgba(246, 196, 83, 0.7);
+ content: "";
+}
+
+.calendar-now-line span {
+ position: absolute;
+ top: -10px;
+ right: 3px;
+ padding: 1px 4px;
+ border: 1px solid rgba(246, 196, 83, 0.48);
+ border-radius: var(--radius-sm);
+ background: rgba(11, 14, 27, 0.92);
+ color: var(--color-accent-gold);
+ font-size: 9px;
+ font-weight: 900;
+ font-variant-numeric: tabular-nums;
+}
+
+.calendar-event {
+ position: absolute;
+ left: calc((100% / var(--calendar-event-lane-count, 1)) * var(--calendar-event-lane, 0));
+ width: calc((100% / var(--calendar-event-lane-count, 1)) - 2px);
+ z-index: 2;
+ display: block;
+ min-height: 24px;
+ overflow: hidden;
+ border: 1px solid rgba(255, 255, 255, 0.22);
+ border-radius: var(--radius-sm);
+ background: rgba(139, 92, 246, 0.78);
+ box-shadow: 0 10px 24px rgba(0, 0, 0, 0.24);
+ pointer-events: auto;
+}
+
+.calendar-event.is-active {
+ outline: 2px solid rgba(246, 196, 83, 0.72);
+ outline-offset: 1px;
+}
+
+.calendar-event-button {
+ display: grid;
+ align-content: start;
+ gap: 1px;
+ min-width: 0;
+ padding: 5px 7px;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ color: white;
+ text-align: left;
+ width: 100%;
+ height: 100%;
+}
+
+.calendar-event-button:hover,
+.calendar-event-button:focus-visible,
+.calendar-month-bar-button:hover,
+.calendar-month-bar-button:focus-visible {
+ background: rgba(0, 0, 0, 0.1);
+ box-shadow: none;
+}
+
+.calendar-event-button > strong,
+.calendar-event-button > span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.calendar-event-button > strong {
+ font-size: 10px;
+ line-height: 1;
+}
+
+.calendar-event-button > span {
+ font-size: 9px;
+ opacity: 0.86;
+}
+
+.calendar-event.is-compact {
+ min-height: 0;
+}
+
+.calendar-event.is-compact .calendar-event-button {
+ place-items: center;
+ padding: 0 6px;
+}
+
+.calendar-event-tooltip {
+ display: grid;
+ gap: 2px;
+}
+
+.calendar-event-tooltip strong,
+.calendar-event-tooltip span {
+ min-width: 0;
+}
+
+.calendar-event-tooltip strong {
+ color: var(--color-text-primary);
+}
+
+.calendar-event-gold {
+ border-color: rgba(11, 14, 27, 0.28);
+ background: rgba(230, 176, 55, 0.9);
+ color: #0b0e1b;
+}
+
+.calendar-event-gold .calendar-event-button {
+ color: #0b0e1b;
+}
+
+.calendar-event-gold .calendar-event-button > span {
+ color: rgba(11, 14, 27, 0.76);
+ opacity: 1;
+}
+
+.calendar-event-cyan {
+ background: rgba(34, 211, 238, 0.72);
+}
+
+.calendar-event-pink {
+ background: rgba(217, 70, 239, 0.72);
+}
+
+.calendar-event-green {
+ background: rgba(34, 197, 94, 0.72);
+}
+
+.calendar-event-gray {
+ background: rgba(125, 135, 159, 0.78);
+}
+
+.calendar-month-grid {
+ position: relative;
+ display: grid;
+ grid-template-columns: repeat(7, minmax(0, 1fr));
+ width: 100%;
+ max-width: 100%;
+ min-width: 0;
+ overflow: visible;
+ border: 1px solid rgba(150, 165, 205, 0.13);
+ border-radius: var(--radius-md);
+ background: rgba(5, 7, 17, 0.22);
+}
+
+.calendar-month-bars-layer {
+ position: absolute;
+ inset: 0;
+ z-index: 3;
+ display: grid;
+ grid-template-columns: repeat(7, minmax(0, 1fr));
+ grid-template-rows: 34px repeat(var(--calendar-month-week-count, 5), minmax(86px, 1fr));
+ pointer-events: none;
+}
+
+.calendar-month-header {
+ display: grid;
+ place-items: center;
+ min-height: 34px;
+ min-width: 0;
+ padding: 0 4px;
+ border-bottom: 1px solid rgba(150, 165, 205, 0.14);
+ background: rgba(10, 13, 27, 0.96);
+ color: var(--color-text-secondary);
+ overflow: hidden;
+ font-size: 12px;
+ font-weight: 900;
+ text-overflow: ellipsis;
+ text-transform: capitalize;
+ white-space: nowrap;
+}
+
+.calendar-month-cell {
+ position: relative;
+ display: grid;
+ align-content: start;
+ gap: 5px;
+ min-width: 0;
+ min-height: 86px;
+ padding: 6px;
+ border-right: 1px solid rgba(150, 165, 205, 0.08);
+ border-bottom: 1px solid rgba(150, 165, 205, 0.08);
+ background: rgba(9, 12, 25, 0.34);
+ cursor: crosshair;
+}
+
+.calendar-month-cell:hover,
+.calendar-month-cell.is-form-selected {
+ background: rgba(246, 196, 83, 0.09);
+}
+
+.calendar-month-cell.is-selected {
+ background: rgba(139, 92, 246, 0.18);
+}
+
+.calendar-month-cell.is-today {
+ box-shadow: inset 0 0 0 1px rgba(246, 196, 83, 0.58);
+ background: rgba(246, 196, 83, 0.08);
+}
+
+.calendar-month-cell.is-outside {
+ cursor: default;
+ background: rgba(5, 7, 17, 0.18);
+}
+
+.calendar-month-day-number {
+ color: var(--color-text-secondary);
+ font-size: 13px;
+ font-weight: 900;
+ font-variant-numeric: tabular-nums;
+}
+
+.calendar-month-now-indicator {
+ position: absolute;
+ top: 5px;
+ right: 5px;
+ padding: 1px 5px;
+ border: 1px solid rgba(246, 196, 83, 0.48);
+ border-radius: var(--radius-pill);
+ background: rgba(11, 14, 27, 0.92);
+ color: var(--color-accent-gold);
+ font-size: 9px;
+ font-weight: 900;
+ font-variant-numeric: tabular-nums;
+}
+
+.calendar-month-bar {
+ align-self: start;
+ min-width: 0;
+ min-height: var(--calendar-month-bar-height, 28px);
+ margin: calc(28px + (var(--calendar-event-lane, 0) * (var(--calendar-month-bar-height, 28px) + var(--calendar-month-bar-gap, 4px)))) 4px 0;
+ overflow: hidden;
+ border: 1px solid rgba(255, 255, 255, 0.22);
+ border-radius: var(--radius-sm);
+ background: rgba(139, 92, 246, 0.82);
+ box-shadow: 0 10px 22px rgba(0, 0, 0, 0.22);
+ pointer-events: auto;
+}
+
+.calendar-month-bar.is-active {
+ outline: 2px solid rgba(246, 196, 83, 0.72);
+ outline-offset: 1px;
+}
+
+.calendar-month-bar-button {
+ display: grid;
+ align-items: center;
+ width: 100%;
+ min-width: 0;
+ min-height: var(--calendar-month-bar-height, 28px);
+ padding: 0 9px;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ color: white;
+ text-align: left;
+}
+
+.calendar-month-bar strong {
+ min-width: 0;
+ overflow: hidden;
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.calendar-month-bar.calendar-event-gold .calendar-month-bar-button {
+ color: #0b0e1b;
+}
+
+.calendar-month-bar.calendar-event-gold {
+ border-color: rgba(11, 14, 27, 0.28);
+ background: rgba(230, 176, 55, 0.92);
+}
+
+.calendar-month-bar.calendar-event-cyan {
+ background: rgba(34, 211, 238, 0.78);
+}
+
+.calendar-month-bar.calendar-event-pink {
+ background: rgba(217, 70, 239, 0.78);
+}
+
+.calendar-month-bar.calendar-event-green {
+ background: rgba(34, 197, 94, 0.78);
+}
+
+.calendar-month-bar.calendar-event-gray {
+ background: rgba(125, 135, 159, 0.84);
+}
+
+@media (max-width: 760px) {
+ .calendar-form-main-row,
+ .calendar-form-time-row,
+ .calendar-form-month-row,
+ .calendar-form-alert-row {
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ }
+
+ .calendar-mode-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .calendar-alert-offsets,
+ .calendar-alert-mode-switch,
+ .calendar-form-actions,
+ .calendar-repeat-field,
+ .calendar-month-event-repeat-field,
+ .calendar-month-repeat-field,
+ .calendar-form-error {
+ grid-column: 1 / -1;
+ }
+
+ .calendar-repeat-switch,
+ .calendar-month-repeat-switch,
+ .calendar-month-event-repeat-switch,
+ .calendar-alert-mode-switch,
+ .calendar-color-picker {
+ justify-self: start;
+ }
+
+ .calendar-week-grid {
+ grid-template-columns: 34px repeat(7, minmax(0, 1fr));
+ }
+
+ .calendar-day-header {
+ font-size: 9px;
+ }
+
+ .calendar-month-header {
+ font-size: 9px;
+ }
+
+ .calendar-month-cell {
+ min-height: 72px;
+ padding: 4px;
+ }
+
+ .calendar-month-bars-layer {
+ grid-template-rows: 34px repeat(var(--calendar-month-week-count, 5), minmax(72px, 1fr));
+ }
+}
diff --git a/website/src/styles/toolboxes/_core.scss b/website/src/styles/toolboxes/_core.scss
index b4738b2..83c9601 100644
--- a/website/src/styles/toolboxes/_core.scss
+++ b/website/src/styles/toolboxes/_core.scss
@@ -11,6 +11,7 @@
@use "counters";
@use "calculator";
@use "timer";
+@use "calendar";
@use "calculator-results";
@use "table";
@use "shared-controls";
diff --git a/website/src/styles/toolboxes/_module-shell.scss b/website/src/styles/toolboxes/_module-shell.scss
index 7bd1df2..e86a48a 100644
--- a/website/src/styles/toolboxes/_module-shell.scss
+++ b/website/src/styles/toolboxes/_module-shell.scss
@@ -240,6 +240,11 @@
-webkit-mask-image: url("/static/icons/clock.svg");
}
+.module-icon-calendar {
+ mask-image: url("/static/icons/calendar.svg");
+ -webkit-mask-image: url("/static/icons/calendar.svg");
+}
+
.module-icon-stopwatch {
mask-image: url("/static/icons/stopwatch.svg");
-webkit-mask-image: url("/static/icons/stopwatch.svg");
diff --git a/website/src/styles/toolboxes/_task-planner.scss b/website/src/styles/toolboxes/_task-planner.scss
index 19e8a19..9e65a9f 100644
--- a/website/src/styles/toolboxes/_task-planner.scss
+++ b/website/src/styles/toolboxes/_task-planner.scss
@@ -127,7 +127,10 @@
.task-planner-type-select,
.task-planner-reset-override select,
.task-planner-reset-override input,
-.task-planner-relations select {
+.task-planner-relations select,
+.combos-device-row select,
+.equipment-planner-socket-links select,
+.equipment-planner-socket-controls select {
width: 100%;
min-height: 36px;
border: 1px solid var(--color-border);
@@ -140,7 +143,10 @@
.task-planner-add-form select option,
.task-planner-type-select option,
.task-planner-reset-override select option,
-.task-planner-relations select option {
+.task-planner-relations select option,
+.combos-device-row select option,
+.equipment-planner-socket-links select option,
+.equipment-planner-socket-controls select option {
background: #11182f;
color: var(--color-text-primary);
}
diff --git a/website/src/styles/toolboxes/_timer.scss b/website/src/styles/toolboxes/_timer.scss
index bf83e25..65c9b9e 100644
--- a/website/src/styles/toolboxes/_timer.scss
+++ b/website/src/styles/toolboxes/_timer.scss
@@ -81,7 +81,10 @@
.task-planner-add-form select,
.task-planner-type-select,
.task-planner-reset-override select,
-.task-planner-relations select {
+.task-planner-relations select,
+.combos-device-row select,
+.equipment-planner-socket-links select,
+.equipment-planner-socket-controls select {
appearance: none;
-webkit-appearance: none;
overflow: hidden;
@@ -113,7 +116,13 @@
.task-planner-reset-override select:hover,
.task-planner-reset-override select:focus,
.task-planner-relations select:hover,
-.task-planner-relations select:focus {
+.task-planner-relations select:focus,
+.combos-device-row select:hover,
+.combos-device-row select:focus,
+.equipment-planner-socket-links select:hover,
+.equipment-planner-socket-links select:focus,
+.equipment-planner-socket-controls select:hover,
+.equipment-planner-socket-controls select:focus {
border-color: rgba(196, 181, 253, 0.22);
background-image:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23f6c453' viewBox='-6.5 0 32 32'%3E%3Cpath d='M18.813 11.406l-7.906 9.906c-.75.906-1.906.906-2.625 0L.376 11.406c-.75-.938-.375-1.656.781-1.656h16.875c1.188 0 1.531.719.781 1.656z'/%3E%3C/svg%3E"),