Split calendar module into form and views
All checks were successful
Deploy Sokko G / deploy (push) Successful in 15s

This commit is contained in:
Shinuwa 2026-08-24 20:29:57 +02:00
parent c81f66c747
commit 233f81541d
4 changed files with 774 additions and 619 deletions

View file

@ -151,6 +151,9 @@ test("toolbox module registry and modules expose expected behavior", async () =>
const timerParts = await readFile("website/src/features/toolboxes/modules/TimerParts.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 timerOptions = await readFile("website/src/features/toolboxes/modules/timerOptions.js", "utf8");
const calendarModule = await readFile("website/src/features/toolboxes/modules/CalendarModule.jsx", "utf8"); const calendarModule = await readFile("website/src/features/toolboxes/modules/CalendarModule.jsx", "utf8");
const calendarEventForm = await readFile("website/src/features/toolboxes/modules/CalendarEventForm.jsx", "utf8");
const calendarViews = await readFile("website/src/features/toolboxes/modules/CalendarViews.jsx", "utf8");
const calendarBundle = `${calendarModule}\n${calendarEventForm}\n${calendarViews}`;
const calendarUtils = await readFile("website/src/features/toolboxes/modules/calendarUtils.js", "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 taskPlannerModule = await readFile("website/src/features/toolboxes/modules/TaskPlannerModule.jsx", "utf8");
const taskPlannerItems = await readFile("website/src/features/toolboxes/modules/TaskPlannerItems.jsx", "utf8"); const taskPlannerItems = await readFile("website/src/features/toolboxes/modules/TaskPlannerItems.jsx", "utf8");
@ -321,28 +324,28 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(timerModule, /TextExchangeActions/); assert.match(timerModule, /TextExchangeActions/);
assert.match(calendarModule, /export function CalendarModule/); assert.match(calendarModule, /export function CalendarModule/);
assert.match(calendarModule, /normalizeCalendarData/); assert.match(calendarModule, /normalizeCalendarData/);
assert.match(calendarModule, /calendar-mode-choice/); assert.match(calendarBundle, /calendar-mode-choice/);
assert.match(calendarModule, /calendar-week-grid/); assert.match(calendarBundle, /calendar-week-grid/);
assert.match(calendarModule, /calendar-month-grid/); assert.match(calendarBundle, /calendar-month-grid/);
assert.match(calendarModule, /getMonthOccurrenceSegments/); assert.match(calendarBundle, /getMonthOccurrenceSegments/);
assert.match(calendarModule, /calendar-month-bar/); assert.match(calendarBundle, /calendar-month-bar/);
assert.match(calendarModule, /MonthDateDropdown/); assert.match(calendarBundle, /MonthDateDropdown/);
assert.match(calendarModule, /MonthEventRepeatSwitch/); assert.match(calendarBundle, /MonthEventRepeatSwitch/);
assert.match(calendarModule, /monthlyRepeatBy/); assert.match(calendarBundle, /monthlyRepeatBy/);
assert.match(calendarModule, /displayedMonth/); assert.match(calendarModule, /displayedMonth/);
assert.match(calendarModule, /deleteOccurrence/); assert.match(calendarModule, /deleteOccurrence/);
assert.match(calendarModule, /onPointerDown/); assert.match(calendarBundle, /onPointerDown/);
assert.match(calendarModule, /copyEvent/); assert.match(calendarModule, /copyEvent/);
assert.match(calendarModule, /calendar-repeat-switch/); assert.match(calendarBundle, /calendar-repeat-switch/);
assert.match(calendarModule, /layout-switch calendar-month-repeat-switch/); assert.match(calendarBundle, /layout-switch calendar-month-repeat-switch/);
assert.match(calendarModule, /WEEK_TIME_INPUT_STEP_MINUTES \* 60/); assert.match(calendarBundle, /WEEK_TIME_INPUT_STEP_MINUTES \* 60/);
assert.match(calendarModule, /durationMinutes < 120/); assert.match(calendarBundle, /durationMinutes < 120/);
assert.match(calendarModule, /is-compact/); assert.match(calendarBundle, /is-compact/);
assert.match(calendarModule, /handleKeyDown/); assert.match(calendarModule, /handleKeyDown/);
assert.match(calendarModule, /TextExchangeActions/); assert.match(calendarModule, /TextExchangeActions/);
assert.match(calendarModule, /type="calendar"/); assert.match(calendarModule, /type="calendar"/);
assert.match(calendarModule, /repeatLabel/); assert.match(calendarBundle, /repeatLabel/);
assert.match(calendarModule, /CalendarAlertModeSwitch/); assert.match(calendarBundle, /CalendarAlertModeSwitch/);
assert.doesNotMatch(moduleRegistry, /calendarInitOpen/); assert.doesNotMatch(moduleRegistry, /calendarInitOpen/);
assert.doesNotMatch(moduleRegistry, /calendar-init-modal/); assert.doesNotMatch(moduleRegistry, /calendar-init-modal/);
assert.match(calendarUtils, /export function getCalendarOccurrences/); assert.match(calendarUtils, /export function getCalendarOccurrences/);

View file

@ -0,0 +1,392 @@
// Rôle : rend le formulaire calendrier et ses contrôles de date, répétition, couleur et alertes.
import { Icon } from "../../../components/Icon.jsx";
import {
CALENDAR_ALERT_OFFSETS,
CALENDAR_COLORS,
WEEK_TIME_INPUT_STEP_MINUTES,
addMonths,
formatLocalDateKey,
getLocalDayIndex,
getMonthGridDays,
getMonthStart,
pad2,
parseLocalDateKey
} from "./calendarUtils.js";
import { WEEKDAY_LABELS } from "./calendarLayout.js";
import { ALERT_MODES } from "./timerOptions.js";
const CALENDAR_COLOR_VALUES = {
violet: "#8b5cf6",
gold: "#f6c453",
cyan: "#22d3ee",
pink: "#d946ef",
green: "#22c55e",
gray: "#7d879f"
};
function normalizeDraftDateKey(value) {
const date = parseLocalDateKey(value);
return date ? formatLocalDateKey(date) : "";
}
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 formatMonthTitle(dateLike) {
return new Date(dateLike).toLocaleDateString(undefined, { month: "long", year: "numeric" });
}
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;
})
};
});
}
export function CalendarEventForm({
data,
draft,
error,
activeOccurrenceDate,
monthStart,
selectedAlertOffsets,
textContent,
onSubmit,
onDraftChange,
onAlertModeChange,
onAlertOffsetToggle,
onDeleteEvent,
onDeleteOccurrence,
onClose,
onDisplayedMonthChange
}) {
return (
<form className={`calendar-event-form calendar-event-form-${data.viewMode}`} onSubmit={onSubmit}>
<div className="calendar-form-row calendar-form-main-row">
<label className="calendar-title-field">
<span>{textContent.titleLabel || "Titre"}</span>
<input value={draft.title} onChange={(event) => onDraftChange({ title: event.target.value })} placeholder={textContent.titlePlaceholder || "Raid, reset, event..."} />
</label>
<CalendarColorPicker value={draft.color} textContent={textContent} onChange={(color) => onDraftChange({ color })} />
{draft.id && <div className="calendar-delete-actions">
{data.viewMode === "month" && draft.repeat !== "none" && activeOccurrenceDate && (
<button className="danger calendar-form-delete-button" type="button" onClick={() => onDeleteOccurrence(draft.id)} aria-label={textContent.deleteOccurrenceTitle || "Supprimer cette occurrence"} title={textContent.deleteOccurrenceTitle || "Supprimer cette occurrence"}><Icon name="trashcan-one" /></button>
)}
<button className="danger calendar-form-delete-button" type="button" onClick={() => onDeleteEvent(draft.id)} aria-label={data.viewMode === "month" && draft.repeat !== "none" ? textContent.deleteSeriesTitle || "Supprimer toutes les répétitions" : textContent.deleteTitle || "Supprimer"} title={data.viewMode === "month" && draft.repeat !== "none" ? textContent.deleteSeriesTitle || "Supprimer toutes les répétitions" : textContent.deleteTitle || "Supprimer"}><Icon name="trash" /></button>
</div>}
</div>
<div className={`calendar-form-row ${data.viewMode === "month" ? "calendar-form-month-row" : "calendar-form-time-row"}`}>
{data.viewMode === "month" && (
<div className="calendar-month-repeat-field">
<span>{textContent.monthlyRepeatLabel || "Règle mensuelle"}</span>
<MonthlyRepeatSwitch
value={draft.monthlyRepeatBy}
textContent={textContent}
onChange={(monthlyRepeatBy) => onDraftChange({
monthlyRepeatBy,
repeat: monthlyRepeatBy === "monthDay" && draft.repeat === "weekly" ? "none" : draft.repeat
})}
/>
</div>
)}
<div className="calendar-day-field">
<span>{data.viewMode === "month" ? textContent.startDayLabel || "Date début" : textContent.dayLabel || "Jour"}</span>
{data.viewMode === "month" ? (
<MonthDateDropdown
value={draft.startDate}
displayedMonth={monthStart}
mode={draft.monthlyRepeatBy}
textContent={textContent}
label={textContent.startDayLabel || "Date début"}
onMonthChange={onDisplayedMonthChange}
onChange={(value) => onDraftChange({ startDate: value, endDate: (draft.endDate && draft.endDate >= value) ? draft.endDate : value })}
/>
) : (
<select
value={draft.dayIndex}
onChange={(event) => {
const value = Number(event.target.value);
onDraftChange({ dayIndex: value, endDayIndex: Math.max(value, Number(draft.endDayIndex) || value) });
}}
>
{WEEKDAY_LABELS.map((label, index) => <option key={label} value={index}>{label}</option>)}
</select>
)}
</div>
{data.viewMode === "month" && (
<div className="calendar-day-field">
<span>{textContent.endDayLabel || "Date fin"}</span>
<MonthDateDropdown
value={draft.endDate}
displayedMonth={monthStart}
minDate={draft.startDate}
mode={draft.monthlyRepeatBy}
textContent={textContent}
label={textContent.endDayLabel || "Date fin"}
align="end"
onMonthChange={onDisplayedMonthChange}
onChange={(value) => onDraftChange({ endDate: value >= draft.startDate ? value : draft.startDate })}
/>
</div>
)}
{data.viewMode === "week" && (
<>
<label className="calendar-time-field">
<span>{textContent.startLabel || "Début"}</span>
<input type="time" step={WEEK_TIME_INPUT_STEP_MINUTES * 60} value={draft.startTime} onChange={(event) => onDraftChange({ startTime: event.target.value })} />
</label>
<label className="calendar-time-field">
<span>{textContent.endLabel || "Fin"}</span>
<input type="time" step={WEEK_TIME_INPUT_STEP_MINUTES * 60} value={draft.endTime} onChange={(event) => onDraftChange({ endTime: event.target.value })} />
</label>
</>
)}
{data.viewMode === "month" ? (
<div className="calendar-month-event-repeat-field">
<span>{textContent.repeatLabel || "Répétition"}</span>
<MonthEventRepeatSwitch value={draft.repeat} monthlyRepeatBy={draft.monthlyRepeatBy} textContent={textContent} onChange={(repeat) => onDraftChange({ repeat })} />
</div>
) : <div className="calendar-repeat-field">
<span>{textContent.repeatableTitle || "Répétable"}</span>
<button
className={`tool-split-scroll-toggle calendar-repeat-switch ${draft.repeatable !== false ? "active" : ""}`}
type="button"
onClick={() => onDraftChange({ repeatable: draft.repeatable === false })}
aria-pressed={draft.repeatable !== false}
title={textContent.repeatableTitle || "Répétable"}
>
<Icon name="refresh" />
<i aria-hidden="true" />
</button>
</div>}
</div>
<div className="calendar-form-row calendar-form-alert-row">
<CalendarAlertOffsets selectedOffsets={selectedAlertOffsets} textContent={textContent} onToggle={onAlertOffsetToggle} />
<div className="calendar-alert-type-field">
<span>{textContent.alertTypeLabel || "Type d'alertes"}</span>
<CalendarAlertModeSwitch mode={draft.alertMode} textContent={textContent} onChange={onAlertModeChange} />
</div>
<div className="calendar-form-actions">
<button className="primary" type="submit">{draft.id ? textContent.saveButton || "Enregistrer" : textContent.addButton || "Ajouter"}</button>
<button type="button" onClick={onClose}>{textContent.cancelButton || "Annuler"}</button>
</div>
</div>
{error && <p className="timer-form-error calendar-form-error" role="alert"><Icon name="warning" /><span>{error}</span></p>}
</form>
);
}
function MonthDateDropdown({ value, displayedMonth, minDate = "", mode, textContent, label, align = "start", onChange, onMonthChange }) {
const safeValue = normalizeDraftDateKey(value) || formatLocalDateKey(displayedMonth);
return (
<div className={`calendar-month-date-dropdown ${align === "end" ? "is-aligned-end" : ""}`}>
<button className="calendar-month-date-trigger" type="button" aria-label={label}>
<Icon name={mode === "weekday" ? "day-name" : "day-number"} />
<span>{formatMonthDateLabel(safeValue, mode)}</span>
<Icon name="chevron-down" />
</button>
<MonthDatePicker value={safeValue} displayedMonth={displayedMonth} minDate={minDate} mode={mode} textContent={textContent} onChange={onChange} onMonthChange={onMonthChange} />
</div>
);
}
function MonthDatePicker({ value, displayedMonth, minDate, mode, textContent, onChange, onMonthChange }) {
const days = getMonthGridDays(displayedMonth);
const weekdayRows = getWeekdayPickerRows(displayedMonth);
const minDateKey = normalizeDraftDateKey(minDate);
return (
<div className={`calendar-month-date-picker ${mode === "weekday" ? "is-weekday-mode" : ""}`} role="group" aria-label={textContent.monthDatePickerLabel || "Choisir une date"}>
{mode === "weekday" ? (
<>
{weekdayRows.flatMap((row) => [
<span key={`label-${row.ordinal}`} className="calendar-month-date-picker-week">{`S${pad2(row.ordinal)}`}</span>,
...row.days.map((day, dayIndex) => {
const dateKey = day ? formatLocalDateKey(day.date) : "";
const disabled = !day || (minDateKey && dateKey < minDateKey);
return (
<button
key={`${row.ordinal}-${dayIndex}`}
className={`${value === dateKey ? "active" : ""} ${!day ? "is-outside" : ""}`}
type="button"
disabled={disabled}
onClick={() => onChange(dateKey)}
aria-pressed={value === dateKey}
>
{WEEKDAY_LABELS[dayIndex]}
</button>
);
})
])}
</>
) : (
<>
<div className="calendar-month-date-picker-header">
<button type="button" onClick={() => onMonthChange(getMonthStart(addMonths(displayedMonth, -1)))} aria-label={textContent.previousMonthTitle || "Mois précédent"} title={textContent.previousMonthTitle || "Mois précédent"}>{textContent.previousShortLabel || "Prec."}</button>
<strong>{formatMonthTitle(displayedMonth)}</strong>
<button type="button" onClick={() => onMonthChange(getMonthStart(addMonths(displayedMonth, 1)))} aria-label={textContent.nextMonthTitle || "Mois suivant"} title={textContent.nextMonthTitle || "Mois suivant"}>{textContent.nextShortLabel || "Suiv."}</button>
</div>
{WEEKDAY_LABELS.map((day) => <span key={day} className="calendar-month-date-picker-day">{day.slice(0, 3)}</span>)}
{days.map((day) => {
const dateKey = formatLocalDateKey(day.date);
const disabled = !day.inMonth || (minDateKey && dateKey < minDateKey);
return (
<button
key={dateKey}
className={`${value === dateKey ? "active" : ""} ${!day.inMonth ? "is-outside" : ""}`}
type="button"
disabled={disabled}
onClick={() => onChange(dateKey)}
aria-pressed={value === dateKey}
>
{day.dayNumber}
</button>
);
})}
</>
)}
</div>
);
}
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 (
<div className="layout-switch calendar-month-repeat-switch" role="group" aria-label={textContent.monthlyRepeatLabel || "Règle mensuelle"}>
{options.map((option) => (
<button
key={option.value}
className={value === option.value ? "active" : ""}
type="button"
onClick={() => onChange(option.value)}
aria-pressed={value === option.value}
aria-label={option.label}
title={option.label}
>
<Icon name={option.icon} />
</button>
))}
</div>
);
}
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 (
<div className="calendar-month-event-repeat-switch" role="group" aria-label={textContent.repeatLabel || "Répétition"}>
{options.map((option) => (
<button
key={option.value}
className={safeValue === option.value ? "active" : ""}
type="button"
disabled={option.disabled}
onClick={() => onChange(option.value)}
aria-pressed={safeValue === option.value}
aria-label={option.label}
title={option.label}
>
<Icon name={option.icon} />
</button>
))}
</div>
);
}
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 (
<fieldset className="calendar-alert-offsets">
<legend>{textContent.alertOffsetLabel || "Alerte"}</legend>
{CALENDAR_ALERT_OFFSETS.map((offset) => (
<label key={offset}>
<input type="checkbox" checked={selectedOffsets.includes(offset)} onChange={() => onToggle(offset)} />
<span>{labels[offset]}</span>
</label>
))}
</fieldset>
);
}
function CalendarColorPicker({ value, textContent, onChange }) {
const label = textContent.colorLabel || "Couleur";
return (
<div className="calendar-color-picker notepad-color-dropdown">
<span>{label}</span>
<button
className="calendar-color-toggle notepad-color-toggle"
type="button"
title={label}
aria-label={label}
style={{ "--swatch-color": CALENDAR_COLOR_VALUES[value] || CALENDAR_COLOR_VALUES.violet }}
/>
<div className="calendar-color-menu notepad-color-menu" role="menu" aria-label={label}>
{CALENDAR_COLORS.map((color) => (
<button
key={color}
className={`notepad-swatch ${value === color ? "active" : ""}`}
type="button"
title={textContent[`color${color.charAt(0).toUpperCase()}${color.slice(1)}`] || color}
aria-label={textContent[`color${color.charAt(0).toUpperCase()}${color.slice(1)}`] || color}
aria-pressed={value === color}
style={{ "--swatch-color": CALENDAR_COLOR_VALUES[color] }}
onClick={() => onChange(color)}
/>
))}
</div>
</div>
);
}
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 (
<div className="timer-alert-mode-switch calendar-alert-mode-switch" role="group" aria-label={textContent.alertModeTitle || "Mode d'alerte"}>
{ALERT_MODES.map((option) => (
<button
key={option.mode}
className={`timer-alert-mode-button ${mode === option.mode ? "active" : ""}`}
type="button"
onClick={() => onChange(option.mode)}
aria-pressed={mode === option.mode}
aria-label={labels[option.mode]}
title={labels[option.mode]}
>
<Icon name={option.icon} />
</button>
))}
</div>
);
}

