183 lines
5.9 KiB
TypeScript
183 lines
5.9 KiB
TypeScript
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
|
||
}
|