// 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 CALENDAR_COLOR_VALUES = { violet: "#8b5cf6", gold: "#f6c453", cyan: "#22d3ee", pink: "#d946ef", green: "#22c55e", gray: "#7d879f" }; export const CALENDAR_DARK_TEXT_COLORS = new Set(["gold", "cyan", "green"]); 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 getCalendarColorValue(color) { return CALENDAR_COLOR_VALUES[color] || CALENDAR_COLOR_VALUES.violet; } export function getCalendarEventSurfaceVariant(color) { return CALENDAR_DARK_TEXT_COLORS.has(color) ? "dark" : "default"; } 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 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; }