933 lines
25 KiB
TypeScript
933 lines
25 KiB
TypeScript
import type {
|
|
CSSProperties,
|
|
DragEvent as ReactDragEvent,
|
|
} from "react";
|
|
|
|
import type {
|
|
CalendarEvent,
|
|
CalendarEventDeltaResponse,
|
|
} from "../../api/calendar";
|
|
|
|
export type CalendarMode =
|
|
| "continuous"
|
|
| "month"
|
|
| "week"
|
|
| "workweek"
|
|
| "day";
|
|
export type Range = { start: Date; end: Date };
|
|
export type AgendaGroup = {
|
|
key: string;
|
|
date: Date;
|
|
events: CalendarEvent[];
|
|
};
|
|
export type ContinuousViewport = { scrollTop: number; height: number };
|
|
export type CalendarDayDropTarget = { kind: "day"; key: string };
|
|
export type CalendarTimeDropTarget = {
|
|
kind: "time";
|
|
key: string;
|
|
minuteOfDay: number;
|
|
};
|
|
export type CalendarDropTarget =
|
|
| CalendarDayDropTarget
|
|
| CalendarTimeDropTarget
|
|
| null;
|
|
export type CalendarDragAction =
|
|
| { kind: "move"; event: CalendarEvent }
|
|
| { kind: "resize-start"; event: CalendarEvent }
|
|
| { kind: "resize-end"; event: CalendarEvent };
|
|
export type CalendarResizeEdge = "start" | "end";
|
|
export type CalendarViewPreferences = {
|
|
dimWeekends: boolean;
|
|
dimOffHours: boolean;
|
|
workdayStartHour: number;
|
|
workdayEndHour: number;
|
|
continuousVirtualization: boolean;
|
|
continuousOverscanWeeks: number;
|
|
alternateContinuousMonths: boolean;
|
|
};
|
|
|
|
export type TimedEventLayoutItem = TimedEventSegment & {
|
|
column: number;
|
|
columns: number;
|
|
};
|
|
export type TimedOverflowLayoutItem = {
|
|
key: string;
|
|
top: number;
|
|
column: number;
|
|
columns: number;
|
|
events: CalendarEvent[];
|
|
};
|
|
|
|
type TimedEventSegment = {
|
|
event: CalendarEvent;
|
|
start: Date;
|
|
end: Date;
|
|
top: number;
|
|
height: number;
|
|
};
|
|
|
|
export const HOUR_ROW_HEIGHT = 64;
|
|
export const INITIAL_SCROLL_HOUR = 7;
|
|
export const CONTINUOUS_WEEK_ROW_HEIGHT = 92;
|
|
export const DEFAULT_CALENDAR_COLOR = "#5aa99b";
|
|
const MAX_TIMED_EVENT_COLUMNS = 3;
|
|
const CALENDAR_MODE_STORAGE_KEY = "govoplan.calendar.mode";
|
|
const CALENDAR_VIEW_PREFERENCES_STORAGE_KEY =
|
|
"govoplan.calendar.viewPreferences";
|
|
export const DEFAULT_CALENDAR_VIEW_PREFERENCES: CalendarViewPreferences = {
|
|
dimWeekends: true,
|
|
dimOffHours: true,
|
|
workdayStartHour: 6,
|
|
workdayEndHour: 20,
|
|
continuousVirtualization: true,
|
|
continuousOverscanWeeks: 6,
|
|
alternateContinuousMonths: true,
|
|
};
|
|
|
|
export function calendarEventInstanceId(event: CalendarEvent): string {
|
|
return event.instance_id || event.id;
|
|
}
|
|
|
|
export function rangeForMode(
|
|
mode: CalendarMode,
|
|
focusDate: Date,
|
|
continuousWeeks: { before: number; after: number },
|
|
): Range {
|
|
if (mode === "month") {
|
|
const start = startOfWeek(startOfMonth(focusDate));
|
|
return { start, end: addDays(start, 42) };
|
|
}
|
|
if (mode === "day") {
|
|
return {
|
|
start: startOfDay(focusDate),
|
|
end: addDays(startOfDay(focusDate), 1),
|
|
};
|
|
}
|
|
if (mode === "continuous") {
|
|
const start = addDays(
|
|
startOfWeek(focusDate),
|
|
-continuousWeeks.before * 7,
|
|
);
|
|
const end = addDays(
|
|
startOfWeek(focusDate),
|
|
continuousWeeks.after * 7,
|
|
);
|
|
return { start, end };
|
|
}
|
|
const start = startOfWeek(focusDate);
|
|
return { start, end: addDays(start, mode === "workweek" ? 5 : 7) };
|
|
}
|
|
|
|
export function daysForMode(
|
|
mode: CalendarMode,
|
|
focusDate: Date,
|
|
continuousWeeks: { before: number; after: number },
|
|
): Date[] {
|
|
const range = rangeForMode(mode, focusDate, continuousWeeks);
|
|
return daysBetween(range.start, range.end);
|
|
}
|
|
|
|
export function continuousEventWindow(
|
|
days: Date[],
|
|
viewport: ContinuousViewport,
|
|
options: {overscanWeeks?: number;minimumWeeks?: number;stepWeeks?: number;} = {},
|
|
): Range {
|
|
if (days.length === 0) {
|
|
const now = startOfDay(new Date());
|
|
return { start: now, end: addDays(now, 7) };
|
|
}
|
|
const totalWeeks = Math.max(1, Math.ceil(days.length / 7));
|
|
const overscanWeeks = Math.max(0, Math.floor(options.overscanWeeks ?? 2));
|
|
const minimumWeeks = Math.max(1, Math.floor(options.minimumWeeks ?? 10));
|
|
const stepWeeks = Math.max(1, Math.floor(options.stepWeeks ?? 4));
|
|
const firstVisibleWeek = Math.max(
|
|
0,
|
|
Math.min(totalWeeks - 1, Math.floor(viewport.scrollTop / CONTINUOUS_WEEK_ROW_HEIGHT)),
|
|
);
|
|
const visibleWeeks = Math.max(
|
|
1,
|
|
Math.ceil(Math.max(viewport.height, CONTINUOUS_WEEK_ROW_HEIGHT) / CONTINUOUS_WEEK_ROW_HEIGHT),
|
|
);
|
|
const desiredWeeks = Math.min(
|
|
totalWeeks,
|
|
Math.max(minimumWeeks, visibleWeeks + overscanWeeks * 2),
|
|
);
|
|
let startWeek = Math.max(
|
|
0,
|
|
Math.floor(Math.max(0, firstVisibleWeek - overscanWeeks) / stepWeeks) * stepWeeks,
|
|
);
|
|
if (startWeek + desiredWeeks > totalWeeks) {
|
|
startWeek = Math.max(0, totalWeeks - desiredWeeks);
|
|
}
|
|
const endWeek = Math.min(totalWeeks, startWeek + desiredWeeks);
|
|
return {
|
|
start: days[startWeek * 7],
|
|
end: addDays(days[Math.min(days.length - 1, endWeek * 7 - 1)], 1),
|
|
};
|
|
}
|
|
|
|
export function groupEventsByDay(
|
|
events: CalendarEvent[],
|
|
): Map<string, CalendarEvent[]> {
|
|
const grouped = new Map<string, CalendarEvent[]>();
|
|
for (const event of events) {
|
|
const start = new Date(event.start_at);
|
|
const end = event.end_at ? new Date(event.end_at) : start;
|
|
let cursor = startOfDay(start);
|
|
let last =
|
|
event.all_day && event.end_at
|
|
? addDays(startOfDay(end), -1)
|
|
: startOfDay(end);
|
|
if (last < cursor) last = cursor;
|
|
while (cursor <= last) {
|
|
const key = dayKey(cursor);
|
|
grouped.set(key, [...(grouped.get(key) ?? []), event]);
|
|
cursor = addDays(cursor, 1);
|
|
}
|
|
}
|
|
for (const [key, items] of grouped.entries()) {
|
|
grouped.set(
|
|
key,
|
|
items.sort(
|
|
(left, right) =>
|
|
left.start_at.localeCompare(right.start_at) ||
|
|
left.summary.localeCompare(right.summary),
|
|
),
|
|
);
|
|
}
|
|
return grouped;
|
|
}
|
|
|
|
export function agendaGroupsForDays(
|
|
days: Date[],
|
|
eventsByDay: Map<string, CalendarEvent[]>,
|
|
): AgendaGroup[] {
|
|
const groups: AgendaGroup[] = [];
|
|
for (const day of days) {
|
|
const key = dayKey(day);
|
|
const events = (eventsByDay.get(key) ?? [])
|
|
.slice()
|
|
.sort(compareAgendaEvents);
|
|
if (events.length > 0) groups.push({ key, date: day, events });
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
export function eventTimeLabel(event: CalendarEvent): string {
|
|
if (event.all_day) return "i18n:govoplan-calendar.all_day.11457433";
|
|
const start = new Date(event.start_at);
|
|
const end = event.end_at ? new Date(event.end_at) : null;
|
|
return end ? `${timeLabel(start)}-${timeLabel(end)}` : timeLabel(start);
|
|
}
|
|
|
|
export function dateTimeLabel(value: Date): string {
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
dateStyle: "medium",
|
|
timeStyle: "short",
|
|
}).format(value);
|
|
}
|
|
|
|
export function headingForMode(
|
|
mode: CalendarMode,
|
|
focusDate: Date,
|
|
range: Range,
|
|
): string {
|
|
if (mode === "day") return dayMonthYearLabel(focusDate);
|
|
if (mode === "month") return monthYearLabel(focusDate);
|
|
return `${dayMonthYearLabel(range.start)} - ${dayMonthYearLabel(
|
|
addDays(range.end, -1),
|
|
)}`;
|
|
}
|
|
|
|
export function chunk<T>(items: T[], size: number): T[][] {
|
|
const rows: T[][] = [];
|
|
for (let index = 0; index < items.length; index += size) {
|
|
rows.push(items.slice(index, index + size));
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
export function startOfDay(value: Date): Date {
|
|
return new Date(value.getFullYear(), value.getMonth(), value.getDate());
|
|
}
|
|
|
|
export function startOfWeek(value: Date): Date {
|
|
const day = value.getDay() || 7;
|
|
return addDays(startOfDay(value), 1 - day);
|
|
}
|
|
|
|
export function startOfMonth(value: Date): Date {
|
|
return new Date(value.getFullYear(), value.getMonth(), 1);
|
|
}
|
|
|
|
export function addDays(value: Date, days: number): Date {
|
|
const next = new Date(value);
|
|
next.setDate(next.getDate() + days);
|
|
return next;
|
|
}
|
|
|
|
export function addMonths(value: Date, months: number): Date {
|
|
const next = new Date(value);
|
|
next.setMonth(next.getMonth() + months);
|
|
return next;
|
|
}
|
|
|
|
export function addHours(value: Date, hours: number): Date {
|
|
const next = new Date(value);
|
|
next.setHours(next.getHours() + hours);
|
|
return next;
|
|
}
|
|
|
|
export function addMinutes(value: Date, minutes: number): Date {
|
|
const next = new Date(value);
|
|
next.setMinutes(next.getMinutes() + minutes);
|
|
return next;
|
|
}
|
|
|
|
export function daysBetween(start: Date, end: Date): Date[] {
|
|
const days: Date[] = [];
|
|
for (
|
|
let cursor = startOfDay(start);
|
|
cursor < end;
|
|
cursor = addDays(cursor, 1)
|
|
) {
|
|
days.push(cursor);
|
|
}
|
|
return days;
|
|
}
|
|
|
|
export function dayKey(value: Date): string {
|
|
return toInputDate(value);
|
|
}
|
|
|
|
export function sameDay(left: Date, right: Date): boolean {
|
|
return (
|
|
left.getFullYear() === right.getFullYear() &&
|
|
left.getMonth() === right.getMonth() &&
|
|
left.getDate() === right.getDate()
|
|
);
|
|
}
|
|
|
|
export function sameMonth(left: Date, right: Date): boolean {
|
|
return (
|
|
left.getFullYear() === right.getFullYear() &&
|
|
left.getMonth() === right.getMonth()
|
|
);
|
|
}
|
|
|
|
export function toInputDate(value: Date): string {
|
|
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(
|
|
2,
|
|
"0",
|
|
)}-${String(value.getDate()).padStart(2, "0")}`;
|
|
}
|
|
|
|
export function toInputTime(value: Date): string {
|
|
return `${String(value.getHours()).padStart(2, "0")}:${String(
|
|
value.getMinutes(),
|
|
).padStart(2, "0")}`;
|
|
}
|
|
|
|
export function localDateTime(date: string, time: string): Date {
|
|
return new Date(`${date}T${time || "00:00"}:00`);
|
|
}
|
|
|
|
export function loadCalendarViewPreferences(): CalendarViewPreferences {
|
|
if (typeof window === "undefined") return DEFAULT_CALENDAR_VIEW_PREFERENCES;
|
|
try {
|
|
const raw = window.localStorage.getItem(
|
|
CALENDAR_VIEW_PREFERENCES_STORAGE_KEY,
|
|
);
|
|
if (!raw) return DEFAULT_CALENDAR_VIEW_PREFERENCES;
|
|
const parsed = JSON.parse(raw) as Partial<CalendarViewPreferences>;
|
|
let workdayStartHour = clampNumber(
|
|
parsed.workdayStartHour,
|
|
0,
|
|
23,
|
|
DEFAULT_CALENDAR_VIEW_PREFERENCES.workdayStartHour,
|
|
);
|
|
let workdayEndHour = clampNumber(
|
|
parsed.workdayEndHour,
|
|
1,
|
|
24,
|
|
DEFAULT_CALENDAR_VIEW_PREFERENCES.workdayEndHour,
|
|
);
|
|
if (workdayEndHour <= workdayStartHour) {
|
|
workdayStartHour =
|
|
DEFAULT_CALENDAR_VIEW_PREFERENCES.workdayStartHour;
|
|
workdayEndHour = DEFAULT_CALENDAR_VIEW_PREFERENCES.workdayEndHour;
|
|
}
|
|
return {
|
|
dimWeekends:
|
|
typeof parsed.dimWeekends === "boolean"
|
|
? parsed.dimWeekends
|
|
: DEFAULT_CALENDAR_VIEW_PREFERENCES.dimWeekends,
|
|
dimOffHours:
|
|
typeof parsed.dimOffHours === "boolean"
|
|
? parsed.dimOffHours
|
|
: DEFAULT_CALENDAR_VIEW_PREFERENCES.dimOffHours,
|
|
workdayStartHour,
|
|
workdayEndHour,
|
|
continuousVirtualization:
|
|
typeof parsed.continuousVirtualization === "boolean"
|
|
? parsed.continuousVirtualization
|
|
: DEFAULT_CALENDAR_VIEW_PREFERENCES.continuousVirtualization,
|
|
continuousOverscanWeeks: clampNumber(
|
|
parsed.continuousOverscanWeeks,
|
|
2,
|
|
20,
|
|
DEFAULT_CALENDAR_VIEW_PREFERENCES.continuousOverscanWeeks,
|
|
),
|
|
alternateContinuousMonths:
|
|
typeof parsed.alternateContinuousMonths === "boolean"
|
|
? parsed.alternateContinuousMonths
|
|
: DEFAULT_CALENDAR_VIEW_PREFERENCES.alternateContinuousMonths,
|
|
};
|
|
} catch {
|
|
return DEFAULT_CALENDAR_VIEW_PREFERENCES;
|
|
}
|
|
}
|
|
|
|
export function loadCalendarMode(): CalendarMode {
|
|
if (typeof window === "undefined") return "continuous";
|
|
try {
|
|
const storedMode = window.localStorage.getItem(
|
|
CALENDAR_MODE_STORAGE_KEY,
|
|
);
|
|
return isCalendarMode(storedMode) ? storedMode : "continuous";
|
|
} catch {
|
|
return "continuous";
|
|
}
|
|
}
|
|
|
|
export function saveCalendarMode(mode: CalendarMode): void {
|
|
if (typeof window === "undefined") return;
|
|
try {
|
|
window.localStorage.setItem(CALENDAR_MODE_STORAGE_KEY, mode);
|
|
} catch {
|
|
// Storage can be unavailable in locked-down browsers.
|
|
}
|
|
}
|
|
|
|
export function normalizeHexColor(
|
|
value: string | null | undefined,
|
|
): string | null {
|
|
if (!value) return null;
|
|
const trimmed = value.trim();
|
|
if (/^#[0-9a-fA-F]{8}$/.test(trimmed)) {
|
|
return trimmed.slice(0, 7).toLowerCase();
|
|
}
|
|
return /^#[0-9a-fA-F]{6}$/.test(trimmed)
|
|
? trimmed.toLowerCase()
|
|
: null;
|
|
}
|
|
|
|
export function calendarEventColorStyle(
|
|
color: string | null | undefined,
|
|
): CSSProperties {
|
|
const normalizedColor =
|
|
normalizeHexColor(color) || DEFAULT_CALENDAR_COLOR;
|
|
return {
|
|
"--calendar-event-color": normalizedColor,
|
|
"--calendar-event-bg": mixHexWithWhite(normalizedColor, 0.88),
|
|
"--calendar-event-border": mixHexWithWhite(normalizedColor, 0.58),
|
|
} as CSSProperties;
|
|
}
|
|
|
|
export function continuousVirtualWindow(
|
|
weekCount: number,
|
|
viewport: ContinuousViewport,
|
|
overscanWeeks: number,
|
|
): {
|
|
start: number;
|
|
end: number;
|
|
topSpacerHeight: number;
|
|
bottomSpacerHeight: number;
|
|
} {
|
|
if (weekCount === 0) {
|
|
return {
|
|
start: 0,
|
|
end: 0,
|
|
topSpacerHeight: 0,
|
|
bottomSpacerHeight: 0,
|
|
};
|
|
}
|
|
const visibleStart = Math.floor(
|
|
viewport.scrollTop / CONTINUOUS_WEEK_ROW_HEIGHT,
|
|
);
|
|
const visibleCount = Math.max(
|
|
1,
|
|
Math.ceil(
|
|
(viewport.height || CONTINUOUS_WEEK_ROW_HEIGHT * 8) /
|
|
CONTINUOUS_WEEK_ROW_HEIGHT,
|
|
),
|
|
);
|
|
const start = Math.max(0, visibleStart - overscanWeeks);
|
|
const end = Math.min(
|
|
weekCount,
|
|
visibleStart + visibleCount + overscanWeeks,
|
|
);
|
|
return {
|
|
start,
|
|
end,
|
|
topSpacerHeight: start * CONTINUOUS_WEEK_ROW_HEIGHT,
|
|
bottomSpacerHeight: Math.max(
|
|
0,
|
|
(weekCount - end) * CONTINUOUS_WEEK_ROW_HEIGHT,
|
|
),
|
|
};
|
|
}
|
|
|
|
export function timeGridStyle(
|
|
columns: string,
|
|
preferences: CalendarViewPreferences,
|
|
): CSSProperties {
|
|
return {
|
|
gridTemplateColumns: columns,
|
|
"--calendar-work-start-y": `${
|
|
preferences.workdayStartHour * HOUR_ROW_HEIGHT
|
|
}px`,
|
|
"--calendar-work-end-y": `${
|
|
preferences.workdayEndHour * HOUR_ROW_HEIGHT
|
|
}px`,
|
|
"--calendar-off-hours-opacity": preferences.dimOffHours ? "1" : "0",
|
|
} as CSSProperties;
|
|
}
|
|
|
|
export function moveEventToDay(
|
|
event: CalendarEvent,
|
|
day: Date,
|
|
): { startAt: Date; endAt: Date | null; allDay: boolean } {
|
|
const start = new Date(event.start_at);
|
|
const end = event.end_at ? new Date(event.end_at) : null;
|
|
if (event.all_day) {
|
|
const durationDays = Math.max(
|
|
1,
|
|
Math.round(
|
|
daysBetweenCount(
|
|
startOfDay(start),
|
|
end ? startOfDay(end) : addDays(startOfDay(start), 1),
|
|
),
|
|
),
|
|
);
|
|
const startAt = startOfDay(day);
|
|
return {
|
|
startAt,
|
|
endAt: addDays(startAt, durationDays),
|
|
allDay: true,
|
|
};
|
|
}
|
|
const startAt = new Date(
|
|
day.getFullYear(),
|
|
day.getMonth(),
|
|
day.getDate(),
|
|
start.getHours(),
|
|
start.getMinutes(),
|
|
);
|
|
const durationMs = eventDurationMs(event, 60 * 60 * 1000);
|
|
return {
|
|
startAt,
|
|
endAt: new Date(startAt.getTime() + durationMs),
|
|
allDay: false,
|
|
};
|
|
}
|
|
|
|
export function moveEventToTime(
|
|
event: CalendarEvent,
|
|
day: Date,
|
|
minuteOfDay: number,
|
|
): { startAt: Date; endAt: Date | null; allDay: boolean } {
|
|
const snappedMinute = Math.min(
|
|
23 * 60 + 45,
|
|
Math.max(0, snapMinute(minuteOfDay)),
|
|
);
|
|
const startAt = dateAtMinuteOfDay(day, snappedMinute);
|
|
const durationMs = event.all_day
|
|
? 60 * 60 * 1000
|
|
: eventDurationMs(event, 60 * 60 * 1000);
|
|
return {
|
|
startAt,
|
|
endAt: new Date(startAt.getTime() + durationMs),
|
|
allDay: false,
|
|
};
|
|
}
|
|
|
|
export function resizeEventToTime(
|
|
event: CalendarEvent,
|
|
edge: CalendarResizeEdge,
|
|
day: Date,
|
|
minuteOfDay: number,
|
|
): { startAt: Date; endAt: Date | null; allDay: boolean } {
|
|
const target = dateAtMinuteOfDay(
|
|
day,
|
|
Math.min(23 * 60 + 45, Math.max(0, snapMinute(minuteOfDay))),
|
|
);
|
|
const currentStart = new Date(event.start_at);
|
|
const fallbackEnd = addMinutes(currentStart, 30);
|
|
const currentEnd =
|
|
event.end_at && new Date(event.end_at) > currentStart
|
|
? new Date(event.end_at)
|
|
: fallbackEnd;
|
|
const minimumDurationMs = 15 * 60_000;
|
|
if (edge === "start") {
|
|
const latestStart = new Date(
|
|
currentEnd.getTime() - minimumDurationMs,
|
|
);
|
|
return {
|
|
startAt: target < latestStart ? target : latestStart,
|
|
endAt: currentEnd,
|
|
allDay: false,
|
|
};
|
|
}
|
|
const earliestEnd = new Date(
|
|
currentStart.getTime() + minimumDurationMs,
|
|
);
|
|
return {
|
|
startAt: currentStart,
|
|
endAt: target > earliestEnd ? target : earliestEnd,
|
|
allDay: false,
|
|
};
|
|
}
|
|
|
|
export function dropMinuteOfDay(
|
|
event: ReactDragEvent<HTMLElement>,
|
|
): number {
|
|
const rect = event.currentTarget.getBoundingClientRect();
|
|
const offset = Math.min(
|
|
Math.max(event.clientY - rect.top, 0),
|
|
HOUR_ROW_HEIGHT * 24,
|
|
);
|
|
return Math.floor((offset / HOUR_ROW_HEIGHT) * 60);
|
|
}
|
|
|
|
export function dropSlotMinuteOfDay(
|
|
event: ReactDragEvent<HTMLElement>,
|
|
): number {
|
|
return Math.min(
|
|
23 * 60 + 45,
|
|
Math.max(0, snapMinute(dropMinuteOfDay(event))),
|
|
);
|
|
}
|
|
|
|
export function minuteOfDayLabel(value: number): string {
|
|
const minute = Math.min(23 * 60 + 45, Math.max(0, value));
|
|
return `${String(Math.floor(minute / 60)).padStart(2, "0")}:${String(
|
|
minute % 60,
|
|
).padStart(2, "0")}`;
|
|
}
|
|
|
|
export function isWeekend(value: Date): boolean {
|
|
const day = value.getDay();
|
|
return day === 0 || day === 6;
|
|
}
|
|
|
|
export function daysBetweenCount(start: Date, end: Date): number {
|
|
return Math.round(
|
|
(startOfDay(end).getTime() - startOfDay(start).getTime()) / 86_400_000,
|
|
);
|
|
}
|
|
|
|
export function addMinutesToTime(
|
|
time: string,
|
|
minutes: number,
|
|
): string {
|
|
const [hourPart, minutePart] = time.split(":");
|
|
const total = Math.min(
|
|
23 * 60 + 59,
|
|
Math.max(
|
|
0,
|
|
Number(hourPart || 0) * 60 + Number(minutePart || 0) + minutes,
|
|
),
|
|
);
|
|
return `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(
|
|
total % 60,
|
|
).padStart(2, "0")}`;
|
|
}
|
|
|
|
export function layoutTimedEventsForDay(
|
|
events: CalendarEvent[],
|
|
day: Date,
|
|
): {
|
|
items: TimedEventLayoutItem[];
|
|
overflows: TimedOverflowLayoutItem[];
|
|
} {
|
|
const segments = timedSegmentsForDay(events, day);
|
|
const clusters = clusterTimedSegments(segments);
|
|
const items: TimedEventLayoutItem[] = [];
|
|
const overflows: TimedOverflowLayoutItem[] = [];
|
|
|
|
for (const cluster of clusters) {
|
|
const columnEnds: Date[] = [];
|
|
const assigned = cluster.map((segment) => {
|
|
let column = columnEnds.findIndex((end) => end <= segment.start);
|
|
if (column === -1) column = columnEnds.length;
|
|
columnEnds[column] = segment.end;
|
|
return { ...segment, column };
|
|
});
|
|
const totalColumns = Math.max(1, columnEnds.length);
|
|
const visibleColumns =
|
|
totalColumns > MAX_TIMED_EVENT_COLUMNS
|
|
? MAX_TIMED_EVENT_COLUMNS
|
|
: totalColumns;
|
|
const overflowColumn = MAX_TIMED_EVENT_COLUMNS - 1;
|
|
const hidden =
|
|
totalColumns > MAX_TIMED_EVENT_COLUMNS
|
|
? assigned.filter((item) => item.column >= overflowColumn)
|
|
: [];
|
|
|
|
for (const item of assigned) {
|
|
if (hidden.includes(item)) continue;
|
|
items.push({ ...item, columns: visibleColumns });
|
|
}
|
|
if (hidden.length > 0) {
|
|
overflows.push({
|
|
key: `${dayKey(day)}-${calendarEventInstanceId(hidden[0].event)}-overflow`,
|
|
top: Math.min(...hidden.map((item) => item.top)),
|
|
column: overflowColumn,
|
|
columns: MAX_TIMED_EVENT_COLUMNS,
|
|
events: hidden.map((item) => item.event),
|
|
});
|
|
}
|
|
}
|
|
|
|
return { items, overflows };
|
|
}
|
|
|
|
export function timedEventStyle(
|
|
item: TimedEventLayoutItem,
|
|
color?: string | null,
|
|
): CSSProperties {
|
|
return {
|
|
top: `${item.top}px`,
|
|
height: `${item.height}px`,
|
|
left: `calc(${(item.column * 100) / item.columns}% + 4px)`,
|
|
width: `calc(${100 / item.columns}% - 8px)`,
|
|
...calendarEventColorStyle(color),
|
|
};
|
|
}
|
|
|
|
export function timedOverflowStyle(
|
|
item: TimedOverflowLayoutItem,
|
|
): CSSProperties {
|
|
return {
|
|
top: `${item.top}px`,
|
|
left: `calc(${(item.column * 100) / item.columns}% + 4px)`,
|
|
width: `calc(${100 / item.columns}% - 8px)`,
|
|
};
|
|
}
|
|
|
|
export function monthYearLabel(value: Date): string {
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
month: "long",
|
|
year: "numeric",
|
|
}).format(value);
|
|
}
|
|
|
|
export function dayMonthLabel(value: Date): string {
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
day: "2-digit",
|
|
month: "short",
|
|
}).format(value);
|
|
}
|
|
|
|
export function dayMonthYearLabel(value: Date): string {
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
day: "2-digit",
|
|
month: "short",
|
|
year: "numeric",
|
|
}).format(value);
|
|
}
|
|
|
|
export function agendaDateLabel(value: Date): string {
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
weekday: "short",
|
|
day: "2-digit",
|
|
month: "short",
|
|
year: "numeric",
|
|
}).format(value);
|
|
}
|
|
|
|
export function weekdayLong(value: Date): string {
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
weekday: "short",
|
|
}).format(value);
|
|
}
|
|
|
|
export function calendarDraftKey(value: unknown): string {
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
export function mergeCalendarEventDelta(
|
|
current: CalendarEvent[],
|
|
response: CalendarEventDeltaResponse,
|
|
): CalendarEvent[] {
|
|
if (response.full) {
|
|
return response.events.slice().sort(compareCalendarEvents);
|
|
}
|
|
const rows = new Map(current.map((event) => [event.id, event]));
|
|
for (const deleted of response.deleted) {
|
|
if (
|
|
!deleted.resource_type ||
|
|
deleted.resource_type === "calendar_event"
|
|
) {
|
|
rows.delete(deleted.id);
|
|
}
|
|
}
|
|
for (const event of response.events) rows.set(event.id, event);
|
|
return Array.from(rows.values()).sort(compareCalendarEvents);
|
|
}
|
|
|
|
export function errorText(error: unknown): string {
|
|
if (error instanceof Error) return error.message;
|
|
return "i18n:govoplan-calendar.calendar_request_failed.ae8a54f4";
|
|
}
|
|
|
|
function isCalendarMode(value: unknown): value is CalendarMode {
|
|
return (
|
|
value === "continuous" ||
|
|
value === "month" ||
|
|
value === "week" ||
|
|
value === "workweek" ||
|
|
value === "day"
|
|
);
|
|
}
|
|
|
|
function mixHexWithWhite(hex: string, whiteRatio: number): string {
|
|
const normalized = normalizeHexColor(hex) || DEFAULT_CALENDAR_COLOR;
|
|
const ratio = Math.min(1, Math.max(0, whiteRatio));
|
|
const red = Number.parseInt(normalized.slice(1, 3), 16);
|
|
const green = Number.parseInt(normalized.slice(3, 5), 16);
|
|
const blue = Number.parseInt(normalized.slice(5, 7), 16);
|
|
return `rgb(${Math.round(red * (1 - ratio) + 255 * ratio)}, ${Math.round(
|
|
green * (1 - ratio) + 255 * ratio,
|
|
)}, ${Math.round(blue * (1 - ratio) + 255 * ratio)})`;
|
|
}
|
|
|
|
function eventDurationMs(event: CalendarEvent, fallback: number): number {
|
|
if (!event.end_at) return fallback;
|
|
const start = new Date(event.start_at);
|
|
const end = new Date(event.end_at);
|
|
return Math.max(30 * 60 * 1000, end.getTime() - start.getTime());
|
|
}
|
|
|
|
function dateAtMinuteOfDay(day: Date, minuteOfDay: number): Date {
|
|
return new Date(
|
|
day.getFullYear(),
|
|
day.getMonth(),
|
|
day.getDate(),
|
|
Math.floor(minuteOfDay / 60),
|
|
minuteOfDay % 60,
|
|
);
|
|
}
|
|
|
|
function snapMinute(value: number): number {
|
|
return Math.round(value / 15) * 15;
|
|
}
|
|
|
|
function clampNumber(
|
|
value: unknown,
|
|
min: number,
|
|
max: number,
|
|
fallback: number,
|
|
): number {
|
|
if (typeof value !== "number" || Number.isNaN(value)) return fallback;
|
|
return Math.min(max, Math.max(min, Math.round(value)));
|
|
}
|
|
|
|
function timedSegmentsForDay(
|
|
events: CalendarEvent[],
|
|
day: Date,
|
|
): TimedEventSegment[] {
|
|
const dayStart = startOfDay(day);
|
|
const dayEnd = addDays(dayStart, 1);
|
|
return events
|
|
.filter((event) => !event.all_day)
|
|
.map((event): TimedEventSegment | null => {
|
|
const eventStart = new Date(event.start_at);
|
|
const rawEventEnd = event.end_at
|
|
? new Date(event.end_at)
|
|
: addMinutes(eventStart, 30);
|
|
const eventEnd =
|
|
rawEventEnd > eventStart ? rawEventEnd : addMinutes(eventStart, 30);
|
|
const start = eventStart < dayStart ? dayStart : eventStart;
|
|
const end = eventEnd > dayEnd ? dayEnd : eventEnd;
|
|
if (end <= start) return null;
|
|
const startMinutes = minutesBetween(dayStart, start);
|
|
const durationMinutes = minutesBetween(start, end);
|
|
return {
|
|
event,
|
|
start,
|
|
end,
|
|
top: Math.max(0, (startMinutes / 60) * HOUR_ROW_HEIGHT),
|
|
height: Math.max(
|
|
26,
|
|
(durationMinutes / 60) * HOUR_ROW_HEIGHT,
|
|
),
|
|
};
|
|
})
|
|
.filter(
|
|
(segment): segment is TimedEventSegment => segment !== null,
|
|
)
|
|
.sort(
|
|
(left, right) =>
|
|
left.start.getTime() - right.start.getTime() ||
|
|
right.end.getTime() - left.end.getTime(),
|
|
);
|
|
}
|
|
|
|
function clusterTimedSegments(
|
|
segments: TimedEventSegment[],
|
|
): TimedEventSegment[][] {
|
|
const clusters: TimedEventSegment[][] = [];
|
|
let current: TimedEventSegment[] = [];
|
|
let currentEnd: Date | null = null;
|
|
for (const segment of segments) {
|
|
if (!current.length || (currentEnd && segment.start < currentEnd)) {
|
|
current.push(segment);
|
|
if (!currentEnd || segment.end.getTime() > currentEnd.getTime()) {
|
|
currentEnd = segment.end;
|
|
}
|
|
continue;
|
|
}
|
|
clusters.push(current);
|
|
current = [segment];
|
|
currentEnd = segment.end;
|
|
}
|
|
if (current.length) clusters.push(current);
|
|
return clusters;
|
|
}
|
|
|
|
function minutesBetween(start: Date, end: Date): number {
|
|
return Math.max(0, (end.getTime() - start.getTime()) / 60_000);
|
|
}
|
|
|
|
function compareAgendaEvents(
|
|
left: CalendarEvent,
|
|
right: CalendarEvent,
|
|
): number {
|
|
if (left.all_day !== right.all_day) return left.all_day ? -1 : 1;
|
|
return (
|
|
left.start_at.localeCompare(right.start_at) ||
|
|
left.summary.localeCompare(right.summary)
|
|
);
|
|
}
|
|
|
|
function timeLabel(value: Date): string {
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
}).format(value);
|
|
}
|
|
|
|
function compareCalendarEvents(
|
|
left: CalendarEvent,
|
|
right: CalendarEvent,
|
|
): number {
|
|
return (
|
|
new Date(left.start_at).getTime() -
|
|
new Date(right.start_at).getTime() ||
|
|
left.summary.localeCompare(right.summary) ||
|
|
left.id.localeCompare(right.id)
|
|
);
|
|
}
|