Initialize GovOPlaN calendar module

This commit is contained in:
2026-07-07 02:49:44 +02:00
commit 3bcb04ff76
36 changed files with 3410 additions and 0 deletions

View File

@@ -0,0 +1,511 @@
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { CalendarDays, ChevronLeft, ChevronRight, Plus, RefreshCw, X } from "lucide-react";
import { DismissibleAlert, LoadingIndicator, hasScope, type ApiSettings, type AuthInfo } from "@govoplan/core-webui";
import { createCalendarEvent, listCalendarEvents, listCalendars, type CalendarCollection, type CalendarEvent } from "../../api/calendar";
type CalendarMode = "month" | "week" | "workweek" | "day" | "continuous";
type Range = { start: Date; end: Date };
const modeOptions: { id: CalendarMode; label: string }[] = [
{ id: "month", label: "Month" },
{ id: "week", label: "Week" },
{ id: "workweek", label: "Workweek" },
{ id: "day", label: "Day" },
{ id: "continuous", label: "Continuous" }
];
export default function CalendarPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
const [calendars, setCalendars] = useState<CalendarCollection[]>([]);
const [selectedCalendarId, setSelectedCalendarId] = useState("");
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [mode, setMode] = useState<CalendarMode>("month");
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 [createOpen, setCreateOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement | null>(null);
const selectedCalendar = calendars.find((calendar) => calendar.id === selectedCalendarId) ?? calendars[0] ?? null;
const visibleRange = useMemo(() => rangeForMode(mode, focusDate, continuousWeeks), [continuousWeeks, focusDate, mode]);
const days = useMemo(() => daysForMode(mode, focusDate, continuousWeeks), [continuousWeeks, focusDate, mode]);
const eventsByDay = useMemo(() => groupEventsByDay(events), [events]);
const canWrite = hasScope(auth, "calendar:event:write");
const heading = headingForMode(mode, focusDate, visibleRange);
useEffect(() => {
void loadCalendars();
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => {
if (calendars.length) void loadEvents();
}, [calendars.length, selectedCalendarId, visibleRange.start.getTime(), visibleRange.end.getTime()]);
async function loadCalendars() {
setLoading(true);
setError("");
try {
const response = await listCalendars(settings);
setCalendars(response.calendars);
setSelectedCalendarId((current) => current && response.calendars.some((calendar) => calendar.id === current) ? current : response.calendars[0]?.id ?? "");
} catch (err) {
setError(errorText(err));
} finally {
setLoading(false);
}
}
async function loadEvents() {
setLoading(true);
setError("");
try {
const response = await listCalendarEvents(settings, {
calendar_id: selectedCalendarId || undefined,
start_at: visibleRange.start.toISOString(),
end_at: visibleRange.end.toISOString()
});
setEvents(response.events);
} catch (err) {
setError(errorText(err));
setEvents([]);
} finally {
setLoading(false);
}
}
function moveFocus(direction: -1 | 1) {
const daysToMove = mode === "month" ? 31 : mode === "day" ? 1 : 7;
setFocusDate((current) => mode === "month" ? addMonths(current, direction) : addDays(current, direction * daysToMove));
}
function handleContinuousScroll() {
const node = scrollRef.current;
if (!node || mode !== "continuous") return;
if (node.scrollTop < 160) {
const oldHeight = node.scrollHeight;
setContinuousWeeks((current) => ({ ...current, before: current.before + 4 }));
window.requestAnimationFrame(() => {
if (scrollRef.current) scrollRef.current.scrollTop += scrollRef.current.scrollHeight - oldHeight;
});
}
if (node.scrollTop + node.clientHeight > node.scrollHeight - 220) {
setContinuousWeeks((current) => ({ ...current, after: current.after + 4 }));
}
}
async function handleCreate(payload: QuickCreatePayload) {
setSaving(true);
setError("");
try {
await createCalendarEvent(settings, payload);
setCreateOpen(false);
await loadEvents();
} catch (err) {
setError(errorText(err));
} finally {
setSaving(false);
}
}
return (
<main className="calendar-page">
<header className="calendar-toolbar">
<div className="calendar-title-block">
<div className="calendar-title-row">
<CalendarDays size={22} aria-hidden="true" />
<h1>Calendar</h1>
</div>
<span>{heading}</span>
</div>
<div className="calendar-toolbar-actions">
<div className="calendar-icon-group" aria-label="Calendar navigation">
<button type="button" className="calendar-icon-button" title="Previous" aria-label="Previous" onClick={() => moveFocus(-1)}>
<ChevronLeft size={18} />
</button>
<button type="button" className="calendar-text-button" onClick={() => setFocusDate(startOfDay(new Date()))}>Today</button>
<button type="button" className="calendar-icon-button" title="Next" aria-label="Next" onClick={() => moveFocus(1)}>
<ChevronRight size={18} />
</button>
</div>
<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 ? "is-active" : ""}
onClick={() => setMode(option.id)}
>
{option.label}
</button>
))}
</div>
<select className="calendar-select" value={selectedCalendarId} onChange={(event) => setSelectedCalendarId(event.target.value)} aria-label="Calendar">
{calendars.map((calendar) => <option key={calendar.id} value={calendar.id}>{calendar.name}</option>)}
</select>
<button type="button" className="calendar-icon-button" title="Refresh" aria-label="Refresh" onClick={() => void loadEvents()}>
<RefreshCw size={18} />
</button>
{canWrite && (
<button type="button" className="calendar-primary-button" onClick={() => setCreateOpen(true)}>
<Plus size={17} /> New
</button>
)}
</div>
</header>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<section className="calendar-workspace">
<aside className="calendar-sidebar">
<h2>Calendars</h2>
<div className="calendar-list">
{calendars.map((calendar) => (
<button
key={calendar.id}
type="button"
className={calendar.id === selectedCalendarId ? "is-selected" : ""}
onClick={() => setSelectedCalendarId(calendar.id)}
>
<span style={{ backgroundColor: calendar.color || "#0f766e" }} />
{calendar.name}
</button>
))}
</div>
<h2>Agenda</h2>
<div className="calendar-agenda">
{events.slice(0, 12).map((event) => (
<article key={event.id}>
<strong>{event.summary}</strong>
<span>{eventTimeLabel(event)}</span>
</article>
))}
{!events.length && !loading && <p>No events</p>}
</div>
</aside>
<div className="calendar-view-shell">
{loading && <div className="calendar-loading"><LoadingIndicator label="Loading calendar..." /></div>}
{mode === "continuous" ? (
<div className="calendar-continuous" ref={scrollRef} onScroll={handleContinuousScroll}>
<CalendarWeekRows days={days} eventsByDay={eventsByDay} focusDate={focusDate} continuous />
</div>
) : mode === "month" ? (
<CalendarWeekRows days={days} eventsByDay={eventsByDay} focusDate={focusDate} />
) : (
<CalendarTimeGrid days={days} eventsByDay={eventsByDay} />
)}
</div>
</section>
{createOpen && selectedCalendar && (
<QuickCreateDialog
calendar={selectedCalendar}
focusDate={focusDate}
saving={saving}
onCancel={() => setCreateOpen(false)}
onCreate={handleCreate}
/>
)}
</main>
);
}
function CalendarWeekRows({ days, eventsByDay, focusDate, continuous = false }: { days: Date[]; eventsByDay: Map<string, CalendarEvent[]>; focusDate: Date; continuous?: boolean }) {
const weeks = chunk(days, 7);
return (
<div className={continuous ? "calendar-week-rows is-continuous" : "calendar-week-rows"}>
<div className="calendar-weekday-header">
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((label) => <span key={label}>{label}</span>)}
</div>
{weeks.map((week) => (
<div className="calendar-week-row" key={dayKey(week[0])}>
{week.map((day) => {
const key = dayKey(day);
const dayEvents = eventsByDay.get(key) ?? [];
return (
<section key={key} className={`calendar-day-cell ${sameMonth(day, focusDate) ? "" : "is-muted"} ${sameDay(day, new Date()) ? "is-today" : ""}`}>
<header>
<span>{day.getDate()}</span>
{continuous && day.getDate() <= 7 && <small>{monthLabel(day)}</small>}
</header>
<div className="calendar-event-stack">
{dayEvents.slice(0, 4).map((event) => <CalendarEventChip key={event.id} event={event} />)}
{dayEvents.length > 4 && <span className="calendar-more-events">+{dayEvents.length - 4}</span>}
</div>
</section>
);
})}
</div>
))}
</div>
);
}
function CalendarTimeGrid({ days, eventsByDay }: { days: Date[]; eventsByDay: Map<string, CalendarEvent[]> }) {
const hours = Array.from({ length: 12 }, (_item, index) => index + 7);
return (
<div className="calendar-time-grid" style={{ gridTemplateColumns: `72px repeat(${days.length}, minmax(132px, 1fr))` }}>
<div className="calendar-time-corner" />
{days.map((day) => (
<header key={dayKey(day)} className={sameDay(day, new Date()) ? "is-today" : ""}>
<strong>{weekdayLong(day)}</strong>
<span>{dayMonthLabel(day)}</span>
</header>
))}
{hours.map((hour) => (
<TimeRow key={hour} hour={hour} days={days} eventsByDay={eventsByDay} />
))}
</div>
);
}
function TimeRow({ hour, days, eventsByDay }: { hour: number; days: Date[]; eventsByDay: Map<string, CalendarEvent[]> }) {
return (
<>
<div className="calendar-hour-label">{String(hour).padStart(2, "0")}:00</div>
{days.map((day) => {
const events = (eventsByDay.get(dayKey(day)) ?? []).filter((event) => event.all_day || new Date(event.start_at).getHours() === hour);
return (
<div key={`${dayKey(day)}-${hour}`} className="calendar-hour-cell">
{events.map((event) => <CalendarEventChip key={event.id} event={event} compact />)}
</div>
);
})}
</>
);
}
function CalendarEventChip({ event, compact = false }: { event: CalendarEvent; compact?: boolean }) {
return (
<article className={compact ? "calendar-event-chip is-compact" : "calendar-event-chip"}>
<strong>{event.summary}</strong>
{!compact && <span>{eventTimeLabel(event)}</span>}
</article>
);
}
type QuickCreatePayload = {
calendar_id: string;
summary: string;
location?: string | null;
start_at: string;
end_at?: string | null;
all_day: boolean;
};
function QuickCreateDialog({
calendar,
focusDate,
saving,
onCancel,
onCreate
}: {
calendar: CalendarCollection;
focusDate: Date;
saving: boolean;
onCancel: () => void;
onCreate: (payload: QuickCreatePayload) => Promise<void>;
}) {
const [summary, setSummary] = useState("");
const [location, setLocation] = useState("");
const [date, setDate] = useState(toInputDate(focusDate));
const [start, setStart] = useState("09:00");
const [end, setEnd] = useState("10:00");
const [allDay, setAllDay] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const startAt = allDay ? localDateTime(date, "00:00") : localDateTime(date, start);
const endAt = allDay ? addDays(startAt, 1) : localDateTime(date, end);
await onCreate({
calendar_id: calendar.id,
summary,
location: location || null,
start_at: startAt.toISOString(),
end_at: endAt.toISOString(),
all_day: allDay
});
}
return (
<div className="calendar-dialog-backdrop" role="presentation">
<form className="calendar-dialog" onSubmit={submit}>
<header>
<h2>New event</h2>
<button type="button" className="calendar-icon-button" title="Close" aria-label="Close" onClick={onCancel}>
<X size={18} />
</button>
</header>
<label>
<span>Title</span>
<input value={summary} onChange={(event) => setSummary(event.target.value)} required maxLength={500} autoFocus />
</label>
<label>
<span>Location</span>
<input value={location} onChange={(event) => setLocation(event.target.value)} maxLength={500} />
</label>
<label>
<span>Date</span>
<input type="date" value={date} onChange={(event) => setDate(event.target.value)} required />
</label>
<div className="calendar-dialog-row">
<label>
<span>Start</span>
<input type="time" value={start} onChange={(event) => setStart(event.target.value)} disabled={allDay} />
</label>
<label>
<span>End</span>
<input type="time" value={end} onChange={(event) => setEnd(event.target.value)} disabled={allDay} />
</label>
</div>
<label className="calendar-checkbox">
<input type="checkbox" checked={allDay} onChange={(event) => setAllDay(event.target.checked)} />
<span>All day</span>
</label>
<footer>
<button type="button" className="calendar-text-button" onClick={onCancel}>Cancel</button>
<button type="submit" className="calendar-primary-button" disabled={saving}>{saving ? "Saving..." : "Create"}</button>
</footer>
</form>
</div>
);
}
function rangeForMode(mode: CalendarMode, focusDate: Date, continuousWeeks: { before: number; after: number }): Range {
if (mode === "month") {
const start = startOfWeek(startOfMonth(focusDate));
const end = addDays(startOfWeek(addMonths(startOfMonth(focusDate), 1)), 7);
return { start, end };
}
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);
const last = startOfDay(end);
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 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 dayMonthLabel(focusDate);
if (mode === "month") return `${monthLabel(focusDate)} ${focusDate.getFullYear()}`;
if (mode === "continuous") return `${dayMonthLabel(range.start)} - ${dayMonthLabel(addDays(range.end, -1))}`;
return `${dayMonthLabel(range.start)} - ${dayMonthLabel(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 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 localDateTime(date: string, time: string): Date {
return new Date(`${date}T${time || "00:00"}:00`);
}
function monthLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, { month: "long" }).format(value);
}
function dayMonthLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, { day: "2-digit", month: "short" }).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.";
}