View file

@ -1,7 +1,8 @@
// Rôle : fournit l'outil calendrier hebdomadaire/mensuel avec événements, récurrences et alertes. // Rôle : fournit l'outil calendrier hebdomadaire/mensuel avec événements, récurrences et alertes.
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { Tooltip } from "../../../components/Tooltip.jsx"; import { CalendarEventForm } from "./CalendarEventForm.jsx";
import { CalendarMonthView, CalendarWeekGrid } from "./CalendarViews.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx"; import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { import {
CALENDAR_ALERT_MODES, CALENDAR_ALERT_MODES,
@ -9,7 +10,6 @@ import {
CALENDAR_COLORS, CALENDAR_COLORS,
CALENDAR_MONTHLY_REPEAT_BY, CALENDAR_MONTHLY_REPEAT_BY,
WEEK_SLOT_MINUTES, WEEK_SLOT_MINUTES,
WEEK_TIME_INPUT_STEP_MINUTES,
addDays, addDays,
addMonths, addMonths,
formatLocalDateKey, formatLocalDateKey,
@ -21,30 +21,13 @@ import {
getWeekDays, getWeekDays,
getWeekStart, getWeekStart,
minutesToTimeValue, minutesToTimeValue,
pad2,
parseLocalDateKey, parseLocalDateKey,
timeValueToMinutes timeValueToMinutes
} from "./calendarUtils.js"; } from "./calendarUtils.js";
import { import {
HOUR_HALF_SLOT_COUNT,
WEEK_COMPACT_EVENT_HEIGHT,
WEEK_SLOT_COUNT, WEEK_SLOT_COUNT,
WEEKDAY_LABELS, getWeekGridLayout
getMonthOccurrenceSegments,
getWeekGridLayout,
getWeekMinutePosition,
layoutConcurrentOccurrences
} from "./calendarLayout.js"; } from "./calendarLayout.js";
import { ALERT_MODES } from "./timerOptions.js";
const CALENDAR_COLOR_VALUES = {
violet: "#8b5cf6",
gold: "#f6c453",
cyan: "#22d3ee",
pink: "#d946ef",
green: "#22c55e",
gray: "#7d879f"
};
function createDefaultDraft(date = new Date()) { function createDefaultDraft(date = new Date()) {
const dayIndex = getLocalDayIndex(date); const dayIndex = getLocalDayIndex(date);
@ -68,39 +51,6 @@ function createDefaultDraft(date = new Date()) {
}; };
} }
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) { function getDraftSelection(draft) {
const dayIndex = Number(draft.dayIndex); const dayIndex = Number(draft.dayIndex);
const startMinutes = timeValueToMinutes(draft.startTime); const startMinutes = timeValueToMinutes(draft.startTime);
@ -119,39 +69,6 @@ function getCalendarRepeatMode() {
return "weekly"; 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) { function getDateSpanDays(startDateKey, endDateKey) {
const start = parseLocalDateKey(startDateKey); const start = parseLocalDateKey(startDateKey);
const end = parseLocalDateKey(endDateKey); const end = parseLocalDateKey(endDateKey);
@ -159,6 +76,11 @@ function getDateSpanDays(startDateKey, endDateKey) {
return Math.round((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000)); return Math.round((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000));
} }
function normalizeDraftDateKey(value) {
const date = parseLocalDateKey(value);
return date ? formatLocalDateKey(date) : "";
}
function normalizeDraftAlertOffsets(value) { function normalizeDraftAlertOffsets(value) {
const rawOffsets = Array.isArray(value) ? value : [value]; const rawOffsets = Array.isArray(value) ? value : [value];
const offsets = [...new Set(rawOffsets.map(Number).filter((offset) => CALENDAR_ALERT_OFFSETS.includes(offset)))]; const offsets = [...new Set(rawOffsets.map(Number).filter((offset) => CALENDAR_ALERT_OFFSETS.includes(offset)))];
@ -527,118 +449,25 @@ export function CalendarModule({ toolboxId, moduleId, context, editing = false,
return ( return (
<div className="calendar-module" onKeyDown={handleKeyDown}> <div className="calendar-module" onKeyDown={handleKeyDown}>
{formOpen && <form className={`calendar-event-form calendar-event-form-${data.viewMode}`} onSubmit={submitEvent}> {formOpen && (
<div className="calendar-form-row calendar-form-main-row"> <CalendarEventForm
<label className="calendar-title-field"> data={data}
<span>{textContent.titleLabel || "Titre"}</span> draft={draft}
<input value={draft.title} onChange={(event) => updateDraft({ title: event.target.value })} placeholder={textContent.titlePlaceholder || "Raid, reset, event..."} /> error={error}
</label> activeOccurrenceDate={activeOccurrenceDate}
<CalendarColorPicker value={draft.color} textContent={textContent} onChange={(color) => updateDraft({ color })} /> monthStart={monthStart}
{draft.id && <div className="calendar-delete-actions"> selectedAlertOffsets={selectedAlertOffsets}
{data.viewMode === "month" && draft.repeat !== "none" && activeOccurrenceDate && ( textContent={textContent}
<button className="danger calendar-form-delete-button" type="button" onClick={() => deleteOccurrence(draft.id)} aria-label={textContent.deleteOccurrenceTitle || "Supprimer cette occurrence"} title={textContent.deleteOccurrenceTitle || "Supprimer cette occurrence"}><Icon name="trashcan-one" /></button> onSubmit={submitEvent}
)} onDraftChange={updateDraft}
<button className="danger calendar-form-delete-button" type="button" onClick={() => deleteEvent(draft.id)} aria-label={data.viewMode === "month" && draft.repeat !== "none" ? textContent.deleteSeriesTitle || "Supprimer toutes les répétitions" : textContent.deleteTitle || "Supprimer"} title={data.viewMode === "month" && draft.repeat !== "none" ? textContent.deleteSeriesTitle || "Supprimer toutes les répétitions" : textContent.deleteTitle || "Supprimer"}><Icon name="trash" /></button> onAlertModeChange={updateAlertMode}
</div>} onAlertOffsetToggle={toggleAlertOffset}
</div> onDeleteEvent={deleteEvent}
<div className={`calendar-form-row ${data.viewMode === "month" ? "calendar-form-month-row" : "calendar-form-time-row"}`}> onDeleteOccurrence={deleteOccurrence}
{data.viewMode === "month" && ( onClose={closeForm}
<div className="calendar-month-repeat-field"> onDisplayedMonthChange={setDisplayedMonth}
<span>{textContent.monthlyRepeatLabel || "Règle mensuelle"}</span> />
<MonthlyRepeatSwitch )}
value={draft.monthlyRepeatBy}
textContent={textContent}
onChange={(monthlyRepeatBy) => updateDraft({
monthlyRepeatBy,
repeat: monthlyRepeatBy === "monthDay" && draft.repeat === "weekly" ? "none" : draft.repeat
})}
/>
</div>
)}
<div className="calendar-day-field">
<span>{data.viewMode === "month" ? textContent.startDayLabel || "Date début" : textContent.dayLabel || "Jour"}</span>
{data.viewMode === "month" ? (
<MonthDateDropdown
value={draft.startDate}
displayedMonth={monthStart}
mode={draft.monthlyRepeatBy}
textContent={textContent}
label={textContent.startDayLabel || "Date début"}
onMonthChange={setDisplayedMonth}
onChange={(value) => updateDraft({ startDate: value, endDate: (draft.endDate && draft.endDate >= value) ? draft.endDate : value })}
/>
) : (
<select
value={draft.dayIndex}
onChange={(event) => {
const value = Number(event.target.value);
updateDraft({ dayIndex: value, endDayIndex: Math.max(value, Number(draft.endDayIndex) || value) });
}}
>
{WEEKDAY_LABELS.map((label, index) => <option key={label} value={index}>{label}</option>)}
</select>
)}
</div>
{data.viewMode === "month" && (
<div className="calendar-day-field">
<span>{textContent.endDayLabel || "Date fin"}</span>
<MonthDateDropdown
value={draft.endDate}
displayedMonth={monthStart}
minDate={draft.startDate}
mode={draft.monthlyRepeatBy}
textContent={textContent}
label={textContent.endDayLabel || "Date fin"}
align="end"
onMonthChange={setDisplayedMonth}
onChange={(value) => updateDraft({ endDate: value >= draft.startDate ? value : draft.startDate })}
/>
</div>
)}
{data.viewMode === "week" && (
<>
<label className="calendar-time-field">
<span>{textContent.startLabel || "Début"}</span>
<input type="time" step={WEEK_TIME_INPUT_STEP_MINUTES * 60} value={draft.startTime} onChange={(event) => updateDraft({ startTime: event.target.value })} />
</label>
<label className="calendar-time-field">
<span>{textContent.endLabel || "Fin"}</span>
<input type="time" step={WEEK_TIME_INPUT_STEP_MINUTES * 60} value={draft.endTime} onChange={(event) => updateDraft({ endTime: event.target.value })} />
</label>
</>
)}
{data.viewMode === "month" ? (
<div className="calendar-month-event-repeat-field">
<span>{textContent.repeatLabel || "Répétition"}</span>
<MonthEventRepeatSwitch value={draft.repeat} monthlyRepeatBy={draft.monthlyRepeatBy} textContent={textContent} onChange={(repeat) => updateDraft({ repeat })} />
</div>
) : <div className="calendar-repeat-field">
<span>{textContent.repeatableTitle || "Répétable"}</span>
<button
className={`tool-split-scroll-toggle calendar-repeat-switch ${draft.repeatable !== false ? "active" : ""}`}
type="button"
onClick={() => updateDraft({ repeatable: draft.repeatable === false })}
aria-pressed={draft.repeatable !== false}
title={textContent.repeatableTitle || "Répétable"}
>
<Icon name="refresh" />
<i aria-hidden="true" />
</button>
</div>}
</div>
<div className="calendar-form-row calendar-form-alert-row">
<CalendarAlertOffsets selectedOffsets={selectedAlertOffsets} textContent={textContent} onToggle={toggleAlertOffset} />
<div className="calendar-alert-type-field">
<span>{textContent.alertTypeLabel || "Type d'alertes"}</span>
<CalendarAlertModeSwitch mode={draft.alertMode} textContent={textContent} onChange={updateAlertMode} />
</div>
<div className="calendar-form-actions">
<button className="primary" type="submit">{draft.id ? textContent.saveButton || "Enregistrer" : textContent.addButton || "Ajouter"}</button>
<button type="button" onClick={closeForm}>{textContent.cancelButton || "Annuler"}</button>
</div>
</div>
{error && <p className="timer-form-error calendar-form-error" role="alert"><Icon name="warning" /><span>{error}</span></p>}
</form>}
{textExchangeOpen && ( {textExchangeOpen && (
<div className="module-text-exchange-panel"> <div className="module-text-exchange-panel">
<TextExchangeActions type="calendar" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} /> <TextExchangeActions type="calendar" data={data} context={context} textContent={textContent} onImport={(nextData) => save(nextData)} />
@ -662,413 +491,42 @@ export function CalendarModule({ toolboxId, moduleId, context, editing = false,
)} )}
{data.viewMode === "month" ? ( {data.viewMode === "month" ? (
<> <CalendarMonthView
<div className="calendar-month-toolbar"> monthStart={monthStart}
<button type="button" onClick={() => setDisplayedMonth(getMonthStart(addMonths(monthStart, -1)))} title={textContent.previousMonthTitle || "Mois précédent"} aria-label={textContent.previousMonthTitle || "Mois précédent"}>{textContent.previousShortLabel || "Prec."}</button> monthGridDays={monthGridDays}
<strong>{formatMonthTitle(monthStart)}</strong> occurrences={occurrences}
<button type="button" onClick={() => setDisplayedMonth(getMonthStart(new Date()))}>{textContent.currentMonthTitle || "Mois courant"}</button> textContent={textContent}
<button type="button" onClick={() => setDisplayedMonth(getMonthStart(addMonths(monthStart, 1)))} title={textContent.nextMonthTitle || "Mois suivant"} aria-label={textContent.nextMonthTitle || "Mois suivant"}>{textContent.nextShortLabel || "Suiv."}</button> activeEventId={activeEventId}
</div> selectedRange={formOpen ? { startDate: draft.startDate, endDate: draft.endDate || draft.startDate } : null}
<CalendarMonthGrid dragSelection={monthSelection}
days={monthGridDays} todayDate={formatLocalDateKey(now)}
occurrences={occurrences} nowLabel={minutesToTimeValue(nowMinutes)}
textContent={textContent} onPreviousMonth={() => setDisplayedMonth(getMonthStart(addMonths(monthStart, -1)))}
activeEventId={activeEventId} onCurrentMonth={() => setDisplayedMonth(getMonthStart(new Date()))}
selectedRange={formOpen ? { startDate: draft.startDate, endDate: draft.endDate || draft.startDate } : null} onNextMonth={() => setDisplayedMonth(getMonthStart(addMonths(monthStart, 1)))}
dragSelection={monthSelection} onPointerDown={handleMonthPointerDown}
todayDate={formatLocalDateKey(now)} onPointerEnter={handleMonthPointerEnter}
nowLabel={minutesToTimeValue(nowMinutes)} onPointerUp={() => setMonthSelection(null)}
onPointerDown={handleMonthPointerDown} onEditEvent={editEvent}
onPointerEnter={handleMonthPointerEnter} />
onPointerUp={() => setMonthSelection(null)}
onEditEvent={editEvent}
/>
</>
) : <section className="calendar-week-grid" aria-label={textContent.gridLabel || "Calendrier hebdomadaire"}>
<div className="calendar-time-header" />
{days.map((day) => <div key={day.toISOString()} className="calendar-day-header">{formatDayLabel(day)}</div>)}
<div className="calendar-time-column" style={{ gridTemplateRows: weekGridLayout.gridTemplateRows }}>
{weekGridLayout.rows.map((row) => (
row.type === "hidden"
? <span key={`hidden:${row.startHour}`} className="calendar-hidden-hours-label">...</span>
: <span key={row.hourIndex} className="calendar-time-row">{formatHourLabel(row.hourIndex)}</span>
))}
</div>
{days.map((day, dayIndex) => {
const dayOccurrences = layoutConcurrentOccurrences(occurrences.filter((occurrence) => occurrence.event.dayIndex === dayIndex));
const isToday = dayIndex === nowDayIndex;
return (
<div key={day.toISOString()} className={`calendar-day-column ${isToday ? "is-today" : ""}`} style={{ gridTemplateRows: weekGridLayout.gridTemplateRows }}>
{weekGridLayout.rows.map((row) => (
row.type === "hidden" ? (
<div key={`hidden:${row.startHour}`} className="calendar-hidden-hours-cell" aria-hidden="true">
{dayIndex === 3 && <span>{`${formatHourLabel(row.startHour)} - ${formatHourLabel(row.endHour + 1)}`}</span>}
</div>
) : (
<div key={row.hourIndex} className="calendar-hour-cell">
{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 (
<button
key={slotIndex}
type="button"
className={`calendar-slot ${formOpen && isSlotSelected(draftSelection, dayIndex, slotIndex) ? "is-form-selected" : ""} ${isSlotSelected(selection, dayIndex, slotIndex) ? "is-selected" : ""}`}
onPointerDown={() => handleSlotPointerDown(dayIndex, slotIndex)}
onPointerEnter={(event) => handleSlotPointerEnter(event, dayIndex, slotIndex)}
onPointerUp={() => setSelection(null)}
aria-label={`${formatDayLabel(day)} ${slotLabel}`}
/>
);
})}
</div>
)
))}
<div className="calendar-events-layer">
{dayOccurrences.map((occurrence) => (
<CalendarOccurrence
key={occurrence.id}
occurrence={occurrence}
layout={weekGridLayout}
active={activeEventId === occurrence.event.id}
onEdit={() => editEvent(occurrence.event)}
/>
))}
</div>
{isToday && (
<div className="calendar-now-line" style={{ top: `${getWeekMinutePosition(weekGridLayout, nowMinutes)}px` }}>
<span>{minutesToTimeValue(nowMinutes)}</span>
</div>
)}
</div>
);
})}
</section>}
</div>
);
}
function MonthDateDropdown({ value, displayedMonth, minDate = "", mode, textContent, label, align = "start", onChange, onMonthChange }) {
const safeValue = normalizeDraftDateKey(value) || formatLocalDateKey(displayedMonth);
return (
<div className={`calendar-month-date-dropdown ${align === "end" ? "is-aligned-end" : ""}`}>
<button className="calendar-month-date-trigger" type="button" aria-label={label}>
<Icon name={mode === "weekday" ? "day-name" : "day-number"} />
<span>{formatMonthDateLabel(safeValue, mode)}</span>
<Icon name="chevron-down" />
</button>
<MonthDatePicker value={safeValue} displayedMonth={displayedMonth} minDate={minDate} mode={mode} textContent={textContent} onChange={onChange} onMonthChange={onMonthChange} />
</div>
);
}
function MonthDatePicker({ value, displayedMonth, minDate, mode, textContent, onChange, onMonthChange }) {
const days = getMonthGridDays(displayedMonth);
const weekdayRows = getWeekdayPickerRows(displayedMonth);
const minDateKey = normalizeDraftDateKey(minDate);
return (
<div className={`calendar-month-date-picker ${mode === "weekday" ? "is-weekday-mode" : ""}`} role="group" aria-label={textContent.monthDatePickerLabel || "Choisir une date"}>
{mode === "weekday" ? (
<>
{weekdayRows.flatMap((row) => [
<span key={`label-${row.ordinal}`} className="calendar-month-date-picker-week">{`S${pad2(row.ordinal)}`}</span>,
...row.days.map((day, dayIndex) => {
const dateKey = day ? formatLocalDateKey(day.date) : "";
const disabled = !day || (minDateKey && dateKey < minDateKey);
return (
<button
key={`${row.ordinal}-${dayIndex}`}
className={`${value === dateKey ? "active" : ""} ${!day ? "is-outside" : ""}`}
type="button"
disabled={disabled}
onClick={() => onChange(dateKey)}
aria-pressed={value === dateKey}
>
{WEEKDAY_LABELS[dayIndex]}
</button>
);
})
])}
</>
) : ( ) : (
<> <CalendarWeekGrid
<div className="calendar-month-date-picker-header"> days={days}
<button type="button" onClick={() => onMonthChange(getMonthStart(addMonths(displayedMonth, -1)))} aria-label={textContent.previousMonthTitle || "Mois précédent"} title={textContent.previousMonthTitle || "Mois précédent"}>{textContent.previousShortLabel || "Prec."}</button> occurrences={occurrences}
<strong>{formatMonthTitle(displayedMonth)}</strong> textContent={textContent}
<button type="button" onClick={() => onMonthChange(getMonthStart(addMonths(displayedMonth, 1)))} aria-label={textContent.nextMonthTitle || "Mois suivant"} title={textContent.nextMonthTitle || "Mois suivant"}>{textContent.nextShortLabel || "Suiv."}</button> activeEventId={activeEventId}
</div> formOpen={formOpen}
{WEEKDAY_LABELS.map((day) => <span key={day} className="calendar-month-date-picker-day">{day.slice(0, 3)}</span>)} draftSelection={draftSelection}
{days.map((day) => { selection={selection}
const dateKey = formatLocalDateKey(day.date); weekGridLayout={weekGridLayout}
const disabled = !day.inMonth || (minDateKey && dateKey < minDateKey); nowDayIndex={nowDayIndex}
return ( nowMinutes={nowMinutes}
<button onSlotPointerDown={handleSlotPointerDown}
key={dateKey} onSlotPointerEnter={handleSlotPointerEnter}
className={`${value === dateKey ? "active" : ""} ${!day.inMonth ? "is-outside" : ""}`} onSlotPointerUp={() => setSelection(null)}
type="button" onEditEvent={editEvent}
disabled={disabled} />
onClick={() => onChange(dateKey)}
aria-pressed={value === dateKey}
>
{day.dayNumber}
</button>
);
})}
</>
)} )}
</div> </div>
); );
} }
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 (
<div className="layout-switch calendar-month-repeat-switch" role="group" aria-label={textContent.monthlyRepeatLabel || "Règle mensuelle"}>
{options.map((option) => (
<button
key={option.value}
className={value === option.value ? "active" : ""}
type="button"
onClick={() => onChange(option.value)}
aria-pressed={value === option.value}
aria-label={option.label}
title={option.label}
>
<Icon name={option.icon} />
</button>
))}
</div>
);
}
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 (
<div className="calendar-month-event-repeat-switch" role="group" aria-label={textContent.repeatLabel || "Répétition"}>
{options.map((option) => (
<button
key={option.value}
className={safeValue === option.value ? "active" : ""}
type="button"
disabled={option.disabled}
onClick={() => onChange(option.value)}
aria-pressed={safeValue === option.value}
aria-label={option.label}
title={option.label}
>
<Icon name={option.icon} />
</button>
))}
</div>
);
}
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 (
<fieldset className="calendar-alert-offsets">
<legend>{textContent.alertOffsetLabel || "Alerte"}</legend>
{CALENDAR_ALERT_OFFSETS.map((offset) => (
<label key={offset}>
<input type="checkbox" checked={selectedOffsets.includes(offset)} onChange={() => onToggle(offset)} />
<span>{labels[offset]}</span>
</label>
))}
</fieldset>
);
}
function CalendarColorPicker({ value, textContent, onChange }) {
const label = textContent.colorLabel || "Couleur";
return (
<div className="calendar-color-picker notepad-color-dropdown">
<span>{label}</span>
<button
className="calendar-color-toggle notepad-color-toggle"
type="button"
title={label}
aria-label={label}
style={{ "--swatch-color": CALENDAR_COLOR_VALUES[value] || CALENDAR_COLOR_VALUES.violet }}
/>
<div className="calendar-color-menu notepad-color-menu" role="menu" aria-label={label}>
{CALENDAR_COLORS.map((color) => (
<button
key={color}
className={`notepad-swatch ${value === color ? "active" : ""}`}
type="button"
title={textContent[`color${color.charAt(0).toUpperCase()}${color.slice(1)}`] || color}
aria-label={textContent[`color${color.charAt(0).toUpperCase()}${color.slice(1)}`] || color}
aria-pressed={value === color}
style={{ "--swatch-color": CALENDAR_COLOR_VALUES[color] }}
onClick={() => onChange(color)}
/>
))}
</div>
</div>
);
}
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 (
<article
className={`calendar-event calendar-event-${occurrence.event.color || "violet"} ${active ? "is-active" : ""} ${compact ? "is-compact" : ""}`}
style={{
top: `${top}px`,
height: `${height}px`,
"--calendar-event-lane": occurrence.lane || 0,
"--calendar-event-lane-count": occurrence.laneCount || 1
}}
>
<Tooltip
as="button"
type="button"
className="calendar-event-button"
tooltipClassName="calendar-event-tooltip"
content={<><strong>{occurrence.event.title}</strong><span>{timeLabel}</span></>}
onClick={onEdit}
>
<strong>{occurrence.event.title}</strong>
{!compact && <span>{timeLabel}</span>}
</Tooltip>
</article>
);
}
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 (
<section className="calendar-month-grid" style={{ "--calendar-month-week-count": weekRowCount }} aria-label={textContent.monthGridLabel || "Calendrier mensuel"}>
{WEEKDAY_LABELS.map((day) => <div key={day} className="calendar-month-header">{day}</div>)}
{days.map((day) => {
const dateKey = formatLocalDateKey(day.date);
const isToday = todayDate === dateKey;
return (
<div
key={day.date.toISOString()}
className={`calendar-month-cell ${day.inMonth ? "" : "is-outside"} ${day.inMonth && isMonthDateSelected(selectedRange, dateKey) ? "is-form-selected" : ""} ${day.inMonth && isMonthDateSelected(dragSelection, dateKey) ? "is-selected" : ""} ${day.inMonth && isToday ? "is-today" : ""}`}
role={day.inMonth ? "button" : undefined}
tabIndex={day.inMonth ? 0 : undefined}
onPointerDown={() => 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 && (
<>
<span className="calendar-month-day-number">{day.dayNumber}</span>
{isToday && <span className="calendar-month-now-indicator">{nowLabel}</span>}
</>
)}
</div>
);
})}
<div className="calendar-month-bars-layer" aria-hidden={monthBars.length ? undefined : true}>
{monthBars.map((segment, index) => (
<CalendarMonthBar
key={`${segment.occurrence.id}:${index}`}
segment={segment}
active={activeEventId === segment.occurrence.event.id}
onEdit={(event) => {
event.stopPropagation();
onEditEvent(segment.occurrence.event, segment.occurrence);
}}
/>
))}
</div>
</section>
);
}
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 (
<article
className={`calendar-month-bar calendar-event-${segment.occurrence.event.color || "violet"} ${active ? "is-active" : ""}`}
style={{
gridColumn: `${startColumn} / ${endColumn}`,
gridRow,
"--calendar-event-lane": segment.lane || 0,
"--calendar-month-bar-height": `${barHeight}px`,
"--calendar-month-bar-gap": `${barGap}px`
}}
>
<Tooltip
as="button"
type="button"
className="calendar-month-bar-button"
tooltipClassName="calendar-event-tooltip"
content={<><strong>{segment.occurrence.event.title}</strong><span>{dateLabel}</span></>}
onPointerDown={(event) => event.stopPropagation()}
onClick={onEdit}
>
<strong>{segment.occurrence.event.title}</strong>
</Tooltip>
</article>
);
}
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 (
<div className="timer-alert-mode-switch calendar-alert-mode-switch" role="group" aria-label={textContent.alertModeTitle || "Mode d'alerte"}>
{ALERT_MODES.map((option) => (
<button
key={option.mode}
className={`timer-alert-mode-button ${mode === option.mode ? "active" : ""}`}
type="button"
onClick={() => onChange(option.mode)}
aria-pressed={mode === option.mode}
aria-label={labels[option.mode]}
title={labels[option.mode]}
>
<Icon name={option.icon} />
</button>
))}
</div>
);
}

