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" import type { AgendaWeekStart } from "./agenda-settings-types.ts" export const WEEK_OPTS = { weekStartsOn: 1 as const, locale: fr } export type WeekStartsOn = 0 | 1 | 2 | 3 | 4 | 5 | 6 /** Valeur stable SSR / premier rendu client quand weekStart = auto. */ export const DEFAULT_WEEK_STARTS_ON: WeekStartsOn = 1 /** Résout le premier jour de semaine (auto = locale navigateur, sinon lundi). */ export function resolveWeekStartsOn(weekStart: AgendaWeekStart = "auto"): WeekStartsOn { if (weekStart !== "auto") return weekStart if (typeof navigator === "undefined") return DEFAULT_WEEK_STARTS_ON try { const locale = new Intl.Locale(navigator.language) const info = (locale as Intl.Locale & { weekInfo?: { firstDay?: number } }).weekInfo const firstDay = info?.firstDay if (firstDay === 7) return 0 if (firstDay != null && firstDay >= 0 && firstDay <= 6) return firstDay as WeekStartsOn } catch { /* locale non supportée */ } return navigator.language.toLowerCase().startsWith("en-us") ? 0 : 1 } export function getWeekOptionsFor(startsOn: WeekStartsOn) { return { weekStartsOn: startsOn, locale: fr } } export function getWeekOptions(weekStart: AgendaWeekStart = "auto") { return getWeekOptionsFor(resolveWeekStartsOn(weekStart)) } export const WEEKDAY_LABELS_MON_FIRST = ["L", "M", "M", "J", "V", "S", "D"] as const export const WEEKDAY_LABELS_SUN_FIRST = ["D", "L", "M", "M", "J", "V", "S"] as const export function weekdayLabelsFor(startsOn: WeekStartsOn): readonly string[] { return startsOn === 0 ? WEEKDAY_LABELS_SUN_FIRST : WEEKDAY_LABELS_MON_FIRST } export function weekdayLabels(weekStart: AgendaWeekStart = "auto"): readonly string[] { return weekdayLabelsFor(resolveWeekStartsOn(weekStart)) } /** 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 } function weekOptionsFor( weekStart: AgendaWeekStart, resolvedStartsOn?: WeekStartsOn, ) { return resolvedStartsOn != null ? getWeekOptionsFor(resolvedStartsOn) : getWeekOptions(weekStart) } /** Bornes affichées pour une vue (le mois inclut les semaines débordantes). */ export function viewRange( view: AgendaView, date: Date, weekStart: AgendaWeekStart = "auto", resolvedStartsOn?: WeekStartsOn, ): { start: Date; end: Date } { const opts = weekOptionsFor(weekStart, resolvedStartsOn) switch (view) { case "day": return { start: startOfDay(date), end: endOfDay(date) } case "week": return { start: startOfWeek(date, opts), end: endOfWeek(date, opts), } case "month": return { start: startOfWeek(startOfMonth(date), opts), end: endOfWeek(endOfMonth(date), opts), } } } export function viewDays( view: AgendaView, date: Date, weekStart: AgendaWeekStart = "auto", resolvedStartsOn?: WeekStartsOn, ): Date[] { const { start, end } = viewRange(view, date, weekStart, resolvedStartsOn) 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, weekStart: AgendaWeekStart = "auto", resolvedStartsOn?: WeekStartsOn, ): 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, weekStart, resolvedStartsOn) 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, timeFormat: "24h" | "12h" = "24h", ): string { return format(date, timeFormat === "12h" ? "h:mm a" : "HH:mm", { locale: fr }) } export function formatHourLabel(hour: number, timeFormat: "24h" | "12h" = "24h"): string { const d = new Date(2000, 0, 1, hour, 0, 0) return formatEventTime(d, timeFormat) } export function formatDurationMinutes(totalMinutes: number): string { const hours = Math.floor(totalMinutes / 60) const minutes = totalMinutes % 60 if (hours === 0) return `${minutes} min` if (minutes === 0) return hours === 1 ? "1 h" : `${hours} h` return `${hours} h ${minutes}` } /** Libellé durée pour chips +/- agenda : largeur stable (heures toujours avec minutes). */ export function formatAgendaStepDurationMinutes(totalMinutes: number): string { const hours = Math.floor(totalMinutes / 60) const minutes = totalMinutes % 60 if (hours === 0) return `${minutes} min` return `${hours} h ${String(minutes).padStart(2, "0")}` } export function minutesToTimeInput(date: Date): string { return format(date, "HH:mm") } export function snapMinutes(value: number, step: number): number { return Math.round(value / step) * step } /** Plage horaire lisible pour le popover de détails. */ export function formatEventRange( start: Date, end: Date, allDay: boolean, timeFormat: "24h" | "12h" = "24h", ): 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, timeFormat)} – ${formatEventTime(end, timeFormat)}` } return `${dayLabel(start)} ${formatEventTime(start, timeFormat)} – ${dayLabel(end)} ${formatEventTime(end, timeFormat)}` } 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 }