Initialize GovOPlaN calendar module
This commit is contained in:
511
webui/src/features/calendar/CalendarPage.tsx
Normal file
511
webui/src/features/calendar/CalendarPage.tsx
Normal 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.";
|
||||
}
|
||||
Reference in New Issue
Block a user