Complete recurrence editing and calendar preferences

This commit is contained in:
2026-07-31 05:46:50 +02:00
parent 1e063f51cc
commit f00cff1d8c
23 changed files with 2354 additions and 368 deletions
+79 -1
View File
@@ -51,6 +51,10 @@ export type CalendarEvent = {
created_at: string;
updated_at: string;
metadata?: Record<string, unknown> | null;
instance_id?: string | null;
series_event_id?: string | null;
is_occurrence?: boolean;
is_override?: boolean;
};
export type CalendarCollectionListResponse = { calendars: CalendarCollection[] };
@@ -243,6 +247,29 @@ export type CalendarEventCreatePayload = {
};
export type CalendarEventUpdatePayload = Partial<CalendarEventCreatePayload>;
export type CalendarViewPreferences = {
dim_weekends: boolean;
dim_off_hours: boolean;
workday_start_hour: number;
workday_end_hour: number;
continuous_virtualization: boolean;
continuous_overscan_weeks: number;
alternate_continuous_months: boolean;
overridden_fields: string[];
defaults: Record<string, boolean | number>;
};
export type CalendarViewPreferencesUpdatePayload = Partial<
Pick<
CalendarViewPreferences,
| "dim_weekends"
| "dim_off_hours"
| "workday_start_hour"
| "workday_end_hour"
| "continuous_virtualization"
| "continuous_overscan_weeks"
| "alternate_continuous_months"
>
>;
export function listCalendars(settings: ApiSettings): Promise<CalendarCollectionListResponse> {
return apiFetch<CalendarCollectionListResponse>(settings, "/api/v1/calendar/calendars");
@@ -320,12 +347,13 @@ export function syncCalDavSource(settings: ApiSettings, sourceId: string, payloa
export function listCalendarEvents(
settings: ApiSettings,
params: { calendar_id?: string; start_at?: string; end_at?: string } = {}
params: { calendar_id?: string; start_at?: string; end_at?: string; expand_recurring?: boolean } = {}
): Promise<CalendarEventListResponse> {
const search = new URLSearchParams();
if (params.calendar_id) search.set("calendar_id", params.calendar_id);
if (params.start_at) search.set("start_at", params.start_at);
if (params.end_at) search.set("end_at", params.end_at);
if (params.expand_recurring) search.set("expand_recurring", "true");
const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<CalendarEventListResponse>(settings, `/api/v1/calendar/events${suffix}`);
}
@@ -344,6 +372,10 @@ export function listCalendarEventsDelta(
return apiFetch<CalendarEventDeltaResponse>(settings, `/api/v1/calendar/events/delta${suffix}`);
}
export function getCalendarEvent(settings: ApiSettings, eventId: string): Promise<CalendarEvent> {
return apiFetch<CalendarEvent>(settings, `/api/v1/calendar/events/${eventId}`);
}
export function createCalendarEvent(settings: ApiSettings, payload: CalendarEventCreatePayload): Promise<CalendarEvent> {
return apiFetch<CalendarEvent>(settings, "/api/v1/calendar/events", { method: "POST", body: JSON.stringify(payload) });
}
@@ -355,3 +387,49 @@ export function updateCalendarEvent(settings: ApiSettings, eventId: string, payl
export function deleteCalendarEvent(settings: ApiSettings, eventId: string): Promise<void> {
return apiFetch<void>(settings, `/api/v1/calendar/events/${eventId}`, { method: "DELETE" });
}
export function updateCalendarEventOccurrence(
settings: ApiSettings,
seriesEventId: string,
recurrenceId: string,
payload: CalendarEventUpdatePayload
): Promise<CalendarEvent> {
return apiFetch<CalendarEvent>(
settings,
`/api/v1/calendar/events/${seriesEventId}/occurrence`,
{
method: "PATCH",
body: JSON.stringify({ ...payload, recurrence_id: recurrenceId })
}
);
}
export function deleteCalendarEventOccurrence(
settings: ApiSettings,
seriesEventId: string,
recurrenceId: string
): Promise<CalendarEvent> {
return apiFetch<CalendarEvent>(
settings,
`/api/v1/calendar/events/${seriesEventId}/occurrence`,
{
method: "DELETE",
body: JSON.stringify({ recurrence_id: recurrenceId })
}
);
}
export function getCalendarViewPreferences(settings: ApiSettings): Promise<CalendarViewPreferences> {
return apiFetch<CalendarViewPreferences>(settings, "/api/v1/calendar/preferences/view");
}
export function updateCalendarViewPreferences(
settings: ApiSettings,
payload: CalendarViewPreferencesUpdatePayload
): Promise<CalendarViewPreferences> {
return apiFetch<CalendarViewPreferences>(
settings,
"/api/v1/calendar/preferences/view",
{ method: "PATCH", body: JSON.stringify(payload) }
);
}
@@ -8,6 +8,7 @@ import {
Button,
DateField,
Dialog,
SegmentedControl,
TimeField,
ToggleSwitch,
useUnsavedDraftGuard,
@@ -30,6 +31,7 @@ import {
} from "./calendarViewModel";
type CalendarEventEndMode = "end" | "duration";
type CalendarEventEditScope = "occurrence" | "series";
type CalendarEventAdvancedPayload = Pick<
CalendarEventCreatePayload,
| "organizer"
@@ -49,25 +51,40 @@ export function CalendarEventDialog({
calendars,
defaultCalendarId,
event,
editScope,
canChooseSeries,
seriesLoaded,
focusDate,
saving,
canWrite,
canDelete,
onEditScopeChange,
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<boolean>;onDelete: (event: CalendarEvent) => Promise<void>;}) {
}: {
calendars: CalendarCollection[];
defaultCalendarId: string;
event: CalendarEvent | null;
editScope: CalendarEventEditScope;
canChooseSeries: boolean;
seriesLoaded: boolean;
focusDate: Date;
saving: boolean;
canWrite: boolean;
canDelete: boolean;
onEditScopeChange: (scope: CalendarEventEditScope) => void;
onCancel: () => void;
onSave: (
payload: CalendarEventCreatePayload,
event: CalendarEvent | null,
editScope: CalendarEventEditScope
) => Promise<boolean>;
onDelete: (
event: CalendarEvent,
editScope: CalendarEventEditScope
) => 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;
@@ -231,7 +248,7 @@ export function CalendarEventDialog({
if (uid.trim()) payload.uid = uid.trim();
if (recurrenceId.trim()) payload.recurrence_id = recurrenceId.trim();
}
return onSave(payload, event);
return onSave(payload, event, editScope);
}
function submit(formEvent: FormEvent<HTMLFormElement>) {
@@ -245,7 +262,7 @@ export function CalendarEventDialog({
setConfirmingDelete(true);
return;
}
void onDelete(event);
void onDelete(event, editScope);
}
return (
@@ -273,6 +290,24 @@ export function CalendarEventDialog({
}>
<form id={formId} className="calendar-dialog-form" onSubmit={submit}>
{canChooseSeries && (
<SegmentedControl
ariaLabel="Recurring event edit scope"
value={editScope}
onChange={onEditScopeChange}
width="fill"
size="equal"
disabled={saving}
options={[
{ id: "occurrence", label: "This occurrence" },
{
id: "series",
label: seriesLoaded ? "Whole series" : "Loading series",
disabled: !seriesLoaded
}
]}
/>
)}
{formError && <p className="calendar-form-error">{formError}</p>}
<label>
<span>i18n:govoplan-calendar.title.768e0c1c</span>
@@ -559,4 +594,3 @@ function eventUsesDuration(event: CalendarEvent): boolean {
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+188 -45
View File
@@ -26,14 +26,17 @@ import {
createSyncSource,
deleteCalendar,
deleteCalendarEvent,
deleteCalendarEventOccurrence,
discoverCalDavCalendars,
getCalendarEvent,
getCalendarViewPreferences,
listCalendarEvents,
listCalendarEventsDelta,
listCalendars,
listSyncSources,
syncSyncSource,
updateCalendar,
updateCalendarEvent,
updateCalendarEventOccurrence,
updateSyncSource,
type CalendarCalDavDiscoveryPayload,
type CalendarCollection,
@@ -41,6 +44,7 @@ import {
type CalendarEvent,
type CalendarEventCreatePayload,
type CalendarSyncSource,
type CalendarViewPreferences as CalendarViewPreferencesResponse,
} from
"../../api/calendar";
import {
@@ -66,6 +70,7 @@ import {
agendaDateLabel,
agendaGroupsForDays,
calendarEventColorStyle,
calendarEventInstanceId,
continuousEventWindow,
daysBetweenCount,
daysForMode,
@@ -76,7 +81,6 @@ import {
headingForMode,
loadCalendarMode,
loadCalendarViewPreferences,
mergeCalendarEventDelta,
moveEventToDay,
moveEventToTime,
normalizeHexColor,
@@ -89,10 +93,19 @@ import {
type CalendarDropTarget,
type CalendarMode,
type CalendarResizeEdge,
type CalendarViewPreferences,
type ContinuousViewport,
} from "./calendarViewModel";
type EventDialogState = {kind: "create";} | {kind: "edit";event: CalendarEvent;};
type EventEditScope = "occurrence" | "series";
type EventDialogState =
| { kind: "create" }
| {
kind: "edit";
occurrence: CalendarEvent;
seriesEvent: CalendarEvent | null;
editScope: EventEditScope;
};
const INITIAL_CONTINUOUS_WEEKS = { before: 4, after: 6 };
const modeOptions: {id: CalendarMode;label: string;}[] = [
@@ -109,8 +122,6 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
const [visibleCalendarIds, setVisibleCalendarIds] = useState<string[]>([]);
const [eventCalendarId, setEventCalendarId] = useState("");
const [events, setEvents] = useState<CalendarEvent[]>([]);
const eventsRef = useRef<CalendarEvent[]>([]);
const eventDeltaRef = useRef<{key: string;watermark: string | null;}>({ key: "", watermark: null });
const eventRequestRef = useRef(0);
const [mode, setMode] = useState<CalendarMode>(() => loadCalendarMode());
const [focusDate, setFocusDate] = useState(() => startOfDay(new Date()));
@@ -132,7 +143,9 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
const pendingContinuousScrollDayRef = useRef<Date | null>(null);
const dragActionRef = useRef<CalendarDragAction | null>(null);
const viewPreferences = useMemo(() => loadCalendarViewPreferences(), []);
const [viewPreferences, setViewPreferences] = useState<CalendarViewPreferences>(
() => loadCalendarViewPreferences()
);
const visibleCalendarIdSet = useMemo(() => new Set(visibleCalendarIds), [visibleCalendarIds]);
const visibleEvents = useMemo(() => events.filter((event) => visibleCalendarIdSet.has(event.calendar_id)), [events, visibleCalendarIdSet]);
const syncSourceByCalendarId = useMemo(() => new Map(syncSources.map((source) => [source.calendar_id, source])), [syncSources]);
@@ -152,6 +165,12 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
const canManageCalendars = hasScope(auth, "calendar:calendar:write");
const canDeleteCalendars = hasScope(auth, "calendar:calendar:admin");
const heading = headingForMode(mode, focusDate, visibleRange);
const dialogEvent =
eventDialog?.kind === "edit"
? eventDialog.editScope === "series"
? eventDialog.seriesEvent
: eventDialog.occurrence
: null;
useEffect(() => {
void loadCalendars();
@@ -161,11 +180,35 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
saveCalendarMode(mode);
}, [mode]);
useEffect(() => {
let active = true;
const load = async () => {
try {
const preferences = await getCalendarViewPreferences(settings);
if (active) setViewPreferences(calendarViewPreferences(preferences));
} catch (err) {
if (active) setError(errorText(err));
}
};
const handlePreferenceChange = () => void load();
void load();
window.addEventListener(
"govoplan:calendar-preferences-changed",
handlePreferenceChange
);
return () => {
active = false;
window.removeEventListener(
"govoplan:calendar-preferences-changed",
handlePreferenceChange
);
};
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => {
if (calendars.length) {
void loadEvents();
} else {
eventsRef.current = [];
setEvents([]);
}
}, [calendars.length, eventWindow.start.getTime(), eventWindow.end.getTime()]);
@@ -222,29 +265,17 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
setError("");
const start = eventWindow.start.toISOString();
const end = eventWindow.end.toISOString();
const deltaKey = `${start}|${end}`;
try {
let since = eventDeltaRef.current.key === deltaKey ? eventDeltaRef.current.watermark : null;
let nextEvents = eventDeltaRef.current.key === deltaKey ? eventsRef.current : [];
let response = null;
do {
response = await listCalendarEventsDelta(settings, {
start_at: start,
end_at: end,
since
});
nextEvents = mergeCalendarEventDelta(nextEvents, response);
since = response.watermark || null;
} while (response.has_more && response.watermark);
const response = await listCalendarEvents(settings, {
start_at: start,
end_at: end,
expand_recurring: true
});
if (requestId !== eventRequestRef.current) return;
eventsRef.current = nextEvents;
eventDeltaRef.current = { key: deltaKey, watermark: since };
setEvents(nextEvents);
setEvents(response.events);
} catch (err) {
if (requestId !== eventRequestRef.current) return;
setError(errorText(err));
eventsRef.current = [];
eventDeltaRef.current = { key: "", watermark: null };
setEvents([]);
} finally {
if (requestId === eventRequestRef.current) setLoading(false);
@@ -352,7 +383,6 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
if (calendars.length > 1 || payload.event_action === "move") {
await loadEvents();
} else {
eventsRef.current = [];
setEvents([]);
}
} catch (err) {
@@ -469,15 +499,50 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
window.requestAnimationFrame(() => updateContinuousViewport(node));
}
function openEventEditor(event: CalendarEvent) {
const recurringOccurrence = Boolean(
event.is_occurrence &&
event.series_event_id &&
event.recurrence_id
);
setEventDialog({
kind: "edit",
occurrence: event,
seriesEvent: recurringOccurrence ? null : event,
editScope: recurringOccurrence ? "occurrence" : "series"
});
if (!recurringOccurrence || !event.series_event_id) return;
const instanceId = calendarEventInstanceId(event);
void getCalendarEvent(settings, event.series_event_id)
.then((seriesEvent) => {
setEventDialog((current) =>
current?.kind === "edit" &&
calendarEventInstanceId(current.occurrence) === instanceId
? { ...current, seriesEvent }
: current
);
})
.catch((err) => setError(errorText(err)));
}
function setEventEditScope(scope: EventEditScope) {
setEventDialog((current) =>
current?.kind === "edit" &&
(scope === "occurrence" || current.seriesEvent)
? { ...current, editScope: scope }
: current
);
}
function beginEventDrag(dragEvent: ReactDragEvent<HTMLElement>, action: CalendarDragAction) {
if (!canWrite) {
dragEvent.preventDefault();
return;
}
dragActionRef.current = action;
setDraggingEventId(action.event.id);
setDraggingEventId(calendarEventInstanceId(action.event));
dragEvent.dataTransfer.effectAllowed = "move";
dragEvent.dataTransfer.setData("text/plain", action.event.id);
dragEvent.dataTransfer.setData("text/plain", calendarEventInstanceId(action.event));
}
function handleEventDragStart(dragEvent: ReactDragEvent<HTMLElement>, calendarEvent: CalendarEvent) {
@@ -553,11 +618,25 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
setSaving(true);
setError("");
try {
await updateCalendarEvent(settings, calendarEvent.id, {
const payload = {
start_at: next.startAt.toISOString(),
end_at: next.endAt ? next.endAt.toISOString() : null,
all_day: next.allDay
});
};
if (
calendarEvent.is_occurrence &&
calendarEvent.series_event_id &&
calendarEvent.recurrence_id
) {
await updateCalendarEventOccurrence(
settings,
calendarEvent.series_event_id,
calendarEvent.recurrence_id,
payload
);
} else {
await updateCalendarEvent(settings, calendarEvent.id, payload);
}
await loadEvents();
} catch (err) {
setError(errorText(err));
@@ -576,11 +655,29 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
});
}
async function handleSave(payload: CalendarEventCreatePayload, event: CalendarEvent | null): Promise<boolean> {
async function handleSave(
payload: CalendarEventCreatePayload,
event: CalendarEvent | null,
editScope: EventEditScope
): Promise<boolean> {
setSaving(true);
setError("");
try {
if (event) {
const currentDialog = eventDialog;
if (
event &&
currentDialog?.kind === "edit" &&
editScope === "occurrence" &&
currentDialog.occurrence.series_event_id &&
currentDialog.occurrence.recurrence_id
) {
await updateCalendarEventOccurrence(
settings,
currentDialog.occurrence.series_event_id,
currentDialog.occurrence.recurrence_id,
payload
);
} else if (event) {
await updateCalendarEvent(settings, event.id, payload);
} else {
await createCalendarEvent(settings, payload);
@@ -600,11 +697,28 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
}
}
async function handleDelete(event: CalendarEvent) {
async function handleDelete(
event: CalendarEvent,
editScope: EventEditScope
) {
setSaving(true);
setError("");
try {
await deleteCalendarEvent(settings, event.id);
const currentDialog = eventDialog;
if (
currentDialog?.kind === "edit" &&
editScope === "occurrence" &&
currentDialog.occurrence.series_event_id &&
currentDialog.occurrence.recurrence_id
) {
await deleteCalendarEventOccurrence(
settings,
currentDialog.occurrence.series_event_id,
currentDialog.occurrence.recurrence_id
);
} else {
await deleteCalendarEvent(settings, event.id);
}
setEventDialog(null);
await loadEvents();
} catch (err) {
@@ -690,15 +804,15 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
<h3>{agendaDateLabel(group.date)}</h3>
{group.events.map((event) =>
<button
key={`${group.key}-${event.id}`}
key={`${group.key}-${calendarEventInstanceId(event)}`}
type="button"
className={["calendar-agenda-item", hoveredEventId === event.id ? "is-linked-hover" : ""].filter(Boolean).join(" ")}
className={["calendar-agenda-item", hoveredEventId === calendarEventInstanceId(event) ? "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 })}>
onMouseEnter={() => setHoveredEventId(calendarEventInstanceId(event))}
onMouseLeave={() => setHoveredEventId((current) => current === calendarEventInstanceId(event) ? "" : current)}
onFocus={() => setHoveredEventId(calendarEventInstanceId(event))}
onBlur={() => setHoveredEventId((current) => current === calendarEventInstanceId(event) ? "" : current)}
onClick={() => openEventEditor(event)}>
<EventInlineLabel event={event} />
</button>
@@ -779,7 +893,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
hoveredEventId={hoveredEventId}
dropTarget={dropTarget}
viewport={continuousViewport}
onEventSelect={(event) => setEventDialog({ kind: "edit", event })}
onEventSelect={openEventEditor}
onEventHover={setHoveredEventId}
onEventDragStart={handleEventDragStart}
onEventDragEnd={handleEventDragEnd}
@@ -800,7 +914,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
draggingEventId={draggingEventId}
hoveredEventId={hoveredEventId}
dropTarget={dropTarget}
onEventSelect={(event) => setEventDialog({ kind: "edit", event })}
onEventSelect={openEventEditor}
onEventHover={setHoveredEventId}
onEventDragStart={handleEventDragStart}
onEventDragEnd={handleEventDragEnd}
@@ -819,7 +933,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
draggingEventId={draggingEventId}
hoveredEventId={hoveredEventId}
dropTarget={dropTarget}
onEventSelect={(event) => setEventDialog({ kind: "edit", event })}
onEventSelect={openEventEditor}
onEventHover={setHoveredEventId}
onEventDragStart={handleEventDragStart}
onEventResizeDragStart={handleEventResizeDragStart}
@@ -838,13 +952,28 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
{eventDialog && calendars.length > 0 &&
<CalendarEventDialog
key={
eventDialog.kind === "edit"
? `${calendarEventInstanceId(eventDialog.occurrence)}:${eventDialog.editScope}`
: "new-event"
}
calendars={calendars}
defaultCalendarId={targetCalendarId}
event={eventDialog.kind === "edit" ? eventDialog.event : null}
event={dialogEvent}
editScope={eventDialog.kind === "edit" ? eventDialog.editScope : "series"}
canChooseSeries={
eventDialog.kind === "edit" &&
Boolean(eventDialog.occurrence.is_occurrence)
}
seriesLoaded={
eventDialog.kind !== "edit" ||
eventDialog.seriesEvent !== null
}
focusDate={focusDate}
saving={saving}
canWrite={canWrite}
canDelete={canDelete}
onEditScopeChange={setEventEditScope}
onCancel={() => setEventDialog(null)}
onSave={handleSave}
onDelete={handleDelete} />
@@ -887,3 +1016,17 @@ function compareCalendars(left: CalendarCollection, right: CalendarCollection):
if (left.is_default !== right.is_default) return left.is_default ? -1 : 1;
return left.name.localeCompare(right.name);
}
function calendarViewPreferences(
response: CalendarViewPreferencesResponse
): CalendarViewPreferences {
return {
dimWeekends: response.dim_weekends,
dimOffHours: response.dim_off_hours,
workdayStartHour: response.workday_start_hour,
workdayEndHour: response.workday_end_hour,
continuousVirtualization: response.continuous_virtualization,
continuousOverscanWeeks: response.continuous_overscan_weeks,
alternateContinuousMonths: response.alternate_continuous_months
};
}
@@ -0,0 +1,241 @@
import { useEffect, useMemo, useState } from "react";
import { Save } from "lucide-react";
import {
Button,
Card,
DismissibleAlert,
FormField,
ToggleSwitch,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import {
getCalendarViewPreferences,
updateCalendarViewPreferences,
type CalendarViewPreferences
} from "../../api/calendar";
type CalendarPreferenceDraft = Pick<
CalendarViewPreferences,
| "dim_weekends"
| "dim_off_hours"
| "workday_start_hour"
| "workday_end_hour"
| "continuous_virtualization"
| "continuous_overscan_weeks"
| "alternate_continuous_months"
>;
const FALLBACK_DRAFT: CalendarPreferenceDraft = {
dim_weekends: true,
dim_off_hours: true,
workday_start_hour: 6,
workday_end_hour: 20,
continuous_virtualization: true,
continuous_overscan_weeks: 6,
alternate_continuous_months: true
};
export default function CalendarSettingsPanel({
settings,
auth
}: {
settings: ApiSettings;
auth: AuthInfo;
}) {
const [loaded, setLoaded] = useState<CalendarPreferenceDraft | null>(null);
const [draft, setDraft] = useState<CalendarPreferenceDraft>(FALLBACK_DRAFT);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState("");
const [tone, setTone] = useState<"success" | "warning">("success");
const dirty = useMemo(
() => loaded !== null && JSON.stringify(loaded) !== JSON.stringify(draft),
[draft, loaded]
);
useEffect(() => {
void loadPreferences();
}, [auth.user.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
async function loadPreferences() {
setLoading(true);
setMessage("");
try {
const response = await getCalendarViewPreferences(settings);
const next = preferenceDraft(response);
setLoaded(next);
setDraft(next);
} catch (error) {
setTone("warning");
setMessage(errorText(error));
} finally {
setLoading(false);
}
}
async function savePreferences() {
if (draft.workday_end_hour <= draft.workday_start_hour) {
setTone("warning");
setMessage("Workday end must be after workday start.");
return;
}
setSaving(true);
setMessage("");
try {
const response = await updateCalendarViewPreferences(settings, draft);
const next = preferenceDraft(response);
setLoaded(next);
setDraft(next);
setTone("success");
setMessage("Calendar preferences saved.");
window.dispatchEvent(
new CustomEvent("govoplan:calendar-preferences-changed")
);
} catch (error) {
setTone("warning");
setMessage(errorText(error));
} finally {
setSaving(false);
}
}
const disabled = loading || saving;
return (
<div className="dashboard-grid settings-dashboard-grid calendar-settings-panel">
<Card title="Calendar display">
<div className="form-grid">
<ToggleSwitch
label="Dim weekends"
help="Use a quieter background for Saturday and Sunday in week views."
checked={draft.dim_weekends}
disabled={disabled}
onChange={(dim_weekends) =>
setDraft((current) => ({ ...current, dim_weekends }))
}
/>
<ToggleSwitch
label="Dim hours outside the workday"
checked={draft.dim_off_hours}
disabled={disabled}
onChange={(dim_off_hours) =>
setDraft((current) => ({ ...current, dim_off_hours }))
}
/>
<div className="calendar-dialog-grid-two">
<FormField label="Workday starts">
<input
type="number"
min={0}
max={23}
value={draft.workday_start_hour}
disabled={disabled}
onChange={(event) =>
setDraft((current) => ({
...current,
workday_start_hour: Number(event.target.value)
}))
}
/>
</FormField>
<FormField label="Workday ends">
<input
type="number"
min={1}
max={24}
value={draft.workday_end_hour}
disabled={disabled}
onChange={(event) =>
setDraft((current) => ({
...current,
workday_end_hour: Number(event.target.value)
}))
}
/>
</FormField>
</div>
</div>
</Card>
<Card title="Continuous view">
<div className="form-grid">
<ToggleSwitch
label="Virtualize distant weeks"
help="Keep the continuous calendar responsive by rendering only nearby weeks."
checked={draft.continuous_virtualization}
disabled={disabled}
onChange={(continuous_virtualization) =>
setDraft((current) => ({
...current,
continuous_virtualization
}))
}
/>
<FormField
label="Overscan weeks"
help="Additional weeks rendered above and below the viewport."
>
<input
type="number"
min={2}
max={20}
value={draft.continuous_overscan_weeks}
disabled={disabled || !draft.continuous_virtualization}
onChange={(event) =>
setDraft((current) => ({
...current,
continuous_overscan_weeks: Number(event.target.value)
}))
}
/>
</FormField>
<ToggleSwitch
label="Alternate month backgrounds"
checked={draft.alternate_continuous_months}
disabled={disabled}
onChange={(alternate_continuous_months) =>
setDraft((current) => ({
...current,
alternate_continuous_months
}))
}
/>
<div className="button-row compact-actions">
<Button
type="button"
variant="primary"
disabled={disabled || !dirty}
onClick={() => void savePreferences()}
>
<Save size={16} /> {saving ? "Saving" : "Save preferences"}
</Button>
</div>
{message && (
<DismissibleAlert tone={tone} resetKey={message} floating>
{message}
</DismissibleAlert>
)}
</div>
</Card>
</div>
);
}
function preferenceDraft(
preferences: CalendarViewPreferences
): CalendarPreferenceDraft {
return {
dim_weekends: preferences.dim_weekends,
dim_off_hours: preferences.dim_off_hours,
workday_start_hour: preferences.workday_start_hour,
workday_end_hour: preferences.workday_end_hour,
continuous_virtualization: preferences.continuous_virtualization,
continuous_overscan_weeks: preferences.continuous_overscan_weeks,
alternate_continuous_months: preferences.alternate_continuous_months
};
}
function errorText(error: unknown): string {
return error instanceof Error
? error.message
: "Calendar preferences could not be loaded.";
}
+14 -13
View File
@@ -12,6 +12,7 @@ import {
HOUR_ROW_HEIGHT,
INITIAL_SCROLL_HOUR,
calendarEventColorStyle,
calendarEventInstanceId,
chunk,
continuousVirtualWindow,
dayKey,
@@ -185,12 +186,12 @@ export function CalendarWeekRows({
<div className="calendar-event-stack">
{dayEvents.slice(0, eventLimit).map((event) => (
<CalendarEventChip
key={event.id}
key={calendarEventInstanceId(event)}
event={event}
color={calendarColorById.get(event.calendar_id)}
canDrag={canWrite}
dragging={draggingEventId === event.id}
linkedHover={hoveredEventId === event.id}
dragging={draggingEventId === calendarEventInstanceId(event)}
linkedHover={hoveredEventId === calendarEventInstanceId(event)}
onSelect={onEventSelect}
onHover={onEventHover}
onDragStart={onEventDragStart}
@@ -350,13 +351,13 @@ export function CalendarTimeGrid({
>
{day.events.map((event) => (
<CalendarEventChip
key={event.id}
key={calendarEventInstanceId(event)}
event={event}
color={calendarColorById.get(event.calendar_id)}
compact
canDrag={canWrite}
dragging={draggingEventId === event.id}
linkedHover={hoveredEventId === event.id}
dragging={draggingEventId === calendarEventInstanceId(event)}
linkedHover={hoveredEventId === calendarEventInstanceId(event)}
onSelect={onEventSelect}
onHover={onEventHover}
onDragStart={onEventDragStart}
@@ -503,11 +504,11 @@ function TimedDayColumn({
)}
{layout.items.map((item) => (
<div
key={item.event.id}
key={calendarEventInstanceId(item.event)}
className={[
"calendar-timed-event",
draggingEventId === item.event.id ? "is-dragging" : "",
hoveredEventId === item.event.id
draggingEventId === calendarEventInstanceId(item.event) ? "is-dragging" : "",
hoveredEventId === calendarEventInstanceId(item.event)
? "is-linked-hover"
: "",
]
@@ -518,7 +519,7 @@ function TimedDayColumn({
calendarColorById.get(item.event.calendar_id),
)}
draggable={canWrite}
onMouseEnter={() => onEventHover(item.event.id)}
onMouseEnter={() => onEventHover(calendarEventInstanceId(item.event))}
onMouseLeave={() => onEventHover("")}
onDragStart={
canWrite
@@ -546,7 +547,7 @@ function TimedDayColumn({
type="button"
className="calendar-timed-event-content"
onClick={() => onEventSelect(item.event)}
onFocus={() => onEventHover(item.event.id)}
onFocus={() => onEventHover(calendarEventInstanceId(item.event))}
onBlur={() => onEventHover("")}
title={i18nMessage(
"i18n:govoplan-calendar.edit_value.fad75899",
@@ -629,9 +630,9 @@ function CalendarEventChip({
style={calendarEventColorStyle(color)}
draggable={canDrag}
onClick={() => onSelect(event)}
onMouseEnter={onHover ? () => onHover(event.id) : undefined}
onMouseEnter={onHover ? () => onHover(calendarEventInstanceId(event)) : undefined}
onMouseLeave={onHover ? () => onHover("") : undefined}
onFocus={onHover ? () => onHover(event.id) : undefined}
onFocus={onHover ? () => onHover(calendarEventInstanceId(event)) : undefined}
onBlur={onHover ? () => onHover("") : undefined}
onDragStart={
canDrag && onDragStart
@@ -32,10 +32,11 @@ export default function UpcomingEventsWidget({
end.setDate(end.getDate() + daysAhead);
const response = await listCalendarEvents(settings, {
start_at: start.toISOString(),
end_at: end.toISOString()
end_at: end.toISOString(),
expand_recurring: true
});
return [...response.events]
.filter((event) => event.status !== "cancelled")
.filter((event) => event.status.toUpperCase() !== "CANCELLED")
.sort(
(left, right) =>
new Date(left.start_at).getTime() - new Date(right.start_at).getTime()
@@ -57,7 +58,7 @@ export default function UpcomingEventsWidget({
<DashboardWidgetList
emptyText={`No events in the next ${daysAhead} days.`}
items={(events ?? []).map((event) => ({
id: event.id,
id: event.instance_id || event.id,
title: event.summary,
detail:
showLocation && event.location ? (
@@ -74,7 +74,7 @@ const MAX_TIMED_EVENT_COLUMNS = 3;
const CALENDAR_MODE_STORAGE_KEY = "govoplan.calendar.mode";
const CALENDAR_VIEW_PREFERENCES_STORAGE_KEY =
"govoplan.calendar.viewPreferences";
const DEFAULT_CALENDAR_VIEW_PREFERENCES: CalendarViewPreferences = {
export const DEFAULT_CALENDAR_VIEW_PREFERENCES: CalendarViewPreferences = {
dimWeekends: true,
dimOffHours: true,
workdayStartHour: 6,
@@ -84,6 +84,10 @@ const DEFAULT_CALENDAR_VIEW_PREFERENCES: CalendarViewPreferences = {
alternateContinuousMonths: true,
};
export function calendarEventInstanceId(event: CalendarEvent): string {
return event.instance_id || event.id;
}
export function rangeForMode(
mode: CalendarMode,
focusDate: Date,
@@ -677,7 +681,7 @@ export function layoutTimedEventsForDay(
}
if (hidden.length > 0) {
overflows.push({
key: `${dayKey(day)}-${hidden[0].event.id}-overflow`,
key: `${dayKey(day)}-${calendarEventInstanceId(hidden[0].event)}-overflow`,
top: Math.min(...hidden.map((item) => item.top)),
column: overflowColumn,
columns: MAX_TIMED_EVENT_COLUMNS,
+28 -2
View File
@@ -2,7 +2,8 @@ import { createElement, lazy } from "react";
import type {
CalendarPickerUiCapability,
DashboardWidgetsUiCapability,
PlatformWebModule
PlatformWebModule,
SettingsSectionsUiCapability
} from "@govoplan/core-webui";
import "./styles/calendar.css";
import { generatedTranslations } from "./i18n/generatedTranslations";
@@ -10,6 +11,9 @@ import CalendarPicker from "./features/calendar/CalendarPicker";
import UpcomingEventsWidget from "./features/calendar/UpcomingEventsWidget";
const CalendarPage = lazy(() => import("./features/calendar/CalendarPage"));
const CalendarSettingsPanel = lazy(
() => import("./features/calendar/CalendarSettingsPanel")
);
const eventRead = ["calendar:event:read"];
const translations = {
@@ -18,6 +22,20 @@ const translations = {
};
const calendarPicker: CalendarPickerUiCapability = { CalendarPicker };
const calendarSettingsSections: SettingsSectionsUiCapability = {
sections: [
{
id: "calendar",
surfaceId: "calendar.settings.preferences",
label: "Calendar",
group: "ui",
order: 45,
anyOf: eventRead,
render: ({ settings, auth }) =>
createElement(CalendarSettingsPanel, { settings, auth })
}
]
};
const calendarDashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
@@ -87,6 +105,13 @@ export const calendarModule: PlatformWebModule = {
kind: "section",
label: "Upcoming events widget",
order: 40
},
{
id: "calendar.settings.preferences",
moduleId: "calendar",
kind: "section",
label: "Calendar preferences",
order: 45
}
],
navItems: [{ to: "/calendar", label: "i18n:govoplan-calendar.calendar.adab5090", iconName: "calendar", anyOf: eventRead, order: 55 }],
@@ -94,7 +119,8 @@ export const calendarModule: PlatformWebModule = {
{ path: "/calendar", anyOf: eventRead, order: 55, render: ({ settings, auth }) => createElement(CalendarPage, { settings, auth }) }],
uiCapabilities: {
"calendar.picker": calendarPicker,
"dashboard.widgets": calendarDashboardWidgets
"dashboard.widgets": calendarDashboardWidgets,
"settings.sections": calendarSettingsSections
}
};
+6
View File
@@ -23,13 +23,19 @@
],
"react/jsx-runtime": [
"../../govoplan-core/webui/node_modules/@types/react/jsx-runtime.d.ts"
],
"react-router": [
"../../govoplan-core/webui/node_modules/react-router/dist/development/index.d.ts"
]
}
},
"include": [
"../../govoplan-core/webui/src/vite-env.d.ts",
"src/module.ts",
"src/api/calendar.ts",
"src/features/calendar/CalendarPage.tsx",
"src/features/calendar/CalendarSettingsPanel.tsx",
"src/features/calendar/UpcomingEventsWidget.tsx",
"src/features/calendar/CalendarViews.tsx",
"src/features/calendar/CalendarCollectionDialogs.tsx",
"src/features/calendar/CalendarEventDialog.tsx",