import { addDays, addMonths, addWeeks, addYears, format, startOfWeek } from "date-fns" import { fr } from "date-fns/locale" import { parseICSDate, WEEK_OPTS } from "./agenda-date.ts" export type RRuleFreq = "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY" export interface ParsedRRule { freq: RRuleFreq interval: number count?: number until?: Date /** Jours (0 = dimanche … 6 = samedi), uniquement pour WEEKLY. */ byday: number[] } const BYDAY_TO_INDEX: Record = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6, } const INDEX_TO_BYDAY = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"] export function parseRRule(rrule: string): ParsedRRule | null { const value = rrule.trim().replace(/^RRULE:/i, "") if (!value) return null const parts: Record = {} for (const seg of value.split(";")) { const [k, v] = seg.split("=") if (k && v) parts[k.toUpperCase()] = v.toUpperCase() } const freq = parts.FREQ as RRuleFreq | undefined if (!freq || !["DAILY", "WEEKLY", "MONTHLY", "YEARLY"].includes(freq)) return null const interval = Math.max(1, Number(parts.INTERVAL ?? "1") || 1) const count = parts.COUNT ? Math.max(1, Number(parts.COUNT) || 1) : undefined const until = parts.UNTIL ? (parseICSDate(parts.UNTIL) ?? undefined) : undefined const byday = freq === "WEEKLY" && parts.BYDAY ? parts.BYDAY.split(",") .map((d) => BYDAY_TO_INDEX[d.trim()]) .filter((d): d is number => d !== undefined) : [] return { freq, interval, count, until, byday } } /** * Développe les occurrences d'une règle dans une fenêtre donnée. * COUNT est compté depuis la première occurrence, même hors fenêtre. */ export function expandOccurrences( masterStart: Date, rule: ParsedRRule, exdates: Set, rangeStart: Date, rangeEnd: Date, maxOccurrences = 400, ): Date[] { const out: Date[] = [] let produced = 0 const push = (occurrence: Date): boolean => { if (rule.until && occurrence > rule.until) return false produced++ if (rule.count && produced > rule.count) return false if ( occurrence >= rangeStart && occurrence <= rangeEnd && !exdates.has(occurrence.getTime()) ) { out.push(occurrence) } return out.length < maxOccurrences } if (rule.freq === "WEEKLY" && rule.byday.length > 0) { const days = [...rule.byday].sort((a, b) => a - b) let weekStart = startOfWeek(masterStart, WEEK_OPTS) // Garde-fou : 1 000 semaines ≈ 19 ans. for (let w = 0; w < 1000; w++) { for (const day of days) { // weekStart est un lundi (index 1) — offset vers le jour demandé. const offset = (day - 1 + 7) % 7 const occurrence = new Date(addDays(weekStart, offset)) occurrence.setHours( masterStart.getHours(), masterStart.getMinutes(), masterStart.getSeconds(), 0, ) if (occurrence < masterStart) continue if (!push(occurrence)) return out } weekStart = addWeeks(weekStart, rule.interval) if (weekStart > rangeEnd && (!rule.count || produced > rule.count)) break if (weekStart > rangeEnd && !rule.count) break } return out } let occurrence = new Date(masterStart) for (let i = 0; i < 5000; i++) { if (!push(occurrence)) return out switch (rule.freq) { case "DAILY": occurrence = addDays(occurrence, rule.interval) break case "WEEKLY": occurrence = addWeeks(occurrence, rule.interval) break case "MONTHLY": occurrence = addMonths(occurrence, rule.interval) break case "YEARLY": occurrence = addYears(occurrence, rule.interval) break } if (occurrence > rangeEnd && !rule.count && !rule.until) break if (occurrence > rangeEnd && rule.until && rule.until <= rangeEnd) break } return out } export interface RecurrenceOption { value: string label: string } /** Options de récurrence proposées dans l'éditeur, dépendantes de la date. */ export function recurrenceOptionsFor(start: Date): RecurrenceOption[] { const weekday = format(start, "EEEE", { locale: fr }) const dayOfMonth = format(start, "d", { locale: fr }) const monthDay = format(start, "d MMMM", { locale: fr }) return [ { value: "", label: "Ne se répète pas" }, { value: "FREQ=DAILY", label: "Tous les jours" }, { value: `FREQ=WEEKLY;BYDAY=${INDEX_TO_BYDAY[start.getDay()]}`, label: `Toutes les semaines le ${weekday}`, }, { value: "FREQ=MONTHLY", label: `Tous les mois le ${dayOfMonth}` }, { value: "FREQ=YEARLY", label: `Tous les ans le ${monthDay}` }, { value: "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", label: "Tous les jours ouvrés (lun.–ven.)", }, ] } const FREQ_LABELS: Record = { DAILY: ["Tous les jours", "Tous les {n} jours"], WEEKLY: ["Toutes les semaines", "Toutes les {n} semaines"], MONTHLY: ["Tous les mois", "Tous les {n} mois"], YEARLY: ["Tous les ans", "Tous les {n} ans"], } const DAY_LABELS = ["dim.", "lun.", "mar.", "mer.", "jeu.", "ven.", "sam."] /** Libellé français d'une règle de récurrence arbitraire. */ export function describeRRule(rrule: string): string { const rule = parseRRule(rrule) if (!rule) return "Récurrence personnalisée" const [singular, plural] = FREQ_LABELS[rule.freq] let label = rule.interval === 1 ? singular : plural.replace("{n}", String(rule.interval)) if (rule.freq === "WEEKLY" && rule.byday.length > 0) { const sorted = [...rule.byday].sort((a, b) => ((a + 6) % 7) - ((b + 6) % 7)) if (sorted.length === 5 && sorted.every((d) => d >= 1 && d <= 5)) { label = "Tous les jours ouvrés (lun.–ven.)" } else { label += ` le ${sorted.map((d) => DAY_LABELS[d]).join(", ")}` } } if (rule.count) label += `, ${rule.count} fois` if (rule.until) { label += `, jusqu'au ${format(rule.until, "d MMM yyyy", { locale: fr })}` } return label }