ultisuite-client/lib/agenda/agenda-date.ts
R3D347HR4Y ad1370ea7e
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat: enhance configuration and add new demo layouts
- Introduced turbopack alias for canvas in next.config.mjs.
- Updated package.json scripts for development and branding tasks.
- Added new dependencies for Tiptap extensions.
- Implemented new demo layouts for agenda, contacts, drive, and mail applications.
- Enhanced globals.css for improved theming and splash screen animations.
- Added OAuth callback handling for drive mounts.
- Updated layout components to integrate new demo shells and improve structure.
2026-06-12 19:10:24 +02:00

239 lines
7.7 KiB
TypeScript
Raw Permalink 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,
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
}