View file

@ -0,0 +1,302 @@
// Rôle : rend les vues calendrier semaine et mois avec occurrences, sélection et ligne horaire courante.
import { Tooltip } from "../../../components/Tooltip.jsx";
import {
WEEK_SLOT_MINUTES,
addDays,
formatLocalDateKey,
minutesToTimeValue,
pad2
} from "./calendarUtils.js";
import {
HOUR_HALF_SLOT_COUNT,
WEEK_COMPACT_EVENT_HEIGHT,
WEEKDAY_LABELS,
getMonthOccurrenceSegments,
getWeekMinutePosition,
layoutConcurrentOccurrences
} from "./calendarLayout.js";
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 formatMonthTitle(dateLike) {
return new Date(dateLike).toLocaleDateString(undefined, { month: "long", year: "numeric" });
}
export function CalendarWeekGrid({
days,
occurrences,
textContent,
activeEventId,
formOpen,
draftSelection,
selection,
weekGridLayout,
nowDayIndex,
nowMinutes,
onSlotPointerDown,
onSlotPointerEnter,
onSlotPointerUp,
onEditEvent
}) {
return (
<section className="calendar-week-grid" aria-label={textContent.gridLabel || "Calendrier hebdomadaire"}>
<div className="calendar-time-header" />
{days.map((day) => <div key={day.toISOString()} className="calendar-day-header">{formatDayLabel(day)}</div>)}
<div className="calendar-time-column" style={{ gridTemplateRows: weekGridLayout.gridTemplateRows }}>
{weekGridLayout.rows.map((row) => (
row.type === "hidden"
? <span key={`hidden:${row.startHour}`} className="calendar-hidden-hours-label">...</span>
: <span key={row.hourIndex} className="calendar-time-row">{formatHourLabel(row.hourIndex)}</span>
))}
</div>
{days.map((day, dayIndex) => {
const dayOccurrences = layoutConcurrentOccurrences(occurrences.filter((occurrence) => occurrence.event.dayIndex === dayIndex));
const isToday = dayIndex === nowDayIndex;
return (
<div key={day.toISOString()} className={`calendar-day-column ${isToday ? "is-today" : ""}`} style={{ gridTemplateRows: weekGridLayout.gridTemplateRows }}>
{weekGridLayout.rows.map((row) => (
row.type === "hidden" ? (
<div key={`hidden:${row.startHour}`} className="calendar-hidden-hours-cell" aria-hidden="true">
{dayIndex === 3 && <span>{`${formatHourLabel(row.startHour)} - ${formatHourLabel(row.endHour + 1)}`}</span>}
</div>
) : (
<div key={row.hourIndex} className="calendar-hour-cell">
{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 (
<button
key={slotIndex}
type="button"
className={`calendar-slot ${formOpen && isSlotSelected(draftSelection, dayIndex, slotIndex) ? "is-form-selected" : ""} ${isSlotSelected(selection, dayIndex, slotIndex) ? "is-selected" : ""}`}
onPointerDown={() => onSlotPointerDown(dayIndex, slotIndex)}
onPointerEnter={(event) => onSlotPointerEnter(event, dayIndex, slotIndex)}
onPointerUp={onSlotPointerUp}
aria-label={`${formatDayLabel(day)} ${slotLabel}`}
/>
);
})}
</div>
)
))}
<div className="calendar-events-layer">
{dayOccurrences.map((occurrence) => (
<CalendarOccurrence
key={occurrence.id}
occurrence={occurrence}
layout={weekGridLayout}
active={activeEventId === occurrence.event.id}
onEdit={() => onEditEvent(occurrence.event)}
/>
))}
</div>
{isToday && (
<div className="calendar-now-line" style={{ top: `${getWeekMinutePosition(weekGridLayout, nowMinutes)}px` }}>
<span>{minutesToTimeValue(nowMinutes)}</span>
</div>
)}
</div>
);
})}
</section>
);
}
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 (
<article
className={`calendar-event calendar-event-${occurrence.event.color || "violet"} ${active ? "is-active" : ""} ${compact ? "is-compact" : ""}`}
style={{
top: `${top}px`,
height: `${height}px`,
"--calendar-event-lane": occurrence.lane || 0,
"--calendar-event-lane-count": occurrence.laneCount || 1
}}
>
<Tooltip
as="button"
type="button"
className="calendar-event-button"
tooltipClassName="calendar-event-tooltip"
content={<><strong>{occurrence.event.title}</strong><span>{timeLabel}</span></>}
onClick={onEdit}
>
<strong>{occurrence.event.title}</strong>
{!compact && <span>{timeLabel}</span>}
</Tooltip>
</article>
);
}
export function CalendarMonthView({
monthStart,
monthGridDays,
occurrences,
textContent,
activeEventId,
selectedRange,
dragSelection,
todayDate,
nowLabel,
onPreviousMonth,
onCurrentMonth,
onNextMonth,
onPointerDown,
onPointerEnter,
onPointerUp,
onEditEvent
}) {
return (
<>
<div className="calendar-month-toolbar">
<button type="button" onClick={onPreviousMonth} title={textContent.previousMonthTitle || "Mois précédent"} aria-label={textContent.previousMonthTitle || "Mois précédent"}>{textContent.previousShortLabel || "Prec."}</button>
<strong>{formatMonthTitle(monthStart)}</strong>
<button type="button" onClick={onCurrentMonth}>{textContent.currentMonthTitle || "Mois courant"}</button>
<button type="button" onClick={onNextMonth} title={textContent.nextMonthTitle || "Mois suivant"} aria-label={textContent.nextMonthTitle || "Mois suivant"}>{textContent.nextShortLabel || "Suiv."}</button>
</div>
<CalendarMonthGrid
days={monthGridDays}
occurrences={occurrences}
textContent={textContent}
activeEventId={activeEventId}
selectedRange={selectedRange}
dragSelection={dragSelection}
todayDate={todayDate}
nowLabel={nowLabel}
onPointerDown={onPointerDown}
onPointerEnter={onPointerEnter}
onPointerUp={onPointerUp}
onEditEvent={onEditEvent}
/>
</>
);
}
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 (
<section className="calendar-month-grid" style={{ "--calendar-month-week-count": weekRowCount }} aria-label={textContent.monthGridLabel || "Calendrier mensuel"}>
{WEEKDAY_LABELS.map((day) => <div key={day} className="calendar-month-header">{day}</div>)}
{days.map((day) => {
const dateKey = formatLocalDateKey(day.date);
const isToday = todayDate === dateKey;
return (
<div
key={day.date.toISOString()}
className={`calendar-month-cell ${day.inMonth ? "" : "is-outside"} ${day.inMonth && isMonthDateSelected(selectedRange, dateKey) ? "is-form-selected" : ""} ${day.inMonth && isMonthDateSelected(dragSelection, dateKey) ? "is-selected" : ""} ${day.inMonth && isToday ? "is-today" : ""}`}
role={day.inMonth ? "button" : undefined}
tabIndex={day.inMonth ? 0 : undefined}
onPointerDown={() => 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 && (
<>
<span className="calendar-month-day-number">{day.dayNumber}</span>
{isToday && <span className="calendar-month-now-indicator">{nowLabel}</span>}
</>
)}
</div>
);
})}
<div className="calendar-month-bars-layer" aria-hidden={monthBars.length ? undefined : true}>
{monthBars.map((segment, index) => (
<CalendarMonthBar
key={`${segment.occurrence.id}:${index}`}
segment={segment}
active={activeEventId === segment.occurrence.event.id}
onEdit={(event) => {
event.stopPropagation();
onEditEvent(segment.occurrence.event, segment.occurrence);
}}
/>
))}
</div>
</section>
);
}
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 (
<article
className={`calendar-month-bar calendar-event-${segment.occurrence.event.color || "violet"} ${active ? "is-active" : ""}`}
style={{
gridColumn: `${startColumn} / ${endColumn}`,
gridRow,
"--calendar-event-lane": segment.lane || 0,
"--calendar-month-bar-height": `${barHeight}px`,
"--calendar-month-bar-gap": `${barGap}px`
}}
>
<Tooltip
as="button"
type="button"
className="calendar-month-bar-button"
tooltipClassName="calendar-event-tooltip"
content={<><strong>{segment.occurrence.event.title}</strong><span>{dateLabel}</span></>}
onPointerDown={(event) => event.stopPropagation()}
onClick={onEdit}
>
<strong>{segment.occurrence.event.title}</strong>
</Tooltip>
</article>
);
}