import { addDays, endOfDay, endOfMonth, endOfWeek, format, isSameMonth, isSameYear, parse, startOfDay, startOfMonth, startOfWeek, } from "date-fns" import { fr } from "date-fns/locale" import type { AgendaView } from "./agenda-url.ts" export const WEEK_OPTS = { weekStartsOn: 1 as const, locale: fr } /** Parse une valeur de date ICS (`YYYYMMDD` ou `YYYYMMDDTHHMMSS[Z]`). */ export function parseICSDate(value: string): Date | null { const v = value.trim() if (/^\d{8}$/.test(v)) { const y = Number(v.slice(0, 4)) const m = Number(v.slice(4, 6)) const d = Number(v.slice(6, 8)) return new Date(y, m - 1, d) } const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/.exec(v) if (!match) { const fallback = new Date(v) return Number.isNaN(fallback.getTime()) ? null : fallback } const [, y, mo, d, h, mi, s, z] = match if (z === "Z") { return new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +s)) } return new Date(+y, +mo - 1, +d, +h, +mi, +s) } /** Format ICS UTC : `YYYYMMDDTHHMMSSZ`. */ export function formatICSDateTimeUTC(date: Date): string { const p = (n: number, w = 2) => String(n).padStart(w, "0") return ( `${p(date.getUTCFullYear(), 4)}${p(date.getUTCMonth() + 1)}${p(date.getUTCDate())}` + `T${p(date.getUTCHours())}${p(date.getUTCMinutes())}${p(date.getUTCSeconds())}Z` ) } /** Format ICS date locale (journée entière) : `YYYYMMDD`. */ export function formatICSDateOnly(date: Date): string { return format(date, "yyyyMMdd") } export function dateKey(date: Date): string { return format(date, "yyyy-MM-dd") } export function parseDateKey(key: string): Date | null { const parsed = parse(key, "yyyy-MM-dd", new Date()) return Number.isNaN(parsed.getTime()) ? null : parsed } /** Bornes affichées pour une vue (le mois inclut les semaines débordantes). */ export function viewRange(view: AgendaView, date: Date): { start: Date; end: Date } { switch (view) { case "day": return { start: startOfDay(date), end: endOfDay(date) } case "week": return { start: startOfWeek(date, WEEK_OPTS), end: endOfWeek(date, WEEK_OPTS), } case "month": return { start: startOfWeek(startOfMonth(date), WEEK_OPTS), end: endOfWeek(endOfMonth(date), WEEK_OPTS), } } } export function viewDays(view: AgendaView, date: Date): Date[] { const { start, end } = viewRange(view, date) const days: Date[] = [] for (let d = start; d <= end; d = addDays(d, 1)) days.push(d) return days } function capitalize(value: string): string { return value.charAt(0).toUpperCase() + value.slice(1) } /** Titre du header façon Google Calendar. */ export function viewTitle(view: AgendaView, date: Date): string { if (view === "day") { return capitalize(format(date, "EEEE d MMMM yyyy", { locale: fr })) } if (view === "month") { return capitalize(format(date, "MMMM yyyy", { locale: fr })) } const { start, end } = viewRange("week", date) if (isSameMonth(start, end)) { return capitalize(format(start, "MMMM yyyy", { locale: fr })) } if (isSameYear(start, end)) { return `${capitalize(format(start, "MMM", { locale: fr }))} – ${format(end, "MMM yyyy", { locale: fr })}` } return `${capitalize(format(start, "MMM yyyy", { locale: fr }))} – ${format(end, "MMM yyyy", { locale: fr })}` } export function formatEventTime(date: Date): string { return format(date, "HH:mm") } /** Plage horaire lisible pour le popover de détails. */ export function formatEventRange(start: Date, end: Date, allDay: boolean): string { const sameDay = dateKey(start) === dateKey(end) const dayLabel = (d: Date) => capitalize(format(d, "EEEE d MMMM", { locale: fr })) if (allDay) { const endIncl = addDays(end, -1) if (dateKey(start) === dateKey(endIncl) || end <= start) return dayLabel(start) return `${dayLabel(start)} – ${dayLabel(endIncl)}` } if (sameDay) { return `${dayLabel(start)} ⋅ ${formatEventTime(start)} – ${formatEventTime(end)}` } return `${dayLabel(start)} ${formatEventTime(start)} – ${dayLabel(end)} ${formatEventTime(end)}` } export function minutesSinceMidnight(date: Date): number { return date.getHours() * 60 + date.getMinutes() } export function roundToStep(minutes: number, step = 15): number { return Math.round(minutes / step) * step }