Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- 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.
239 lines
7.7 KiB
TypeScript
239 lines
7.7 KiB
TypeScript
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
|
||
}
|