ultisuite-client/lib/agenda/agenda-recurrence.ts
R3D347HR4Y 3bbf3691b0
Some checks failed
E2E / Playwright e2e (push) Has been cancelled
bordel c'est beau
2026-06-11 10:10:39 +02:00

183 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, number> = {
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<string, string> = {}
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<number>,
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<RRuleFreq, [string, string]> = {
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
}