278 lines
11 KiB
JavaScript
278 lines
11 KiB
JavaScript
// 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;
|
|
}
|