Add calendar toolbox with weekly and monthly scheduling
All checks were successful
Deploy Sokko G / deploy (push) Successful in 14s

This commit is contained in:
Shinuwa 2026-08-20 15:48:24 +02:00
parent 4f73e0068b
commit 992665f4ea
39 changed files with 4331 additions and 36 deletions

View file

@ -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;
}