2009 lines
79 KiB
TypeScript
2009 lines
79 KiB
TypeScript
import {
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type CSSProperties,
|
|
type DragEvent as ReactDragEvent,
|
|
type FormEvent,
|
|
type WheelEvent as ReactWheelEvent
|
|
} from "react";
|
|
import { CalendarDays, ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
|
import {
|
|
Button,
|
|
Dialog,
|
|
DismissibleAlert,
|
|
LoadingIndicator,
|
|
ToggleSwitch,
|
|
hasScope,
|
|
type ApiSettings,
|
|
type AuthInfo
|
|
} from "@govoplan/core-webui";
|
|
import {
|
|
createCalendar,
|
|
createCalendarEvent,
|
|
deleteCalendar,
|
|
deleteCalendarEvent,
|
|
listCalendarEvents,
|
|
listCalendars,
|
|
updateCalendar,
|
|
updateCalendarEvent,
|
|
type CalendarCollection,
|
|
type CalendarCollectionDeletePayload,
|
|
type CalendarDeleteEventAction,
|
|
type CalendarEvent,
|
|
type CalendarEventCreatePayload
|
|
} from "../../api/calendar";
|
|
|
|
type CalendarMode = "continuous" | "month" | "week" | "workweek" | "day";
|
|
type Range = { start: Date; end: Date };
|
|
type EventDialogState = { kind: "create" } | { kind: "edit"; event: CalendarEvent };
|
|
type CalendarEditDialogState = {
|
|
calendar: CalendarCollection;
|
|
eventCount: number | null;
|
|
loadingEventCount: boolean;
|
|
confirmingDelete: boolean;
|
|
};
|
|
type AgendaGroup = { key: string; date: Date; events: CalendarEvent[] };
|
|
type ContinuousViewport = { scrollTop: number; height: number };
|
|
type CalendarDayDropTarget = { kind: "day"; key: string };
|
|
type CalendarTimeDropTarget = { kind: "time"; key: string; minuteOfDay: number };
|
|
type CalendarDropTarget = CalendarDayDropTarget | CalendarTimeDropTarget | null;
|
|
type CalendarDragAction =
|
|
| { kind: "move"; event: CalendarEvent }
|
|
| { kind: "resize-start"; event: CalendarEvent }
|
|
| { kind: "resize-end"; event: CalendarEvent };
|
|
type CalendarResizeEdge = "start" | "end";
|
|
type CalendarViewPreferences = {
|
|
dimWeekends: boolean;
|
|
dimOffHours: boolean;
|
|
workdayStartHour: number;
|
|
workdayEndHour: number;
|
|
continuousVirtualization: boolean;
|
|
continuousOverscanWeeks: number;
|
|
alternateContinuousMonths: boolean;
|
|
};
|
|
|
|
const HOUR_ROW_HEIGHT = 64;
|
|
const INITIAL_SCROLL_HOUR = 7;
|
|
const MAX_TIMED_EVENT_COLUMNS = 3;
|
|
const CONTINUOUS_WEEK_ROW_HEIGHT = 92;
|
|
const DEFAULT_CALENDAR_COLOR = "#5aa99b";
|
|
const CALENDAR_MODE_STORAGE_KEY = "govoplan.calendar.mode";
|
|
const CALENDAR_VIEW_PREFERENCES_STORAGE_KEY = "govoplan.calendar.viewPreferences";
|
|
const DEFAULT_CALENDAR_VIEW_PREFERENCES: CalendarViewPreferences = {
|
|
dimWeekends: true,
|
|
dimOffHours: true,
|
|
workdayStartHour: 6,
|
|
workdayEndHour: 20,
|
|
continuousVirtualization: true,
|
|
continuousOverscanWeeks: 6,
|
|
alternateContinuousMonths: true
|
|
};
|
|
|
|
const modeOptions: { id: CalendarMode; label: string }[] = [
|
|
{ id: "continuous", label: "Continuous" },
|
|
{ id: "month", label: "Month" },
|
|
{ id: "week", label: "Week" },
|
|
{ id: "workweek", label: "Workweek" },
|
|
{ id: "day", label: "Day" }
|
|
];
|
|
|
|
export default function CalendarPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
|
const [calendars, setCalendars] = useState<CalendarCollection[]>([]);
|
|
const [visibleCalendarIds, setVisibleCalendarIds] = useState<string[]>([]);
|
|
const [eventCalendarId, setEventCalendarId] = useState("");
|
|
const [newCalendarName, setNewCalendarName] = useState("");
|
|
const [newCalendarColor, setNewCalendarColor] = useState(DEFAULT_CALENDAR_COLOR);
|
|
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
|
const [mode, setMode] = useState<CalendarMode>(() => loadCalendarMode());
|
|
const [focusDate, setFocusDate] = useState(() => startOfDay(new Date()));
|
|
const [continuousWeeks, setContinuousWeeks] = useState({ before: 8, after: 12 });
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [eventDialog, setEventDialog] = useState<EventDialogState | null>(null);
|
|
const [calendarEditDialog, setCalendarEditDialog] = useState<CalendarEditDialogState | null>(null);
|
|
const [continuousViewport, setContinuousViewport] = useState<ContinuousViewport>({ scrollTop: 0, height: 0 });
|
|
const [draggingEventId, setDraggingEventId] = useState("");
|
|
const [hoveredEventId, setHoveredEventId] = useState("");
|
|
const [dropTarget, setDropTarget] = useState<CalendarDropTarget>(null);
|
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
const continuousPrependPendingRef = useRef(false);
|
|
const continuousAppendPendingRef = useRef(false);
|
|
const pendingContinuousScrollDayRef = useRef<Date | null>(null);
|
|
const dragActionRef = useRef<CalendarDragAction | null>(null);
|
|
|
|
const viewPreferences = useMemo(() => loadCalendarViewPreferences(), []);
|
|
const visibleCalendarIdSet = useMemo(() => new Set(visibleCalendarIds), [visibleCalendarIds]);
|
|
const visibleEvents = useMemo(() => events.filter((event) => visibleCalendarIdSet.has(event.calendar_id)), [events, visibleCalendarIdSet]);
|
|
const calendarColorById = useMemo(() => new Map(calendars.map((calendar) => [calendar.id, calendar.color || DEFAULT_CALENDAR_COLOR])), [calendars]);
|
|
const targetCalendarId = eventCalendarId || calendars[0]?.id || "";
|
|
const visibleRange = useMemo(() => rangeForMode(mode, focusDate, continuousWeeks), [continuousWeeks, focusDate, mode]);
|
|
const days = useMemo(() => daysForMode(mode, focusDate, continuousWeeks), [continuousWeeks, focusDate, mode]);
|
|
const eventsByDay = useMemo(() => groupEventsByDay(visibleEvents), [visibleEvents]);
|
|
const agendaGroups = useMemo(() => agendaGroupsForDays(days, eventsByDay), [days, eventsByDay]);
|
|
const canWrite = hasScope(auth, "calendar:event:write");
|
|
const canDelete = canWrite || hasScope(auth, "calendar:event:delete");
|
|
const canManageCalendars = hasScope(auth, "calendar:calendar:write");
|
|
const canDeleteCalendars = hasScope(auth, "calendar:calendar:admin");
|
|
const heading = headingForMode(mode, focusDate, visibleRange);
|
|
|
|
useEffect(() => {
|
|
void loadCalendars();
|
|
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
|
|
|
useEffect(() => {
|
|
saveCalendarMode(mode);
|
|
}, [mode]);
|
|
|
|
useEffect(() => {
|
|
if (calendars.length) {
|
|
void loadEvents();
|
|
} else {
|
|
setEvents([]);
|
|
}
|
|
}, [calendars.length, visibleRange.start.getTime(), visibleRange.end.getTime()]);
|
|
|
|
useEffect(() => {
|
|
if (mode !== "continuous") return undefined;
|
|
const frame = window.requestAnimationFrame(() => {
|
|
const node = scrollRef.current;
|
|
if (!node || node.scrollTop > 0) return;
|
|
const firstWeek = node.querySelector<HTMLElement>(".calendar-week-row");
|
|
const rowHeight = firstWeek?.offsetHeight || 92;
|
|
node.scrollTop = Math.max(0, continuousWeeks.before * rowHeight - node.clientHeight / 3);
|
|
updateContinuousViewport(node);
|
|
});
|
|
return () => window.cancelAnimationFrame(frame);
|
|
}, [focusDate, mode]);
|
|
|
|
useEffect(() => {
|
|
if (mode !== "continuous" || !pendingContinuousScrollDayRef.current) return;
|
|
const targetDay = pendingContinuousScrollDayRef.current;
|
|
pendingContinuousScrollDayRef.current = null;
|
|
const frame = window.requestAnimationFrame(() => {
|
|
scrollContinuousDayIntoView(targetDay, "smooth");
|
|
});
|
|
return () => window.cancelAnimationFrame(frame);
|
|
}, [days, mode]);
|
|
|
|
async function loadCalendars() {
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
const response = await listCalendars(settings);
|
|
setCalendars(response.calendars);
|
|
const ids = response.calendars.map((calendar) => calendar.id);
|
|
setVisibleCalendarIds((current) => {
|
|
const next = current.filter((id) => ids.includes(id));
|
|
return next.length > 0 ? next : ids;
|
|
});
|
|
setEventCalendarId((current) => current && ids.includes(current) ? current : ids[0] ?? "");
|
|
} catch (err) {
|
|
setError(errorText(err));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function loadEvents() {
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
const response = await listCalendarEvents(settings, {
|
|
start_at: visibleRange.start.toISOString(),
|
|
end_at: visibleRange.end.toISOString()
|
|
});
|
|
setEvents(response.events);
|
|
} catch (err) {
|
|
setError(errorText(err));
|
|
setEvents([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
function toggleCalendarVisibility(calendarId: string) {
|
|
setVisibleCalendarIds((current) => {
|
|
const next = current.includes(calendarId) ? current.filter((id) => id !== calendarId) : [...current, calendarId];
|
|
if (next.length > 0 && !next.includes(eventCalendarId)) setEventCalendarId(next[0]);
|
|
return next;
|
|
});
|
|
setEventCalendarId((current) => current || calendarId);
|
|
}
|
|
|
|
function openCalendarEditor(calendar: CalendarCollection) {
|
|
setCalendarEditDialog({
|
|
calendar,
|
|
eventCount: null,
|
|
loadingEventCount: true,
|
|
confirmingDelete: false
|
|
});
|
|
void listCalendarEvents(settings, { calendar_id: calendar.id })
|
|
.then((response) => {
|
|
setCalendarEditDialog((current) => current?.calendar.id === calendar.id
|
|
? { ...current, eventCount: response.events.length, loadingEventCount: false }
|
|
: current);
|
|
})
|
|
.catch((err) => {
|
|
setCalendarEditDialog((current) => current?.calendar.id === calendar.id
|
|
? { ...current, eventCount: null, loadingEventCount: false }
|
|
: current);
|
|
setError(errorText(err));
|
|
});
|
|
}
|
|
|
|
async function handleCalendarSave(calendar: CalendarCollection, payload: { name: string; color: string }) {
|
|
const name = payload.name.trim();
|
|
if (!name || !canManageCalendars) return;
|
|
setSaving(true);
|
|
setError("");
|
|
try {
|
|
const updated = await updateCalendar(settings, calendar.id, {
|
|
name,
|
|
color: normalizeHexColor(payload.color) || DEFAULT_CALENDAR_COLOR
|
|
});
|
|
setCalendars((current) => current.map((item) => item.id === calendar.id ? updated : item));
|
|
setCalendarEditDialog(null);
|
|
} catch (err) {
|
|
setError(errorText(err));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleCalendarDelete(calendar: CalendarCollection, payload: CalendarCollectionDeletePayload) {
|
|
if (!canDeleteCalendars) return;
|
|
const currentDialog = calendarEditDialog;
|
|
if (!currentDialog || currentDialog.calendar.id !== calendar.id) return;
|
|
if (!currentDialog.confirmingDelete) {
|
|
setCalendarEditDialog({ ...currentDialog, confirmingDelete: true });
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
setError("");
|
|
try {
|
|
await deleteCalendar(settings, calendar.id, payload);
|
|
setCalendarEditDialog(null);
|
|
setCalendars((current) => current.filter((item) => item.id !== calendar.id));
|
|
setVisibleCalendarIds((current) => current.filter((id) => id !== calendar.id));
|
|
setEventCalendarId((current) => current === calendar.id ? "" : current);
|
|
await loadCalendars();
|
|
if (calendars.length > 1 || payload.event_action === "move") {
|
|
await loadEvents();
|
|
} else {
|
|
setEvents([]);
|
|
}
|
|
} catch (err) {
|
|
setError(errorText(err));
|
|
}
|
|
finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleCreateCalendar(formEvent: FormEvent<HTMLFormElement>) {
|
|
formEvent.preventDefault();
|
|
const name = newCalendarName.trim();
|
|
if (!name || !canManageCalendars) return;
|
|
setSaving(true);
|
|
setError("");
|
|
try {
|
|
const calendar = await createCalendar(settings, {
|
|
name,
|
|
color: normalizeHexColor(newCalendarColor) || DEFAULT_CALENDAR_COLOR,
|
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
|
|
});
|
|
setCalendars((current) => [...current, calendar].sort(compareCalendars));
|
|
setVisibleCalendarIds((current) => current.includes(calendar.id) ? current : [...current, calendar.id]);
|
|
setEventCalendarId(calendar.id);
|
|
setNewCalendarName("");
|
|
setNewCalendarColor(DEFAULT_CALENDAR_COLOR);
|
|
} catch (err) {
|
|
setError(errorText(err));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
function moveFocus(direction: -1 | 1) {
|
|
if (mode === "continuous") return;
|
|
if (mode === "month") {
|
|
setFocusDate((current) => addMonths(current, direction));
|
|
return;
|
|
}
|
|
const daysToMove = mode === "day" ? 1 : 7;
|
|
setFocusDate((current) => addDays(current, direction * daysToMove));
|
|
}
|
|
|
|
function handleToday() {
|
|
const today = startOfDay(new Date());
|
|
if (mode !== "continuous") {
|
|
setFocusDate(today);
|
|
return;
|
|
}
|
|
pendingContinuousScrollDayRef.current = today;
|
|
setFocusDate(today);
|
|
window.requestAnimationFrame(() => {
|
|
if (pendingContinuousScrollDayRef.current) {
|
|
pendingContinuousScrollDayRef.current = null;
|
|
scrollContinuousDayIntoView(today, "smooth");
|
|
}
|
|
});
|
|
}
|
|
|
|
function handleContinuousScroll() {
|
|
const node = scrollRef.current;
|
|
if (!node || mode !== "continuous") return;
|
|
updateContinuousViewport(node);
|
|
if (node.scrollTop < 160) prependContinuousWeeks();
|
|
if (node.scrollTop + node.clientHeight > node.scrollHeight - 220) appendContinuousWeeks();
|
|
}
|
|
|
|
function handleContinuousWheel(event: ReactWheelEvent<HTMLDivElement>) {
|
|
const node = scrollRef.current;
|
|
if (!node || mode !== "continuous") return;
|
|
if (event.deltaY < 0 && node.scrollTop <= 0) prependContinuousWeeks();
|
|
}
|
|
|
|
function prependContinuousWeeks() {
|
|
const node = scrollRef.current;
|
|
if (!node || continuousPrependPendingRef.current) return;
|
|
continuousPrependPendingRef.current = true;
|
|
const oldHeight = node.scrollHeight;
|
|
setContinuousWeeks((current) => ({ ...current, before: current.before + 4 }));
|
|
window.requestAnimationFrame(() => {
|
|
const nextNode = scrollRef.current;
|
|
if (nextNode) nextNode.scrollTop += nextNode.scrollHeight - oldHeight;
|
|
continuousPrependPendingRef.current = false;
|
|
});
|
|
}
|
|
|
|
function updateContinuousViewport(node: HTMLDivElement) {
|
|
const next = { scrollTop: node.scrollTop, height: node.clientHeight };
|
|
setContinuousViewport((current) => (
|
|
Math.abs(current.scrollTop - next.scrollTop) < 1 && Math.abs(current.height - next.height) < 1 ? current : next
|
|
));
|
|
}
|
|
|
|
function scrollContinuousDayIntoView(day: Date, behavior: ScrollBehavior = "auto") {
|
|
const node = scrollRef.current;
|
|
if (!node || days.length === 0) return;
|
|
const firstWeek = startOfWeek(days[0]);
|
|
const targetWeek = startOfWeek(day);
|
|
const weekIndex = Math.max(0, Math.floor(daysBetweenCount(firstWeek, targetWeek) / 7));
|
|
node.scrollTo({ top: weekIndex * CONTINUOUS_WEEK_ROW_HEIGHT, behavior });
|
|
window.requestAnimationFrame(() => updateContinuousViewport(node));
|
|
}
|
|
|
|
function beginEventDrag(dragEvent: ReactDragEvent<HTMLElement>, action: CalendarDragAction) {
|
|
if (!canWrite) {
|
|
dragEvent.preventDefault();
|
|
return;
|
|
}
|
|
dragActionRef.current = action;
|
|
setDraggingEventId(action.event.id);
|
|
dragEvent.dataTransfer.effectAllowed = "move";
|
|
dragEvent.dataTransfer.setData("text/plain", action.event.id);
|
|
}
|
|
|
|
function handleEventDragStart(dragEvent: ReactDragEvent<HTMLElement>, calendarEvent: CalendarEvent) {
|
|
beginEventDrag(dragEvent, { kind: "move", event: calendarEvent });
|
|
}
|
|
|
|
function handleEventResizeDragStart(dragEvent: ReactDragEvent<HTMLElement>, calendarEvent: CalendarEvent, edge: CalendarResizeEdge) {
|
|
dragEvent.stopPropagation();
|
|
if (calendarEvent.all_day) {
|
|
dragEvent.preventDefault();
|
|
return;
|
|
}
|
|
beginEventDrag(dragEvent, { kind: edge === "start" ? "resize-start" : "resize-end", event: calendarEvent });
|
|
}
|
|
|
|
function handleEventDragEnd() {
|
|
dragActionRef.current = null;
|
|
setDraggingEventId("");
|
|
setDropTarget(null);
|
|
}
|
|
|
|
function allowEventDrop(dragEvent: ReactDragEvent<HTMLElement>, canDrop: (action: CalendarDragAction) => boolean): boolean {
|
|
const action = dragActionRef.current;
|
|
if (!canWrite || !action || !canDrop(action)) return false;
|
|
dragEvent.preventDefault();
|
|
dragEvent.dataTransfer.dropEffect = "move";
|
|
return true;
|
|
}
|
|
|
|
function handleEventDragOverDay(dragEvent: ReactDragEvent<HTMLElement>, day: Date) {
|
|
if (!allowEventDrop(dragEvent, (action) => action.kind === "move")) return;
|
|
const key = dayKey(day);
|
|
setDropTarget((current) => (current?.kind === "day" && current.key === key ? current : { kind: "day", key }));
|
|
}
|
|
|
|
function handleEventDragOverTime(dragEvent: ReactDragEvent<HTMLElement>, day: Date) {
|
|
if (!allowEventDrop(dragEvent, (action) => action.kind === "move" || action.kind === "resize-start" || action.kind === "resize-end")) return;
|
|
const key = dayKey(day);
|
|
const minuteOfDay = dropSlotMinuteOfDay(dragEvent);
|
|
setDropTarget((current) => (
|
|
current?.kind === "time" && current.key === key && current.minuteOfDay === minuteOfDay
|
|
? current
|
|
: { kind: "time", key, minuteOfDay }
|
|
));
|
|
}
|
|
|
|
function handleDropTargetLeave(dragEvent: ReactDragEvent<HTMLElement>) {
|
|
const related = dragEvent.relatedTarget;
|
|
if (related instanceof Node && dragEvent.currentTarget.contains(related)) return;
|
|
setDropTarget(null);
|
|
}
|
|
|
|
function handleEventDropOnDay(dropEvent: ReactDragEvent<HTMLElement>, day: Date) {
|
|
dropEvent.preventDefault();
|
|
const action = dragActionRef.current;
|
|
if (!action || action.kind !== "move" || !canWrite) return;
|
|
setDropTarget(null);
|
|
void moveEvent(action.event, moveEventToDay(action.event, day));
|
|
}
|
|
|
|
function handleEventDropOnTime(dropEvent: ReactDragEvent<HTMLElement>, day: Date, minuteOfDay: number) {
|
|
dropEvent.preventDefault();
|
|
const action = dragActionRef.current;
|
|
if (!action || !canWrite) return;
|
|
setDropTarget(null);
|
|
const next = action.kind === "move"
|
|
? moveEventToTime(action.event, day, minuteOfDay)
|
|
: resizeEventToTime(action.event, action.kind === "resize-start" ? "start" : "end", day, minuteOfDay);
|
|
void moveEvent(action.event, next);
|
|
}
|
|
|
|
async function moveEvent(calendarEvent: CalendarEvent, next: { startAt: Date; endAt: Date | null; allDay: boolean }) {
|
|
setSaving(true);
|
|
setError("");
|
|
try {
|
|
await updateCalendarEvent(settings, calendarEvent.id, {
|
|
start_at: next.startAt.toISOString(),
|
|
end_at: next.endAt ? next.endAt.toISOString() : null,
|
|
all_day: next.allDay
|
|
});
|
|
await loadEvents();
|
|
} catch (err) {
|
|
setError(errorText(err));
|
|
} finally {
|
|
handleEventDragEnd();
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
function appendContinuousWeeks() {
|
|
if (continuousAppendPendingRef.current) return;
|
|
continuousAppendPendingRef.current = true;
|
|
setContinuousWeeks((current) => ({ ...current, after: current.after + 4 }));
|
|
window.requestAnimationFrame(() => {
|
|
continuousAppendPendingRef.current = false;
|
|
});
|
|
}
|
|
|
|
async function handleSave(payload: CalendarEventCreatePayload, event: CalendarEvent | null) {
|
|
setSaving(true);
|
|
setError("");
|
|
try {
|
|
if (event) {
|
|
await updateCalendarEvent(settings, event.id, payload);
|
|
} else {
|
|
await createCalendarEvent(settings, payload);
|
|
}
|
|
if (payload.calendar_id) {
|
|
setVisibleCalendarIds((current) => current.includes(payload.calendar_id as string) ? current : [...current, payload.calendar_id as string]);
|
|
setEventCalendarId(payload.calendar_id);
|
|
}
|
|
setEventDialog(null);
|
|
await loadEvents();
|
|
} catch (err) {
|
|
setError(errorText(err));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleDelete(event: CalendarEvent) {
|
|
setSaving(true);
|
|
setError("");
|
|
try {
|
|
await deleteCalendarEvent(settings, event.id);
|
|
setEventDialog(null);
|
|
await loadEvents();
|
|
} catch (err) {
|
|
setError(errorText(err));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="workspace-data-page module-entry-page calendar-page calendar-fullscreen">
|
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
|
|
|
<div className={`calendar-shell ${loading ? "is-loading" : ""}`}>
|
|
<aside className="calendar-sidebar">
|
|
<div className="calendar-sidebar-heading">Calendars</div>
|
|
<div className="calendar-list">
|
|
{calendars.map((calendar) => (
|
|
<div key={calendar.id} className={calendar.id === targetCalendarId ? "calendar-list-row is-target" : "calendar-list-row"}>
|
|
<button
|
|
type="button"
|
|
className={visibleCalendarIds.includes(calendar.id) ? "calendar-visibility-switch is-on" : "calendar-visibility-switch"}
|
|
role="switch"
|
|
aria-checked={visibleCalendarIds.includes(calendar.id)}
|
|
aria-label={`Show ${calendar.name}`}
|
|
title={`Show ${calendar.name}`}
|
|
style={{ "--calendar-list-color": normalizeHexColor(calendar.color) || DEFAULT_CALENDAR_COLOR } as CSSProperties}
|
|
onClick={() => toggleCalendarVisibility(calendar.id)}
|
|
>
|
|
<span />
|
|
</button>
|
|
<button type="button" className="calendar-list-name" onClick={() => setEventCalendarId(calendar.id)}>
|
|
{calendar.name}
|
|
</button>
|
|
{(canManageCalendars || canDeleteCalendars) && (
|
|
<Button
|
|
type="button"
|
|
className="calendar-row-icon-button"
|
|
title={`Edit ${calendar.name}`}
|
|
aria-label={`Edit ${calendar.name}`}
|
|
onClick={() => openCalendarEditor(calendar)}
|
|
>
|
|
<Pencil size={15} />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
))}
|
|
{!calendars.length && !loading && (
|
|
<p className="calendar-empty-note">No calendars</p>
|
|
)}
|
|
{canManageCalendars && (
|
|
<form className="calendar-create-form" onSubmit={handleCreateCalendar}>
|
|
<input
|
|
value={newCalendarName}
|
|
onChange={(event) => setNewCalendarName(event.target.value)}
|
|
placeholder="New calendar"
|
|
aria-label="New calendar name"
|
|
disabled={saving}
|
|
maxLength={255}
|
|
/>
|
|
<input
|
|
type="color"
|
|
value={newCalendarColor}
|
|
aria-label="New calendar color"
|
|
disabled={saving}
|
|
onChange={(event) => setNewCalendarColor(event.target.value)}
|
|
/>
|
|
<Button type="submit" className="calendar-icon-button" title="Add calendar" aria-label="Add calendar" disabled={saving || !newCalendarName.trim()}>
|
|
<Plus size={16} />
|
|
</Button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
<div className="calendar-sidebar-heading is-secondary">Agenda</div>
|
|
<div className="calendar-agenda">
|
|
{agendaGroups.map((group) => (
|
|
<section key={group.key} className="calendar-agenda-group">
|
|
<h3>{agendaDateLabel(group.date)}</h3>
|
|
{group.events.map((event) => (
|
|
<button
|
|
key={`${group.key}-${event.id}`}
|
|
type="button"
|
|
className={["calendar-agenda-item", hoveredEventId === event.id ? "is-linked-hover" : ""].filter(Boolean).join(" ")}
|
|
style={calendarEventColorStyle(calendarColorById.get(event.calendar_id))}
|
|
onMouseEnter={() => setHoveredEventId(event.id)}
|
|
onMouseLeave={() => setHoveredEventId((current) => current === event.id ? "" : current)}
|
|
onFocus={() => setHoveredEventId(event.id)}
|
|
onBlur={() => setHoveredEventId((current) => current === event.id ? "" : current)}
|
|
onClick={() => setEventDialog({ kind: "edit", event })}
|
|
>
|
|
<EventInlineLabel event={event} />
|
|
</button>
|
|
))}
|
|
</section>
|
|
))}
|
|
{!agendaGroups.length && !loading && <p>No events</p>}
|
|
</div>
|
|
</aside>
|
|
|
|
<section className="calendar-main-panel" aria-label="Calendar">
|
|
<div className="calendar-view-toolbar" aria-label="Calendar controls">
|
|
<div className="calendar-toolbar-left">
|
|
<div className="calendar-mode-switch" role="tablist" aria-label="Calendar views">
|
|
{modeOptions.map((option) => (
|
|
<button
|
|
key={option.id}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={mode === option.id}
|
|
className={mode === option.id ? "calendar-mode-tab is-active" : "calendar-mode-tab"}
|
|
onClick={() => setMode(option.id)}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="calendar-toolbar-center">
|
|
<div className="calendar-icon-group" aria-label="Calendar navigation">
|
|
<Button type="button" className="calendar-icon-button" title="Previous" aria-label="Previous" onClick={() => moveFocus(-1)} disabled={mode === "continuous"}>
|
|
<ChevronLeft size={18} />
|
|
</Button>
|
|
<Button type="button" onClick={handleToday}>Today</Button>
|
|
<Button type="button" className="calendar-icon-button" title="Next" aria-label="Next" onClick={() => moveFocus(1)} disabled={mode === "continuous"}>
|
|
<ChevronRight size={18} />
|
|
</Button>
|
|
<div className="calendar-range-label">
|
|
<CalendarDays size={18} aria-hidden="true" />
|
|
<strong>{heading}</strong>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="calendar-toolbar-right">
|
|
<Button type="button" className="calendar-icon-button" title="Refresh" aria-label="Refresh" onClick={() => void loadEvents()}>
|
|
<RefreshCw size={18} />
|
|
</Button>
|
|
{canWrite && (
|
|
<Button type="button" variant="primary" onClick={() => setEventDialog({ kind: "create" })} disabled={!targetCalendarId}>
|
|
<Plus size={17} /> New
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className={`calendar-view-shell is-${mode}`}>
|
|
{loading && <div className="calendar-loading"><LoadingIndicator label="Loading calendar..." /></div>}
|
|
{mode === "continuous" ? (
|
|
<div className="calendar-continuous" ref={scrollRef} onScroll={handleContinuousScroll} onWheel={handleContinuousWheel}>
|
|
<CalendarWeekRows
|
|
days={days}
|
|
eventsByDay={eventsByDay}
|
|
calendarColorById={calendarColorById}
|
|
focusDate={focusDate}
|
|
variant="continuous"
|
|
preferences={viewPreferences}
|
|
canWrite={canWrite}
|
|
draggingEventId={draggingEventId}
|
|
hoveredEventId={hoveredEventId}
|
|
dropTarget={dropTarget}
|
|
viewport={continuousViewport}
|
|
onEventSelect={(event) => setEventDialog({ kind: "edit", event })}
|
|
onEventHover={setHoveredEventId}
|
|
onEventDragStart={handleEventDragStart}
|
|
onEventDragEnd={handleEventDragEnd}
|
|
onEventDragOverDay={handleEventDragOverDay}
|
|
onDropTargetLeave={handleDropTargetLeave}
|
|
onEventDropOnDay={handleEventDropOnDay}
|
|
/>
|
|
</div>
|
|
) : mode === "month" ? (
|
|
<CalendarWeekRows
|
|
days={days}
|
|
eventsByDay={eventsByDay}
|
|
calendarColorById={calendarColorById}
|
|
focusDate={focusDate}
|
|
variant="month"
|
|
preferences={viewPreferences}
|
|
canWrite={canWrite}
|
|
draggingEventId={draggingEventId}
|
|
hoveredEventId={hoveredEventId}
|
|
dropTarget={dropTarget}
|
|
onEventSelect={(event) => setEventDialog({ kind: "edit", event })}
|
|
onEventHover={setHoveredEventId}
|
|
onEventDragStart={handleEventDragStart}
|
|
onEventDragEnd={handleEventDragEnd}
|
|
onEventDragOverDay={handleEventDragOverDay}
|
|
onDropTargetLeave={handleDropTargetLeave}
|
|
onEventDropOnDay={handleEventDropOnDay}
|
|
/>
|
|
) : (
|
|
<CalendarTimeGrid
|
|
days={days}
|
|
eventsByDay={eventsByDay}
|
|
calendarColorById={calendarColorById}
|
|
mode={mode}
|
|
preferences={viewPreferences}
|
|
canWrite={canWrite}
|
|
draggingEventId={draggingEventId}
|
|
hoveredEventId={hoveredEventId}
|
|
dropTarget={dropTarget}
|
|
onEventSelect={(event) => setEventDialog({ kind: "edit", event })}
|
|
onEventHover={setHoveredEventId}
|
|
onEventDragStart={handleEventDragStart}
|
|
onEventResizeDragStart={handleEventResizeDragStart}
|
|
onEventDragEnd={handleEventDragEnd}
|
|
onEventDragOverDay={handleEventDragOverDay}
|
|
onEventDragOverTime={handleEventDragOverTime}
|
|
onDropTargetLeave={handleDropTargetLeave}
|
|
onEventDropOnDay={handleEventDropOnDay}
|
|
onEventDropOnTime={handleEventDropOnTime}
|
|
/>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
{eventDialog && calendars.length > 0 && (
|
|
<CalendarEventDialog
|
|
calendars={calendars}
|
|
defaultCalendarId={targetCalendarId}
|
|
event={eventDialog.kind === "edit" ? eventDialog.event : null}
|
|
focusDate={focusDate}
|
|
saving={saving}
|
|
canWrite={canWrite}
|
|
canDelete={canDelete}
|
|
onCancel={() => setEventDialog(null)}
|
|
onSave={handleSave}
|
|
onDelete={handleDelete}
|
|
/>
|
|
)}
|
|
{calendarEditDialog && (
|
|
<CalendarCollectionDialog
|
|
key={calendarEditDialog.calendar.id}
|
|
state={calendarEditDialog}
|
|
calendars={calendars}
|
|
saving={saving}
|
|
canWrite={canManageCalendars}
|
|
canDelete={canDeleteCalendars}
|
|
onCancel={() => setCalendarEditDialog(null)}
|
|
onSave={handleCalendarSave}
|
|
onDelete={handleCalendarDelete}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CalendarWeekRows({
|
|
days,
|
|
eventsByDay,
|
|
calendarColorById,
|
|
focusDate,
|
|
variant,
|
|
preferences,
|
|
canWrite,
|
|
draggingEventId,
|
|
hoveredEventId,
|
|
dropTarget,
|
|
viewport,
|
|
onEventSelect,
|
|
onEventHover,
|
|
onEventDragStart,
|
|
onEventDragEnd,
|
|
onEventDragOverDay,
|
|
onDropTargetLeave,
|
|
onEventDropOnDay
|
|
}: {
|
|
days: Date[];
|
|
eventsByDay: Map<string, CalendarEvent[]>;
|
|
calendarColorById: Map<string, string>;
|
|
focusDate: Date;
|
|
variant: "month" | "continuous";
|
|
preferences: CalendarViewPreferences;
|
|
canWrite: boolean;
|
|
draggingEventId: string;
|
|
hoveredEventId: string;
|
|
dropTarget: CalendarDropTarget;
|
|
viewport?: ContinuousViewport;
|
|
onEventSelect: (event: CalendarEvent) => void;
|
|
onEventHover: (eventId: string) => void;
|
|
onEventDragStart: (dragEvent: ReactDragEvent<HTMLElement>, calendarEvent: CalendarEvent) => void;
|
|
onEventDragEnd: () => void;
|
|
onEventDragOverDay: (dragEvent: ReactDragEvent<HTMLElement>, day: Date) => void;
|
|
onDropTargetLeave: (dragEvent: ReactDragEvent<HTMLElement>) => void;
|
|
onEventDropOnDay: (dropEvent: ReactDragEvent<HTMLElement>, day: Date) => void;
|
|
}) {
|
|
const weeks = chunk(days, 7);
|
|
const virtualWindow = variant === "continuous" && preferences.continuousVirtualization
|
|
? continuousVirtualWindow(weeks.length, viewport ?? { scrollTop: 0, height: 0 }, preferences.continuousOverscanWeeks)
|
|
: { start: 0, end: weeks.length, topSpacerHeight: 0, bottomSpacerHeight: 0 };
|
|
const visibleWeeks = weeks.slice(virtualWindow.start, virtualWindow.end);
|
|
return (
|
|
<div className={`calendar-week-rows is-${variant}`}>
|
|
<div className="calendar-weekday-header">
|
|
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((label) => <span key={label}>{label}</span>)}
|
|
</div>
|
|
{virtualWindow.topSpacerHeight > 0 && <div className="calendar-week-spacer" style={{ height: virtualWindow.topSpacerHeight }} aria-hidden="true" />}
|
|
{visibleWeeks.map((week) => (
|
|
<div className="calendar-week-row" key={dayKey(week[0])}>
|
|
{week.map((day) => {
|
|
const key = dayKey(day);
|
|
const dayEvents = eventsByDay.get(key) ?? [];
|
|
const outsideFocusMonth = variant === "month" && !sameMonth(day, focusDate);
|
|
return (
|
|
<section
|
|
key={key}
|
|
className={[
|
|
"calendar-day-cell",
|
|
outsideFocusMonth ? "is-muted" : "",
|
|
sameDay(day, new Date()) ? "is-today" : "",
|
|
dropTarget?.kind === "day" && dropTarget.key === key ? "is-drop-target" : "",
|
|
variant === "continuous" && preferences.alternateContinuousMonths ? (day.getMonth() % 2 === 0 ? "is-even-month" : "is-odd-month") : ""
|
|
].filter(Boolean).join(" ")}
|
|
onDragOver={canWrite ? (event) => onEventDragOverDay(event, day) : undefined}
|
|
onDragLeave={canWrite ? onDropTargetLeave : undefined}
|
|
onDrop={canWrite ? (event) => onEventDropOnDay(event, day) : undefined}
|
|
>
|
|
<header>
|
|
<span className="calendar-day-number">{day.getDate()}</span>
|
|
{day.getDate() === 1 && <small>{monthYearLabel(day)}</small>}
|
|
</header>
|
|
<div className="calendar-event-stack">
|
|
{dayEvents.slice(0, variant === "month" ? 5 : 3).map((event) => (
|
|
<CalendarEventChip
|
|
key={event.id}
|
|
event={event}
|
|
color={calendarColorById.get(event.calendar_id)}
|
|
canDrag={canWrite}
|
|
dragging={draggingEventId === event.id}
|
|
linkedHover={hoveredEventId === event.id}
|
|
onSelect={onEventSelect}
|
|
onHover={onEventHover}
|
|
onDragStart={onEventDragStart}
|
|
onDragEnd={onEventDragEnd}
|
|
/>
|
|
))}
|
|
{dayEvents.length > (variant === "month" ? 5 : 3) && <span className="calendar-more-events">+{dayEvents.length - (variant === "month" ? 5 : 3)}</span>}
|
|
</div>
|
|
</section>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
{virtualWindow.bottomSpacerHeight > 0 && <div className="calendar-week-spacer" style={{ height: virtualWindow.bottomSpacerHeight }} aria-hidden="true" />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CalendarTimeGrid({
|
|
days,
|
|
eventsByDay,
|
|
calendarColorById,
|
|
mode,
|
|
preferences,
|
|
canWrite,
|
|
draggingEventId,
|
|
hoveredEventId,
|
|
dropTarget,
|
|
onEventSelect,
|
|
onEventHover,
|
|
onEventDragStart,
|
|
onEventResizeDragStart,
|
|
onEventDragEnd,
|
|
onEventDragOverDay,
|
|
onEventDragOverTime,
|
|
onDropTargetLeave,
|
|
onEventDropOnDay,
|
|
onEventDropOnTime
|
|
}: {
|
|
days: Date[];
|
|
eventsByDay: Map<string, CalendarEvent[]>;
|
|
calendarColorById: Map<string, string>;
|
|
mode: CalendarMode;
|
|
preferences: CalendarViewPreferences;
|
|
canWrite: boolean;
|
|
draggingEventId: string;
|
|
hoveredEventId: string;
|
|
dropTarget: CalendarDropTarget;
|
|
onEventSelect: (event: CalendarEvent) => void;
|
|
onEventHover: (eventId: string) => void;
|
|
onEventDragStart: (dragEvent: ReactDragEvent<HTMLElement>, calendarEvent: CalendarEvent) => void;
|
|
onEventResizeDragStart: (dragEvent: ReactDragEvent<HTMLElement>, calendarEvent: CalendarEvent, edge: CalendarResizeEdge) => void;
|
|
onEventDragEnd: () => void;
|
|
onEventDragOverDay: (dragEvent: ReactDragEvent<HTMLElement>, day: Date) => void;
|
|
onEventDragOverTime: (dragEvent: ReactDragEvent<HTMLElement>, day: Date) => void;
|
|
onDropTargetLeave: (dragEvent: ReactDragEvent<HTMLElement>) => void;
|
|
onEventDropOnDay: (dropEvent: ReactDragEvent<HTMLElement>, day: Date) => void;
|
|
onEventDropOnTime: (dropEvent: ReactDragEvent<HTMLElement>, day: Date, minuteOfDay: number) => void;
|
|
}) {
|
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
const hours = Array.from({ length: 24 }, (_item, index) => index);
|
|
const columns = `72px repeat(${days.length}, minmax(132px, 1fr))`;
|
|
const dimWeekends = preferences.dimWeekends && mode === "week";
|
|
const gridStyle = timeGridStyle(columns, preferences);
|
|
const allDayEventsByDay = days.map((day) => ({
|
|
key: dayKey(day),
|
|
date: day,
|
|
events: (eventsByDay.get(dayKey(day)) ?? []).filter((event) => event.all_day)
|
|
}));
|
|
const hasAllDayEvents = allDayEventsByDay.some((day) => day.events.length > 0);
|
|
|
|
useEffect(() => {
|
|
if (scrollRef.current) scrollRef.current.scrollTop = INITIAL_SCROLL_HOUR * HOUR_ROW_HEIGHT;
|
|
}, [days.length, days[0]?.getTime()]);
|
|
|
|
return (
|
|
<div className="calendar-time-view">
|
|
<div className="calendar-time-header" style={{ gridTemplateColumns: columns }}>
|
|
<div className="calendar-time-corner" />
|
|
{days.map((day) => (
|
|
<header key={dayKey(day)} className={[sameDay(day, new Date()) ? "is-today" : "", dimWeekends && isWeekend(day) ? "is-weekend" : ""].filter(Boolean).join(" ")}>
|
|
<strong>{weekdayLong(day)}</strong>
|
|
<span>{dayMonthLabel(day)}</span>
|
|
</header>
|
|
))}
|
|
</div>
|
|
{hasAllDayEvents && (
|
|
<div className="calendar-all-day-strip" style={{ gridTemplateColumns: columns }}>
|
|
<div className="calendar-all-day-label">All day</div>
|
|
{allDayEventsByDay.map((day) => (
|
|
<div
|
|
key={day.key}
|
|
className={[
|
|
"calendar-all-day-cell",
|
|
dimWeekends && isWeekend(day.date) ? "is-weekend" : "",
|
|
dropTarget?.kind === "day" && dropTarget.key === day.key ? "is-drop-target" : ""
|
|
].filter(Boolean).join(" ")}
|
|
onDragOver={canWrite ? (event) => onEventDragOverDay(event, day.date) : undefined}
|
|
onDragLeave={canWrite ? onDropTargetLeave : undefined}
|
|
onDrop={canWrite ? (event) => onEventDropOnDay(event, day.date) : undefined}
|
|
>
|
|
{day.events.map((event) => (
|
|
<CalendarEventChip
|
|
key={event.id}
|
|
event={event}
|
|
color={calendarColorById.get(event.calendar_id)}
|
|
compact
|
|
canDrag={canWrite}
|
|
dragging={draggingEventId === event.id}
|
|
linkedHover={hoveredEventId === event.id}
|
|
onSelect={onEventSelect}
|
|
onHover={onEventHover}
|
|
onDragStart={onEventDragStart}
|
|
onDragEnd={onEventDragEnd}
|
|
/>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="calendar-time-scroll" ref={scrollRef}>
|
|
<div className="calendar-time-grid" style={gridStyle}>
|
|
<div className="calendar-hour-label-column">
|
|
{hours.map((hour) => <div className="calendar-hour-label" key={hour}>{String(hour).padStart(2, "0")}:00</div>)}
|
|
</div>
|
|
{days.map((day) => (
|
|
<TimedDayColumn
|
|
key={dayKey(day)}
|
|
day={day}
|
|
events={eventsByDay.get(dayKey(day)) ?? []}
|
|
calendarColorById={calendarColorById}
|
|
dimWeekend={dimWeekends && isWeekend(day)}
|
|
canWrite={canWrite}
|
|
draggingEventId={draggingEventId}
|
|
hoveredEventId={hoveredEventId}
|
|
dropTarget={dropTarget?.kind === "time" && dropTarget.key === dayKey(day) ? dropTarget : null}
|
|
onEventSelect={onEventSelect}
|
|
onEventHover={onEventHover}
|
|
onEventDragStart={onEventDragStart}
|
|
onEventResizeDragStart={onEventResizeDragStart}
|
|
onEventDragEnd={onEventDragEnd}
|
|
onEventDragOverTime={onEventDragOverTime}
|
|
onDropTargetLeave={onDropTargetLeave}
|
|
onEventDropOnTime={onEventDropOnTime}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TimedDayColumn({
|
|
day,
|
|
events,
|
|
calendarColorById,
|
|
dimWeekend,
|
|
canWrite,
|
|
draggingEventId,
|
|
hoveredEventId,
|
|
dropTarget,
|
|
onEventSelect,
|
|
onEventHover,
|
|
onEventDragStart,
|
|
onEventResizeDragStart,
|
|
onEventDragEnd,
|
|
onEventDragOverTime,
|
|
onDropTargetLeave,
|
|
onEventDropOnTime
|
|
}: {
|
|
day: Date;
|
|
events: CalendarEvent[];
|
|
calendarColorById: Map<string, string>;
|
|
dimWeekend: boolean;
|
|
canWrite: boolean;
|
|
draggingEventId: string;
|
|
hoveredEventId: string;
|
|
dropTarget: CalendarTimeDropTarget | null;
|
|
onEventSelect: (event: CalendarEvent) => void;
|
|
onEventHover: (eventId: string) => void;
|
|
onEventDragStart: (dragEvent: ReactDragEvent<HTMLElement>, calendarEvent: CalendarEvent) => void;
|
|
onEventResizeDragStart: (dragEvent: ReactDragEvent<HTMLElement>, calendarEvent: CalendarEvent, edge: CalendarResizeEdge) => void;
|
|
onEventDragEnd: () => void;
|
|
onEventDragOverTime: (dragEvent: ReactDragEvent<HTMLElement>, day: Date) => void;
|
|
onDropTargetLeave: (dragEvent: ReactDragEvent<HTMLElement>) => void;
|
|
onEventDropOnTime: (dropEvent: ReactDragEvent<HTMLElement>, day: Date, minuteOfDay: number) => void;
|
|
}) {
|
|
const layout = useMemo(() => layoutTimedEventsForDay(events, day), [day, events]);
|
|
return (
|
|
<div
|
|
className={["calendar-day-time-column", dimWeekend ? "is-weekend" : "", dropTarget ? "is-drop-target" : ""].filter(Boolean).join(" ")}
|
|
onDragOver={canWrite ? (event) => onEventDragOverTime(event, day) : undefined}
|
|
onDragLeave={canWrite ? onDropTargetLeave : undefined}
|
|
onDrop={canWrite ? (event) => onEventDropOnTime(event, day, dropSlotMinuteOfDay(event)) : undefined}
|
|
>
|
|
{dropTarget && (
|
|
<div
|
|
className="calendar-time-drop-marker"
|
|
style={{ top: `${dropTarget.minuteOfDay / 60 * HOUR_ROW_HEIGHT}px` }}
|
|
aria-hidden="true"
|
|
>
|
|
<span>{minuteOfDayLabel(dropTarget.minuteOfDay)}</span>
|
|
</div>
|
|
)}
|
|
{layout.items.map((item) => (
|
|
<div
|
|
key={item.event.id}
|
|
className={[
|
|
"calendar-timed-event",
|
|
draggingEventId === item.event.id ? "is-dragging" : "",
|
|
hoveredEventId === item.event.id ? "is-linked-hover" : ""
|
|
].filter(Boolean).join(" ")}
|
|
style={timedEventStyle(item, calendarColorById.get(item.event.calendar_id))}
|
|
draggable={canWrite}
|
|
onMouseEnter={() => onEventHover(item.event.id)}
|
|
onMouseLeave={() => onEventHover("")}
|
|
onDragStart={canWrite ? (event) => onEventDragStart(event, item.event) : undefined}
|
|
onDragEnd={canWrite ? onEventDragEnd : undefined}
|
|
title={`Edit ${item.event.summary}`}
|
|
>
|
|
{canWrite && (
|
|
<span
|
|
className="calendar-event-resize-handle is-start"
|
|
draggable
|
|
title="Change start time"
|
|
onDragStart={(event) => onEventResizeDragStart(event, item.event, "start")}
|
|
onDragEnd={onEventDragEnd}
|
|
/>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="calendar-timed-event-content"
|
|
onClick={() => onEventSelect(item.event)}
|
|
onFocus={() => onEventHover(item.event.id)}
|
|
onBlur={() => onEventHover("")}
|
|
title={`Edit ${item.event.summary}`}
|
|
>
|
|
<EventInlineLabel event={item.event} />
|
|
</button>
|
|
{canWrite && (
|
|
<span
|
|
className="calendar-event-resize-handle is-end"
|
|
draggable
|
|
title="Change end time"
|
|
onDragStart={(event) => onEventResizeDragStart(event, item.event, "end")}
|
|
onDragEnd={onEventDragEnd}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
{layout.overflows.map((overflow) => (
|
|
<button
|
|
key={overflow.key}
|
|
type="button"
|
|
className="calendar-timed-overflow"
|
|
style={timedOverflowStyle(overflow)}
|
|
title={overflow.events.map((event) => event.summary).join(", ")}
|
|
onClick={() => onEventSelect(overflow.events[0])}
|
|
>
|
|
+{overflow.events.length}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CalendarEventChip({
|
|
event,
|
|
color,
|
|
compact = false,
|
|
canDrag = false,
|
|
dragging = false,
|
|
linkedHover = false,
|
|
onSelect,
|
|
onHover,
|
|
onDragStart,
|
|
onDragEnd
|
|
}: {
|
|
event: CalendarEvent;
|
|
color?: string | null;
|
|
compact?: boolean;
|
|
canDrag?: boolean;
|
|
dragging?: boolean;
|
|
linkedHover?: boolean;
|
|
onSelect: (event: CalendarEvent) => void;
|
|
onHover?: (eventId: string) => void;
|
|
onDragStart?: (dragEvent: ReactDragEvent<HTMLElement>, calendarEvent: CalendarEvent) => void;
|
|
onDragEnd?: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={[
|
|
compact ? "calendar-event-chip is-compact" : "calendar-event-chip",
|
|
dragging ? "is-dragging" : "",
|
|
linkedHover ? "is-linked-hover" : ""
|
|
].filter(Boolean).join(" ")}
|
|
style={calendarEventColorStyle(color)}
|
|
draggable={canDrag}
|
|
onClick={() => onSelect(event)}
|
|
onMouseEnter={onHover ? () => onHover(event.id) : undefined}
|
|
onMouseLeave={onHover ? () => onHover("") : undefined}
|
|
onFocus={onHover ? () => onHover(event.id) : undefined}
|
|
onBlur={onHover ? () => onHover("") : undefined}
|
|
onDragStart={canDrag && onDragStart ? (dragEvent) => onDragStart(dragEvent, event) : undefined}
|
|
onDragEnd={canDrag ? onDragEnd : undefined}
|
|
title={`Edit ${event.summary}`}
|
|
>
|
|
<EventInlineLabel event={event} />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function CalendarCollectionDialog({
|
|
state,
|
|
calendars,
|
|
saving,
|
|
canWrite,
|
|
canDelete,
|
|
onCancel,
|
|
onSave,
|
|
onDelete
|
|
}: {
|
|
state: CalendarEditDialogState;
|
|
calendars: CalendarCollection[];
|
|
saving: boolean;
|
|
canWrite: boolean;
|
|
canDelete: boolean;
|
|
onCancel: () => void;
|
|
onSave: (calendar: CalendarCollection, payload: { name: string; color: string }) => Promise<void>;
|
|
onDelete: (calendar: CalendarCollection, payload: CalendarCollectionDeletePayload) => Promise<void>;
|
|
}) {
|
|
const { calendar, eventCount, loadingEventCount, confirmingDelete } = state;
|
|
const [name, setName] = useState(calendar.name);
|
|
const [color, setColor] = useState(normalizeHexColor(calendar.color) || DEFAULT_CALENDAR_COLOR);
|
|
const moveTargets = calendars.filter((item) => item.id !== calendar.id);
|
|
const firstMoveTargetId = moveTargets[0]?.id || "";
|
|
const [eventAction, setEventAction] = useState<CalendarDeleteEventAction>("delete");
|
|
const [targetCalendarId, setTargetCalendarId] = useState(firstMoveTargetId);
|
|
const [makeTargetDefault, setMakeTargetDefault] = useState(calendar.is_default && Boolean(firstMoveTargetId));
|
|
const formId = "calendar-collection-form";
|
|
const actionLabel = calendarDeleteActionLabel(calendar);
|
|
const actionVerb = actionLabel.toLowerCase();
|
|
const hasEvents = (eventCount ?? 0) > 0;
|
|
const canMoveEvents = moveTargets.length > 0;
|
|
const effectiveEventAction: CalendarDeleteEventAction = hasEvents ? eventAction : "delete";
|
|
const deleteDisabled = (
|
|
saving ||
|
|
loadingEventCount ||
|
|
eventCount === null ||
|
|
!canDelete ||
|
|
(hasEvents && effectiveEventAction === "move" && !targetCalendarId)
|
|
);
|
|
|
|
function submit(formEvent: FormEvent<HTMLFormElement>) {
|
|
formEvent.preventDefault();
|
|
void onSave(calendar, { name, color });
|
|
}
|
|
|
|
function submitDelete() {
|
|
const payload: CalendarCollectionDeletePayload = {
|
|
event_action: effectiveEventAction,
|
|
target_calendar_id: effectiveEventAction === "move" ? targetCalendarId : null,
|
|
make_target_default: effectiveEventAction === "move" && calendar.is_default && makeTargetDefault
|
|
};
|
|
void onDelete(calendar, payload);
|
|
}
|
|
|
|
return (
|
|
<Dialog
|
|
open
|
|
title="Edit calendar"
|
|
className="calendar-event-dialog"
|
|
footerClassName="calendar-event-dialog-footer"
|
|
closeDisabled={saving}
|
|
onClose={onCancel}
|
|
footer={(
|
|
<>
|
|
<div>
|
|
{canDelete && (
|
|
<Button type="button" variant="danger" onClick={submitDelete} disabled={deleteDisabled}>
|
|
<Trash2 size={16} /> {confirmingDelete ? `Confirm ${actionVerb}` : actionLabel}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="calendar-dialog-actions">
|
|
<Button type="button" onClick={onCancel} disabled={saving}>Cancel</Button>
|
|
<Button type="submit" form={formId} variant="primary" disabled={saving || !canWrite || !name.trim()}>{saving ? "Saving..." : "Save"}</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
>
|
|
<form id={formId} className="calendar-dialog-form" onSubmit={submit}>
|
|
{confirmingDelete && (
|
|
<p className="calendar-delete-warning">
|
|
{calendarDeleteWarning(calendar, eventCount ?? 0, effectiveEventAction, targetCalendarId, moveTargets)}
|
|
</p>
|
|
)}
|
|
{!loadingEventCount && eventCount === null && canDelete && <p className="calendar-form-note">Event count could not be loaded.</p>}
|
|
<div className="calendar-dialog-name-color-row">
|
|
<label>
|
|
<span>Name</span>
|
|
<input value={name} onChange={(item) => setName(item.target.value)} required maxLength={255} autoFocus disabled={saving || !canWrite} />
|
|
</label>
|
|
<label className="calendar-dialog-color-label">
|
|
<span>Color</span>
|
|
<input type="color" value={color} onChange={(item) => setColor(item.target.value)} disabled={saving || !canWrite} />
|
|
</label>
|
|
</div>
|
|
{canDelete && hasEvents && (
|
|
<fieldset className="calendar-delete-options" disabled={saving || loadingEventCount}>
|
|
<legend>{eventCount} event{eventCount === 1 ? "" : "s"}</legend>
|
|
<label>
|
|
<input
|
|
type="radio"
|
|
name="calendar-delete-event-action"
|
|
value="delete"
|
|
checked={eventAction === "delete"}
|
|
onChange={() => setEventAction("delete")}
|
|
/>
|
|
<span>{calendarEventDeleteOptionLabel(calendar)}</span>
|
|
</label>
|
|
<label className={!canMoveEvents ? "is-disabled" : ""}>
|
|
<input
|
|
type="radio"
|
|
name="calendar-delete-event-action"
|
|
value="move"
|
|
checked={eventAction === "move"}
|
|
onChange={() => setEventAction("move")}
|
|
disabled={!canMoveEvents}
|
|
/>
|
|
<span>Move events to another calendar</span>
|
|
</label>
|
|
{eventAction === "move" && canMoveEvents && (
|
|
<>
|
|
<label className="calendar-delete-target-label">
|
|
<span>Target calendar</span>
|
|
<select value={targetCalendarId} onChange={(item) => setTargetCalendarId(item.target.value)} required>
|
|
{moveTargets.map((item) => (
|
|
<option key={item.id} value={item.id}>{item.name}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
{calendar.is_default && (
|
|
<label className="calendar-checkbox-row">
|
|
<input type="checkbox" checked={makeTargetDefault} onChange={(item) => setMakeTargetDefault(item.target.checked)} />
|
|
<span>Make target calendar the default</span>
|
|
</label>
|
|
)}
|
|
</>
|
|
)}
|
|
{!canMoveEvents && <p className="calendar-form-note">No other calendar is available, so events can only be {calendarIsExternal(calendar) ? "removed locally" : "deleted"}.</p>}
|
|
</fieldset>
|
|
)}
|
|
</form>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function EventInlineLabel({ event }: { event: CalendarEvent }) {
|
|
return (
|
|
<span className="calendar-event-line">
|
|
<span className="calendar-event-time">{eventTimeLabel(event)}</span>
|
|
<span className="calendar-event-separator">-</span>
|
|
<strong>{event.summary}</strong>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function CalendarEventDialog({
|
|
calendars,
|
|
defaultCalendarId,
|
|
event,
|
|
focusDate,
|
|
saving,
|
|
canWrite,
|
|
canDelete,
|
|
onCancel,
|
|
onSave,
|
|
onDelete
|
|
}: {
|
|
calendars: CalendarCollection[];
|
|
defaultCalendarId: string;
|
|
event: CalendarEvent | null;
|
|
focusDate: Date;
|
|
saving: boolean;
|
|
canWrite: boolean;
|
|
canDelete: boolean;
|
|
onCancel: () => void;
|
|
onSave: (payload: CalendarEventCreatePayload, event: CalendarEvent | null) => Promise<void>;
|
|
onDelete: (event: CalendarEvent) => Promise<void>;
|
|
}) {
|
|
const initialStart = event ? new Date(event.start_at) : focusDate;
|
|
const initialEnd = event?.end_at ? new Date(event.end_at) : addHours(initialStart, 1);
|
|
const initialAllDayEnd = event?.all_day && event.end_at ? addDays(startOfDay(initialEnd), -1) : initialEnd;
|
|
const [summary, setSummary] = useState(event?.summary ?? "");
|
|
const [calendarId, setCalendarId] = useState(event?.calendar_id ?? defaultCalendarId);
|
|
const [description, setDescription] = useState(event?.description ?? "");
|
|
const [location, setLocation] = useState(event?.location ?? "");
|
|
const [startDate, setStartDate] = useState(toInputDate(initialStart));
|
|
const [endDate, setEndDate] = useState(toInputDate(initialAllDayEnd < initialStart ? initialStart : initialAllDayEnd));
|
|
const [startTime, setStartTime] = useState(toInputTime(initialStart));
|
|
const [endTime, setEndTime] = useState(toInputTime(initialEnd));
|
|
const [allDay, setAllDay] = useState(event?.all_day ?? false);
|
|
const [formError, setFormError] = useState("");
|
|
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
|
const formId = "calendar-event-form";
|
|
|
|
function handleAllDayChange(next: boolean) {
|
|
setAllDay(next);
|
|
if (next) {
|
|
setStartTime("00:00");
|
|
setEndTime("00:00");
|
|
} else if (startTime === "00:00" && endTime === "00:00") {
|
|
setStartTime("09:00");
|
|
setEndTime("10:00");
|
|
}
|
|
}
|
|
|
|
function handleStartDateChange(next: string) {
|
|
setStartDate(next);
|
|
if (endDate < next) setEndDate(next);
|
|
}
|
|
|
|
function handleStartTimeChange(next: string) {
|
|
setStartTime(next);
|
|
if (startDate === endDate && endTime <= next) setEndTime(addMinutesToTime(next, 60));
|
|
}
|
|
|
|
async function submit(formEvent: FormEvent<HTMLFormElement>) {
|
|
formEvent.preventDefault();
|
|
setFormError("");
|
|
const startAt = allDay ? localDateTime(startDate, "00:00") : localDateTime(startDate, startTime);
|
|
const endAt = allDay ? addDays(localDateTime(endDate, "00:00"), 1) : localDateTime(endDate, endTime);
|
|
if (endAt <= startAt) {
|
|
setFormError(allDay ? "End date must be on or after start date." : "End date and time must be after start date and time.");
|
|
return;
|
|
}
|
|
await onSave({
|
|
calendar_id: calendarId,
|
|
summary,
|
|
description: description || null,
|
|
location: location || null,
|
|
start_at: startAt.toISOString(),
|
|
end_at: endAt.toISOString(),
|
|
all_day: allDay
|
|
}, event);
|
|
}
|
|
|
|
function requestDelete() {
|
|
if (!event) return;
|
|
if (!confirmingDelete) {
|
|
setConfirmingDelete(true);
|
|
return;
|
|
}
|
|
void onDelete(event);
|
|
}
|
|
|
|
return (
|
|
<Dialog
|
|
open
|
|
title={event ? "Edit event" : "New event"}
|
|
className="calendar-event-dialog"
|
|
footerClassName="calendar-event-dialog-footer"
|
|
closeDisabled={saving}
|
|
onClose={onCancel}
|
|
footer={(
|
|
<>
|
|
<div>
|
|
{event && canDelete && (
|
|
<Button type="button" variant="danger" onClick={requestDelete} disabled={saving}>
|
|
<Trash2 size={16} /> {confirmingDelete ? "Confirm delete" : "Delete"}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="calendar-dialog-actions">
|
|
<Button type="button" onClick={onCancel} disabled={saving}>Cancel</Button>
|
|
<Button type="submit" form={formId} variant="primary" disabled={saving || !canWrite}>{saving ? "Saving..." : "Save"}</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
>
|
|
<form id={formId} className="calendar-dialog-form" onSubmit={submit}>
|
|
{formError && <p className="calendar-form-error">{formError}</p>}
|
|
<label>
|
|
<span>Title</span>
|
|
<input value={summary} onChange={(item) => setSummary(item.target.value)} required maxLength={500} autoFocus disabled={saving || !canWrite} />
|
|
</label>
|
|
<label>
|
|
<span>Calendar</span>
|
|
<select value={calendarId} onChange={(item) => setCalendarId(item.target.value)} required disabled={saving || !canWrite}>
|
|
{calendars.map((calendar) => (
|
|
<option key={calendar.id} value={calendar.id}>{calendar.name}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>Description</span>
|
|
<textarea value={description} onChange={(item) => setDescription(item.target.value)} rows={4} disabled={saving || !canWrite} />
|
|
</label>
|
|
<label>
|
|
<span>Location</span>
|
|
<input value={location} onChange={(item) => setLocation(item.target.value)} maxLength={500} disabled={saving || !canWrite} />
|
|
</label>
|
|
<ToggleSwitch label="Whole day" checked={allDay} disabled={saving || !canWrite} onChange={handleAllDayChange} />
|
|
<div className="calendar-dialog-date-row">
|
|
<label>
|
|
<span>Start date</span>
|
|
<input type="date" value={startDate} onChange={(item) => handleStartDateChange(item.target.value)} required disabled={saving || !canWrite} />
|
|
</label>
|
|
<label>
|
|
<span>Start time</span>
|
|
<input type="time" value={startTime} onChange={(item) => handleStartTimeChange(item.target.value)} disabled={saving || !canWrite || allDay} />
|
|
</label>
|
|
</div>
|
|
<div className="calendar-dialog-date-row">
|
|
<label>
|
|
<span>End date</span>
|
|
<input type="date" value={endDate} min={startDate} onChange={(item) => setEndDate(item.target.value)} required disabled={saving || !canWrite} />
|
|
</label>
|
|
<label>
|
|
<span>End time</span>
|
|
<input type="time" value={endTime} min={!allDay && startDate === endDate ? startTime : undefined} onChange={(item) => setEndTime(item.target.value)} disabled={saving || !canWrite || allDay} />
|
|
</label>
|
|
</div>
|
|
</form>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
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) };
|
|
}
|
|
|
|
function daysForMode(mode: CalendarMode, focusDate: Date, continuousWeeks: { before: number; after: number }): Date[] {
|
|
const range = rangeForMode(mode, focusDate, continuousWeeks);
|
|
return daysBetween(range.start, range.end);
|
|
}
|
|
|
|
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)));
|
|
}
|
|
return grouped;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function compareCalendars(left: CalendarCollection, right: CalendarCollection): number {
|
|
if (left.is_default !== right.is_default) return left.is_default ? -1 : 1;
|
|
return left.name.localeCompare(right.name);
|
|
}
|
|
|
|
function calendarDeleteActionLabel(calendar: CalendarCollection): string {
|
|
return calendarIsExternal(calendar) ? "Remove" : "Delete";
|
|
}
|
|
|
|
function calendarEventDeleteOptionLabel(calendar: CalendarCollection): string {
|
|
return calendarIsExternal(calendar) ? "Remove local events with this calendar" : "Delete events with this calendar";
|
|
}
|
|
|
|
function calendarDeleteWarning(
|
|
calendar: CalendarCollection,
|
|
eventCount: number,
|
|
eventAction: CalendarDeleteEventAction,
|
|
targetCalendarId: string,
|
|
moveTargets: CalendarCollection[]
|
|
): string {
|
|
const action = calendarIsExternal(calendar) ? "Removing" : "Deleting";
|
|
const eventWord = eventCount === 1 ? "event" : "events";
|
|
if (eventAction === "move") {
|
|
const target = moveTargets.find((item) => item.id === targetCalendarId);
|
|
return `${action} this calendar will move ${eventCount} ${eventWord} to ${target?.name || "the selected calendar"}.`;
|
|
}
|
|
if (calendarIsExternal(calendar)) {
|
|
return `${action} this calendar will remove ${eventCount} local ${eventWord} from GovOPlaN.`;
|
|
}
|
|
return `${action} this calendar will delete ${eventCount} ${eventWord}.`;
|
|
}
|
|
|
|
function calendarIsExternal(calendar: CalendarCollection): boolean {
|
|
const metadata = calendar.metadata;
|
|
if (!metadata || typeof metadata !== "object") return false;
|
|
const sourceKind = metadataText(metadata, "source_kind") || metadataText(metadata, "sourceKind") || metadataText(metadata, "connector_kind") || metadataText(metadata, "connectorKind");
|
|
if (sourceKind && sourceKind !== "local") return true;
|
|
const connectionKind = metadataText(metadata, "connection_kind") || metadataText(metadata, "connectionKind");
|
|
if (connectionKind && connectionKind !== "local") return true;
|
|
return Boolean(
|
|
metadataFlag(metadata, "external") ||
|
|
metadataFlag(metadata, "remote") ||
|
|
metadataFlag(metadata, "sync") ||
|
|
metadataFlag(metadata, "caldav") ||
|
|
metadataText(metadata, "connector_id") ||
|
|
metadataText(metadata, "connectorId") ||
|
|
metadataText(metadata, "sync_href") ||
|
|
metadataText(metadata, "syncHref")
|
|
);
|
|
}
|
|
|
|
function metadataText(metadata: Record<string, unknown>, key: string): string {
|
|
const value = metadata[key];
|
|
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
}
|
|
|
|
function metadataFlag(metadata: Record<string, unknown>, key: string): boolean {
|
|
return metadata[key] === true;
|
|
}
|
|
|
|
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 eventTimeLabel(event: CalendarEvent): string {
|
|
if (event.all_day) return "All day";
|
|
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);
|
|
}
|
|
|
|
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))}`;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function startOfDay(value: Date): Date {
|
|
return new Date(value.getFullYear(), value.getMonth(), value.getDate());
|
|
}
|
|
|
|
function startOfWeek(value: Date): Date {
|
|
const day = value.getDay() || 7;
|
|
return addDays(startOfDay(value), 1 - day);
|
|
}
|
|
|
|
function startOfMonth(value: Date): Date {
|
|
return new Date(value.getFullYear(), value.getMonth(), 1);
|
|
}
|
|
|
|
function addDays(value: Date, days: number): Date {
|
|
const next = new Date(value);
|
|
next.setDate(next.getDate() + days);
|
|
return next;
|
|
}
|
|
|
|
function addMonths(value: Date, months: number): Date {
|
|
const next = new Date(value);
|
|
next.setMonth(next.getMonth() + months);
|
|
return next;
|
|
}
|
|
|
|
function addHours(value: Date, hours: number): Date {
|
|
const next = new Date(value);
|
|
next.setHours(next.getHours() + hours);
|
|
return next;
|
|
}
|
|
|
|
function addMinutes(value: Date, minutes: number): Date {
|
|
const next = new Date(value);
|
|
next.setMinutes(next.getMinutes() + minutes);
|
|
return next;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function dayKey(value: Date): string {
|
|
return toInputDate(value);
|
|
}
|
|
|
|
function sameDay(left: Date, right: Date): boolean {
|
|
return left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth() && left.getDate() === right.getDate();
|
|
}
|
|
|
|
function sameMonth(left: Date, right: Date): boolean {
|
|
return left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth();
|
|
}
|
|
|
|
function toInputDate(value: Date): string {
|
|
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`;
|
|
}
|
|
|
|
function toInputTime(value: Date): string {
|
|
return `${String(value.getHours()).padStart(2, "0")}:${String(value.getMinutes()).padStart(2, "0")}`;
|
|
}
|
|
|
|
function localDateTime(date: string, time: string): Date {
|
|
return new Date(`${date}T${time || "00:00"}:00`);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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";
|
|
}
|
|
}
|
|
|
|
function saveCalendarMode(mode: CalendarMode): void {
|
|
if (typeof window === "undefined") return;
|
|
try {
|
|
window.localStorage.setItem(CALENDAR_MODE_STORAGE_KEY, mode);
|
|
} catch {
|
|
// Storage can fail in private browsing or locked-down deployments; mode still works for the current page.
|
|
}
|
|
}
|
|
|
|
function isCalendarMode(value: unknown): value is CalendarMode {
|
|
return value === "continuous" || value === "month" || value === "week" || value === "workweek" || value === "day";
|
|
}
|
|
|
|
function normalizeHexColor(value: string | null | undefined): string | null {
|
|
if (!value) return null;
|
|
const trimmed = value.trim();
|
|
return /^#[0-9a-fA-F]{6}$/.test(trimmed) ? trimmed.toLowerCase() : null;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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 = parseInt(normalized.slice(1, 3), 16);
|
|
const green = parseInt(normalized.slice(3, 5), 16);
|
|
const blue = 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 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)
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
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 * 60000;
|
|
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 };
|
|
}
|
|
|
|
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 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);
|
|
}
|
|
|
|
function dropSlotMinuteOfDay(event: ReactDragEvent<HTMLElement>): number {
|
|
return Math.min(23 * 60 + 45, Math.max(0, snapMinute(dropMinuteOfDay(event))));
|
|
}
|
|
|
|
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 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")}`;
|
|
}
|
|
|
|
function isWeekend(value: Date): boolean {
|
|
const day = value.getDay();
|
|
return day === 0 || day === 6;
|
|
}
|
|
|
|
function daysBetweenCount(start: Date, end: Date): number {
|
|
return Math.round((startOfDay(end).getTime() - startOfDay(start).getTime()) / 86400000);
|
|
}
|
|
|
|
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")}`;
|
|
}
|
|
|
|
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)));
|
|
}
|
|
|
|
type TimedEventSegment = {
|
|
event: CalendarEvent;
|
|
start: Date;
|
|
end: Date;
|
|
top: number;
|
|
height: number;
|
|
};
|
|
|
|
type TimedEventLayoutItem = TimedEventSegment & {
|
|
column: number;
|
|
columns: number;
|
|
};
|
|
|
|
type TimedOverflowLayoutItem = {
|
|
key: string;
|
|
top: number;
|
|
column: number;
|
|
columns: number;
|
|
events: CalendarEvent[];
|
|
};
|
|
|
|
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)}-${hidden[0].event.id}-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 };
|
|
}
|
|
|
|
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()) / 60000);
|
|
}
|
|
|
|
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)
|
|
};
|
|
}
|
|
|
|
function timedOverflowStyle(item: TimedOverflowLayoutItem): CSSProperties {
|
|
return {
|
|
top: `${item.top}px`,
|
|
left: `calc(${item.column * 100 / item.columns}% + 4px)`,
|
|
width: `calc(${100 / item.columns}% - 8px)`
|
|
};
|
|
}
|
|
|
|
function monthYearLabel(value: Date): string {
|
|
return new Intl.DateTimeFormat(undefined, { month: "long", year: "numeric" }).format(value);
|
|
}
|
|
|
|
function dayMonthLabel(value: Date): string {
|
|
return new Intl.DateTimeFormat(undefined, { day: "2-digit", month: "short" }).format(value);
|
|
}
|
|
|
|
function dayMonthYearLabel(value: Date): string {
|
|
return new Intl.DateTimeFormat(undefined, { day: "2-digit", month: "short", year: "numeric" }).format(value);
|
|
}
|
|
|
|
function agendaDateLabel(value: Date): string {
|
|
return new Intl.DateTimeFormat(undefined, { weekday: "short", day: "2-digit", month: "short", year: "numeric" }).format(value);
|
|
}
|
|
|
|
function weekdayLong(value: Date): string {
|
|
return new Intl.DateTimeFormat(undefined, { weekday: "short" }).format(value);
|
|
}
|
|
|
|
function timeLabel(value: Date): string {
|
|
return new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(value);
|
|
}
|
|
|
|
function errorText(error: unknown): string {
|
|
if (error instanceof Error) return error.message;
|
|
return "Calendar request failed.";
|
|
}
|