feat(webui): complete calendar quick access

This commit is contained in:
2026-08-19 19:48:51 +02:00
parent 734501281e
commit 4bbf80e634
12 changed files with 493 additions and 23 deletions
+2 -1
View File
@@ -472,13 +472,14 @@ export function cancelCalendarMigration(
export function listCalendarEvents(
settings: ApiSettings,
params: { calendar_id?: string; start_at?: string; end_at?: string; expand_recurring?: boolean } = {}
params: { calendar_id?: string; start_at?: string; end_at?: string; expand_recurring?: boolean; limit?: number } = {}
): 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");
if (params.limit) search.set("limit", String(params.limit));
const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<CalendarEventListResponse>(settings, `/api/v1/calendar/events${suffix}`);
}
@@ -21,6 +21,7 @@ import { ToolbarGroup, ActionToolbar,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import { useLocation, useNavigate } from "react-router";
import {
createCalendar,
createCalendarEvent,
@@ -124,6 +125,8 @@ const modeOptions: {id: CalendarMode;label: string;}[] = [
export default function CalendarPage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) {
const location = useLocation();
const navigate = useNavigate();
const [calendars, setCalendars] = useState<CalendarCollection[]>([]);
const [syncSources, setSyncSources] = useState<CalendarSyncSource[]>([]);
const [visibleCalendarIds, setVisibleCalendarIds] = useState<string[]>([]);
@@ -185,6 +188,38 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
void loadCalendars();
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => {
const parameters = new URLSearchParams(location.search);
const focusParameter = parameters.get("focusDate");
const requestedFocus = validCalendarLaunchDate(focusParameter);
if (requestedFocus) setFocusDate(requestedFocus);
if (parameters.get("quickAction") !== "create-event" || loading) return;
const requestedStart = validCalendarLaunchDate(parameters.get("startAt"));
if (canWrite && calendars.length > 0 && targetCalendarId) {
setFocusDate(requestedStart ?? requestedFocus ?? new Date());
setEventDialog({ kind: "create" });
}
parameters.delete("quickAction");
parameters.delete("startAt");
if (requestedStart) parameters.set("focusDate", requestedStart.toISOString());
const search = parameters.toString();
navigate(
{ pathname: location.pathname, search: search ? `?${search}` : "" },
{ replace: true, state: location.state }
);
}, [
calendars.length,
canWrite,
loading,
location.pathname,
location.search,
location.state,
navigate,
targetCalendarId
]);
useEffect(() => {
saveCalendarMode(mode);
}, [mode]);
@@ -1083,6 +1118,12 @@ function calendarRemoteMoveBatchId(calendar: CalendarCollection): string {
return typeof batchId === "string" ? batchId : "";
}
function validCalendarLaunchDate(value: string | null): Date | null {
if (!value) return null;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function calendarViewPreferences(
response: CalendarViewPreferencesResponse
): CalendarViewPreferences {
@@ -0,0 +1,200 @@
import { CalendarDays, ExternalLink, MapPin, Plus } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router";
import {
Button,
DismissibleAlert,
LoadingFrame,
SelectionList,
SelectionListItem,
SelectionListItemContent,
hasScope,
quickAccessLaunchState,
useDashboardWidgetData,
type QuickAccessToolRenderContext
} from "@govoplan/core-webui";
import { listCalendarEvents, type CalendarEvent } from "../../api/calendar";
const AGENDA_DAYS = 21;
const AGENDA_LIMIT = 7;
type Props = Pick<
QuickAccessToolRenderContext,
"settings" | "auth" | "launchContext" | "complete" | "close"
>;
/**
* Calendar-owned, range- and result-bounded agenda. The API applies tenant,
* scope, and collection-visibility checks before any event reaches the rail.
*/
export default function CalendarQuickAccess({
settings,
auth,
launchContext,
complete,
close
}: Props) {
const [selectedKey, setSelectedKey] = useState("");
const rangeStart = useMemo(
() => agendaStart(launchContext.temporalContext),
[
launchContext.temporalContext.validAt,
launchContext.temporalContext.validityMode
]
);
const rangeEnd = useMemo(() => {
const end = new Date(rangeStart);
end.setDate(end.getDate() + AGENDA_DAYS);
return end;
}, [rangeStart]);
const load = useCallback(async () => {
const response = await listCalendarEvents(settings, {
start_at: rangeStart.toISOString(),
end_at: rangeEnd.toISOString(),
expand_recurring: true,
limit: AGENDA_LIMIT
});
return response.events.filter(
(event) => event.status.toUpperCase() !== "CANCELLED"
);
}, [rangeEnd, rangeStart, settings]);
const { data: events, loading, error } = useDashboardWidgetData(load, 0);
const items = events ?? [];
const selected = useMemo(
() => items.find((event) => eventKey(event) === selectedKey) ?? items[0] ?? null,
[items, selectedKey]
);
const canCreate = hasScope(auth, "calendar:event:write");
useEffect(() => {
if (!selectedKey && items[0]) setSelectedKey(eventKey(items[0]));
if (selectedKey && !items.some((event) => eventKey(event) === selectedKey)) {
setSelectedKey(items[0] ? eventKey(items[0]) : "");
}
}, [items, selectedKey]);
function selectForHost(event: CalendarEvent) {
complete({
contractVersion: "1",
outcome: "completed",
action: "selected",
reference: {
ownerModule: "calendar",
kind: "event",
objectId: eventKey(event),
tenantId: event.tenant_id,
label: event.summary,
version: `${event.sequence}:${event.updated_at}`,
path: calendarFocusPath(event.start_at)
}
});
}
return (
<LoadingFrame loading={loading} label="i18n:govoplan-calendar.loading_calendar.7eb8f548">
{error ? (
<DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert>
) : null}
<p className="calendar-quick-range">
i18n:govoplan-calendar.quick_access_range: {rangeLabel(rangeStart, rangeEnd)}
</p>
{items.length ? (
<SelectionList variant="navigation" label="i18n:govoplan-calendar.agenda.891e9d6d">
{items.map((event) => (
<SelectionListItem
key={eventKey(event)}
selected={selected ? eventKey(event) === eventKey(selected) : false}
onClick={() => setSelectedKey(eventKey(event))}
>
<SelectionListItemContent
leading={<CalendarDays size={16} aria-hidden="true" />}
title={event.summary}
description={eventTimeLabel(event)}
/>
</SelectionListItem>
))}
</SelectionList>
) : !loading && !error ? (
<p className="muted">i18n:govoplan-calendar.no_events.e339ba73</p>
) : null}
{selected ? (
<section className="calendar-quick-detail" aria-label="i18n:govoplan-calendar.quick_access_event_details">
<strong>{selected.summary}</strong>
<span>{eventTimeLabel(selected)}</span>
{selected.location ? (
<span><MapPin size={13} aria-hidden="true" /> {selected.location}</span>
) : null}
{selected.description ? <p>{selected.description}</p> : null}
<div className="button-row compact-actions">
<Button variant="primary" onClick={() => selectForHost(selected)}>
i18n:govoplan-calendar.quick_access_select_event
</Button>
<Link
className="btn btn-secondary"
to={calendarFocusPath(selected.start_at)}
state={quickAccessLaunchState(launchContext)}
onClick={() => selectForHost(selected)}
>
<ExternalLink size={15} aria-hidden="true" />
i18n:govoplan-calendar.quick_access_open_calendar
</Link>
</div>
</section>
) : null}
{canCreate ? (
<div className="dashboard-contribution-footer">
<Link
className="btn btn-secondary"
to={calendarCreatePath(rangeStart)}
state={quickAccessLaunchState(launchContext)}
onClick={close}
>
<Plus size={15} aria-hidden="true" />
i18n:govoplan-calendar.new_event.2ef3795c
</Link>
</div>
) : null}
</LoadingFrame>
);
}
function agendaStart(
context: QuickAccessToolRenderContext["launchContext"]["temporalContext"]
): Date {
if (context.validityMode === "at" && context.validAt) {
const parsed = new Date(context.validAt);
if (!Number.isNaN(parsed.getTime())) return parsed;
}
return new Date();
}
function eventKey(event: CalendarEvent): string {
return event.instance_id || event.id;
}
function calendarFocusPath(startAt: string): string {
return `/calendar?focusDate=${encodeURIComponent(startAt)}`;
}
function calendarCreatePath(startAt: Date): string {
return `/calendar?quickAction=create-event&startAt=${encodeURIComponent(startAt.toISOString())}`;
}
function eventTimeLabel(event: CalendarEvent): string {
const start = new Date(event.start_at);
if (event.all_day) {
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(start);
}
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short"
}).format(start);
}
function rangeLabel(start: Date, end: Date): string {
const formatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" });
return `${formatter.format(start)} ${formatter.format(end)}`;
}
+8
View File
@@ -52,6 +52,10 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.calendar_views.9e6b9c2b": "Calendar views",
"i18n:govoplan-calendar.calendar.adab5090": "Calendar",
"i18n:govoplan-calendar.quick_access_description": "Upcoming events across visible calendars.",
"i18n:govoplan-calendar.quick_access_range": "Agenda range",
"i18n:govoplan-calendar.quick_access_event_details": "Event details",
"i18n:govoplan-calendar.quick_access_select_event": "Select event",
"i18n:govoplan-calendar.quick_access_open_calendar": "Open in Calendar",
"i18n:govoplan-calendar.calendars_are_unavailable.f074c862": "Calendars are unavailable.",
"i18n:govoplan-calendar.calendars.94445018": "Calendars",
"i18n:govoplan-calendar.cancel.77dfd213": "Cancel",
@@ -291,6 +295,10 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.calendar_views.9e6b9c2b": "Calendar views",
"i18n:govoplan-calendar.calendar.adab5090": "Kalender",
"i18n:govoplan-calendar.quick_access_description": "Anstehende Termine aus den sichtbaren Kalendern.",
"i18n:govoplan-calendar.quick_access_range": "Agendazeitraum",
"i18n:govoplan-calendar.quick_access_event_details": "Termindetails",
"i18n:govoplan-calendar.quick_access_select_event": "Termin auswählen",
"i18n:govoplan-calendar.quick_access_open_calendar": "Im Kalender öffnen",
"i18n:govoplan-calendar.calendars_are_unavailable.f074c862": "Kalender sind nicht verfügbar.",
"i18n:govoplan-calendar.calendars.94445018": "Calendars",
"i18n:govoplan-calendar.cancel.77dfd213": "Abbrechen",
+2 -9
View File
@@ -10,6 +10,7 @@ import "./styles/calendar.css";
import { generatedTranslations } from "./i18n/generatedTranslations";
import CalendarPicker from "./features/calendar/CalendarPicker";
import UpcomingEventsWidget from "./features/calendar/UpcomingEventsWidget";
import CalendarQuickAccess from "./features/calendar/CalendarQuickAccess";
const CalendarPage = lazy(() => import("./features/calendar/CalendarPage"));
const CalendarSettingsPanel = lazy(
@@ -95,15 +96,7 @@ const calendarQuickAccessTools: QuickAccessToolsUiCapability = {
tools: [
{
id: "calendar.agenda",
render: ({ settings }) => createElement(UpcomingEventsWidget, {
settings,
refreshKey: 0,
configuration: {
maxItems: 7,
daysAhead: 21,
showLocation: true
}
})
render: (context) => createElement(CalendarQuickAccess, context)
}
]
};
+27
View File
@@ -266,6 +266,33 @@
justify-content: center;
}
.calendar-quick-range {
margin: 0 0 10px;
color: var(--muted);
font-size: 12px;
}
.calendar-quick-detail {
display: grid;
gap: 7px;
margin-top: 12px;
border-top: var(--border-line);
padding-top: 12px;
}
.calendar-quick-detail > span,
.calendar-quick-detail > p {
margin: 0;
color: var(--muted);
font-size: 12px;
}
.calendar-quick-detail > span {
display: inline-flex;
align-items: center;
gap: 5px;
}
.calendar-agenda {
flex: 1 1 auto;
min-height: 0;