From 4bbf80e634fe49101ea051befb8a71ca65db8c5f Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 19 Aug 2026 19:48:51 +0200 Subject: [PATCH] feat(webui): complete calendar quick access --- src/govoplan_calendar/backend/manifest.py | 15 +- src/govoplan_calendar/backend/router.py | 77 ++++++- src/govoplan_calendar/backend/service.py | 24 ++- .../test_interface_documentation_contract.py | 20 ++ tests/test_service.py | 89 +++++++- webui/src/api/calendar.ts | 3 +- webui/src/features/calendar/CalendarPage.tsx | 41 ++++ .../features/calendar/CalendarQuickAccess.tsx | 200 ++++++++++++++++++ webui/src/i18n/generatedTranslations.ts | 8 + webui/src/module.ts | 11 +- webui/src/styles/calendar.css | 27 +++ webui/tsconfig.calendar-page-tests.json | 1 + 12 files changed, 493 insertions(+), 23 deletions(-) create mode 100644 webui/src/features/calendar/CalendarQuickAccess.tsx diff --git a/src/govoplan_calendar/backend/manifest.py b/src/govoplan_calendar/backend/manifest.py index ba5d653..badc48f 100644 --- a/src/govoplan_calendar/backend/manifest.py +++ b/src/govoplan_calendar/backend/manifest.py @@ -562,8 +562,11 @@ manifest = ModuleManifest( summary="Use Calendar in Meetings and decisions and keep an optional compact agenda beside current work.", body=( "Calendar contributes its workspace to Meetings and decisions. When Quick Access is enabled, " - "the owner-rendered agenda shows authorized upcoming events in the Calendar category and links to the full workspace. " - "View and Quick Access settings affect presentation only; Calendar reauthorizes every event read and action." + "the owner-rendered agenda shows at most seven authorized events across the current or explicitly selected temporal " + "context and links to the full workspace. Event selection returns a typed Calendar reference; accounts with event-write " + "permission can launch the full owner-rendered creation dialog at that date. Calendar rechecks tenant, scope, private " + "collection ownership or group membership, and the requested time range on every read. View and Quick Access settings " + "affect presentation only." ), layer="configured", documentation_types=("user", "admin"), @@ -575,8 +578,10 @@ manifest = ModuleManifest( "summary": "Den Kalender unter Termine und Entscheidungen sowie optional als kompakte Agenda neben der aktuellen Arbeit verwenden.", "body": ( "Calendar ordnet seinen Arbeitsbereich Termine und Entscheidungen zu. Ist der Schnellzugriff aktiviert, " - "zeigt die vom Kalender gerenderte Agenda berechtigte anstehende Termine und verweist auf den vollständigen Arbeitsbereich. " - "Ansichts- und Schnellzugriffseinstellungen ändern nur die Darstellung." + "zeigt die vom Kalender gerenderte Agenda höchstens sieben berechtigte Termine im aktuellen oder ausdrücklich " + "gewählten Zeitkontext. Die Terminauswahl liefert eine typisierte Kalenderreferenz; mit Schreibberechtigung lässt " + "sich der vollständige Termineditor für dieses Datum öffnen. Calendar prüft Mandant, Berechtigung, private " + "Kalendereigentümerschaft beziehungsweise Gruppenmitgliedschaft und Zeitraum bei jedem Abruf erneut." ), } }, @@ -842,6 +847,8 @@ manifest = ModuleManifest( required_any=("calendar:event:read",), order=10, modes=("browse", "create"), + returned_reference_kinds=("calendar.event",), + help_context_id="calendar.quick_access.agenda", ), ), ), diff --git a/src/govoplan_calendar/backend/router.py b/src/govoplan_calendar/backend/router.py index 155a777..02b5cfc 100644 --- a/src/govoplan_calendar/backend/router.py +++ b/src/govoplan_calendar/backend/router.py @@ -221,6 +221,7 @@ def _visible_events_for_delta( calendar_id: str | None, start_at: datetime | None, end_at: datetime | None, + visible_calendar_ids: set[str] | None = None, ) -> list[CalendarEvent]: if not event_ids: return [] @@ -229,6 +230,10 @@ def _visible_events_for_delta( CalendarEvent.id.in_(event_ids), CalendarEvent.deleted_at.is_(None), ) + if visible_calendar_ids is not None: + if not visible_calendar_ids: + return [] + query = query.filter(CalendarEvent.calendar_id.in_(visible_calendar_ids)) if calendar_id: query = query.filter(CalendarEvent.calendar_id == calendar_id) if start_at is not None: @@ -246,8 +251,16 @@ def _full_event_delta_response( calendar_id: str | None, start_at: datetime | None, end_at: datetime | None, + visible_calendar_ids: set[str] | None = None, ) -> CalendarEventDeltaResponse: - events = list_events(session, tenant_id=tenant_id, calendar_id=calendar_id, start_at=start_at, end_at=end_at) + events = list_events( + session, + tenant_id=tenant_id, + calendar_id=calendar_id, + start_at=start_at, + end_at=end_at, + visible_calendar_ids=visible_calendar_ids, + ) return CalendarEventDeltaResponse( events=[_event_response(event) for event in events], deleted=[], @@ -257,6 +270,37 @@ def _full_event_delta_response( ) +def _principal_visible_calendar_ids( + session: Session, + principal: ApiPrincipal, +) -> set[str]: + return { + calendar.id + for calendar in list_calendars( + session, + tenant_id=principal.tenant_id, + user_id=principal.user.id, + group_ids=principal.group_ids, + can_admin=principal.has("calendar:calendar:admin"), + ) + } + + +def _event_entry_matches_visible_calendars( + entry, + visible_calendar_ids: set[str], +) -> bool: + payload = entry.payload or {} + return any( + calendar_id in visible_calendar_ids + for calendar_id in ( + payload.get("calendar_id"), + payload.get("previous_calendar_id"), + ) + if isinstance(calendar_id, str) + ) + + def _event_delta_entries(session: Session, *, tenant_id: str, since: str, limit: int): try: since_sequence = decode_sequence_watermark(since) @@ -858,10 +902,12 @@ def api_list_events( start_at: datetime | None = Query(default=None), end_at: datetime | None = Query(default=None), expand_recurring: bool = Query(default=False), + limit: int | None = Query(default=None, ge=1, le=500), principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "calendar:event:read") + visible_calendar_ids = _principal_visible_calendar_ids(session, principal) if expand_recurring: if start_at is None or end_at is None: raise HTTPException( @@ -878,6 +924,8 @@ def api_list_events( calendar_id=calendar_id, start_at=start_at, end_at=end_at, + visible_calendar_ids=visible_calendar_ids, + limit=limit, ) return CalendarEventListResponse( events=[ @@ -890,7 +938,15 @@ def api_list_events( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) from exc - events = list_events(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, start_at=start_at, end_at=end_at) + events = list_events( + session, + tenant_id=principal.tenant_id, + calendar_id=calendar_id, + start_at=start_at, + end_at=end_at, + visible_calendar_ids=visible_calendar_ids, + limit=limit, + ) return CalendarEventListResponse(events=[_event_response(event) for event in events]) @@ -905,15 +961,18 @@ def api_list_events_delta( session: Session = Depends(get_session), ): _require_scope(principal, "calendar:event:read") + visible_calendar_ids = _principal_visible_calendar_ids(session, principal) if since is None: - return _full_event_delta_response(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, start_at=start_at, end_at=end_at) + return _full_event_delta_response(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, start_at=start_at, end_at=end_at, visible_calendar_ids=visible_calendar_ids) entries, has_more = _event_delta_entries(session, tenant_id=principal.tenant_id, since=since, limit=limit) if entries is None: - return _full_event_delta_response(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, start_at=start_at, end_at=end_at) + return _full_event_delta_response(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, start_at=start_at, end_at=end_at, visible_calendar_ids=visible_calendar_ids) scoped_entries = [ entry for entry in entries - if entry.resource_type == CALENDAR_EVENT_RESOURCE and _event_payload_matches_window(entry, calendar_id=calendar_id, start_at=start_at, end_at=end_at) + if entry.resource_type == CALENDAR_EVENT_RESOURCE + and _event_payload_matches_window(entry, calendar_id=calendar_id, start_at=start_at, end_at=end_at) + and _event_entry_matches_visible_calendars(entry, visible_calendar_ids) ] changed_ids = list(dict.fromkeys(entry.resource_id for entry in scoped_entries if entry.operation != "deleted")) visible_events = _visible_events_for_delta( @@ -923,6 +982,7 @@ def api_list_events_delta( calendar_id=calendar_id, start_at=start_at, end_at=end_at, + visible_calendar_ids=visible_calendar_ids, ) visible_ids = {event.id for event in visible_events} deleted = [ @@ -970,7 +1030,10 @@ def api_get_event( ): _require_scope(principal, "calendar:event:read") try: - return _event_response(get_event(session, tenant_id=principal.tenant_id, event_id=event_id)) + event = get_event(session, tenant_id=principal.tenant_id, event_id=event_id) + if event.calendar_id not in _principal_visible_calendar_ids(session, principal): + raise CalendarError("Calendar event not found") + return _event_response(event) except CalendarError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc @@ -1154,6 +1217,8 @@ def api_export_ics_event( _require_scope(principal, "calendar:event:export") try: event = get_event(session, tenant_id=principal.tenant_id, event_id=event_id) + if event.calendar_id not in _principal_visible_calendar_ids(session, principal): + raise CalendarError("Calendar event not found") except CalendarError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc ics = event.raw_ics or event_to_ics(event) diff --git a/src/govoplan_calendar/backend/service.py b/src/govoplan_calendar/backend/service.py index 3d7ce71..00c76ab 100644 --- a/src/govoplan_calendar/backend/service.py +++ b/src/govoplan_calendar/backend/service.py @@ -3829,15 +3829,25 @@ def list_events( calendar_id: str | None = None, start_at: datetime | None = None, end_at: datetime | None = None, + visible_calendar_ids: Iterable[str] | None = None, + limit: int | None = None, ) -> list[CalendarEvent]: query = session.query(CalendarEvent).filter(CalendarEvent.tenant_id == tenant_id, CalendarEvent.deleted_at.is_(None)) + if visible_calendar_ids is not None: + normalized_calendar_ids = tuple(dict.fromkeys(visible_calendar_ids)) + if not normalized_calendar_ids: + return [] + query = query.filter(CalendarEvent.calendar_id.in_(normalized_calendar_ids)) if calendar_id: query = query.filter(CalendarEvent.calendar_id == calendar_id) if start_at is not None: query = query.filter(or_(CalendarEvent.end_at.is_(None), CalendarEvent.end_at >= normalize_datetime(start_at))) if end_at is not None: query = query.filter(CalendarEvent.start_at <= normalize_datetime(end_at)) - return query.order_by(CalendarEvent.start_at.asc(), CalendarEvent.summary.asc()).all() + query = query.order_by(CalendarEvent.start_at.asc(), CalendarEvent.summary.asc()) + if limit is not None: + query = query.limit(max(1, limit)) + return query.all() def list_event_occurrences( @@ -3847,6 +3857,8 @@ def list_event_occurrences( start_at: datetime, end_at: datetime, calendar_id: str | None = None, + visible_calendar_ids: Iterable[str] | None = None, + limit: int | None = None, ) -> list[dict[str, Any]]: """Return range-bounded events with recurring series fully reconciled.""" @@ -3863,6 +3875,13 @@ def list_event_occurrences( CalendarEvent.tenant_id == tenant_id, CalendarEvent.deleted_at.is_(None), ) + if visible_calendar_ids is not None: + normalized_calendar_ids = tuple(dict.fromkeys(visible_calendar_ids)) + if not normalized_calendar_ids: + return [] + base_query = base_query.filter( + CalendarEvent.calendar_id.in_(normalized_calendar_ids) + ) if calendar_id: base_query = base_query.filter(CalendarEvent.calendar_id == calendar_id) @@ -3991,7 +4010,7 @@ def list_event_occurrences( is_override=is_override, ) ) - return sorted( + sorted_results = sorted( results, key=lambda item: ( item["start_at"], @@ -4000,6 +4019,7 @@ def list_event_occurrences( item["instance_id"], ), ) + return sorted_results[: max(1, limit)] if limit is not None else sorted_results def event_overlaps_range( diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py index f1c068e..6b19483 100644 --- a/tests/test_interface_documentation_contract.py +++ b/tests/test_interface_documentation_contract.py @@ -54,6 +54,26 @@ class CalendarInterfaceDocumentationContractTests(unittest.TestCase): self.assertIn("calendar.outbox", recovery.metadata["help_contexts"]) self.assertIn("reconcile_outbox", recovery.metadata["consequence_classes"]) + quick_tool = get_manifest().frontend.quick_access_tools[0] + self.assertEqual(("calendar.event",), quick_tool.returned_reference_kinds) + self.assertEqual("calendar.quick_access.agenda", quick_tool.help_context_id) + self.assertEqual("/calendar", quick_tool.full_page_path) + + def test_quick_access_is_bounded_temporal_and_owner_launched(self) -> None: + quick_access = ( + REPO_ROOT / "webui/src/features/calendar/CalendarQuickAccess.tsx" + ).read_text(encoding="utf-8") + page = ( + REPO_ROOT / "webui/src/features/calendar/CalendarPage.tsx" + ).read_text(encoding="utf-8") + + self.assertIn("const AGENDA_LIMIT = 7", quick_access) + self.assertIn("launchContext.temporalContext", quick_access) + self.assertIn("limit: AGENDA_LIMIT", quick_access) + self.assertIn('kind: "event"', quick_access) + self.assertIn("quickAccessLaunchState(launchContext)", quick_access) + self.assertIn('parameters.get("quickAction") !== "create-event"', page) + def test_webui_uses_shared_help_guard_and_confirmation_components(self) -> None: event_dialog = (REPO_ROOT / "webui/src/features/calendar/CalendarEventDialog.tsx").read_text(encoding="utf-8") collection_dialog = (REPO_ROOT / "webui/src/features/calendar/CalendarCollectionDialogs.tsx").read_text(encoding="utf-8") diff --git a/tests/test_service.py b/tests/test_service.py index 9c3286e..0c7c336 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -5,8 +5,20 @@ from datetime import datetime, timedelta, timezone from types import SimpleNamespace from unittest.mock import patch +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from govoplan_access.backend.db.models import Account, User +from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent from govoplan_calendar.backend.schemas import CalendarCollectionDeleteRequest, CalendarEventResponse -from govoplan_calendar.backend.service import calendar_is_visible_to_principal, delete_calendar, event_response +from govoplan_calendar.backend.service import ( + calendar_is_visible_to_principal, + delete_calendar, + event_response, + list_event_occurrences, + list_events, +) +from govoplan_core.db.base import Base class FakeSession: @@ -108,6 +120,81 @@ class CalendarVisibilityTests(unittest.TestCase): ) ) + def test_event_queries_apply_visible_calendar_fence_and_limit(self) -> None: + engine = create_engine("sqlite://") + Base.metadata.create_all( + engine, + tables=( + Account.__table__, + User.__table__, + CalendarCollection.__table__, + CalendarEvent.__table__, + ), + ) + session = Session(engine) + start = datetime(2026, 8, 19, 9, tzinfo=timezone.utc) + try: + session.add_all( + ( + CalendarCollection( + id="visible-calendar", + tenant_id="tenant-1", + slug="visible", + name="Visible", + visibility="tenant", + ), + CalendarCollection( + id="private-calendar", + tenant_id="tenant-1", + slug="private", + name="Private", + visibility="private", + owner_type="user", + owner_id="other-user", + ), + CalendarEvent( + id="visible-event", + tenant_id="tenant-1", + calendar_id="visible-calendar", + uid="visible@example.test", + summary="Visible event", + start_at=start, + end_at=start + timedelta(hours=1), + ), + CalendarEvent( + id="private-event", + tenant_id="tenant-1", + calendar_id="private-calendar", + uid="private@example.test", + summary="Private event", + start_at=start + timedelta(hours=2), + end_at=start + timedelta(hours=3), + ), + ) + ) + session.commit() + + events = list_events( + session, + tenant_id="tenant-1", + visible_calendar_ids=("visible-calendar",), + limit=1, + ) + occurrences = list_event_occurrences( + session, + tenant_id="tenant-1", + start_at=start - timedelta(hours=1), + end_at=start + timedelta(days=1), + visible_calendar_ids=("visible-calendar",), + limit=1, + ) + + self.assertEqual(["visible-event"], [event.id for event in events]) + self.assertEqual(["visible-event"], [event["id"] for event in occurrences]) + finally: + session.close() + engine.dispose() + def test_private_calendar_is_hidden_from_other_readers_but_visible_to_admin(self) -> None: calendar = self.calendar() self.assertFalse( diff --git a/webui/src/api/calendar.ts b/webui/src/api/calendar.ts index 67bae1a..013e75b 100644 --- a/webui/src/api/calendar.ts +++ b/webui/src/api/calendar.ts @@ -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 { 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(settings, `/api/v1/calendar/events${suffix}`); } diff --git a/webui/src/features/calendar/CalendarPage.tsx b/webui/src/features/calendar/CalendarPage.tsx index f01ccdc..3fc32bb 100644 --- a/webui/src/features/calendar/CalendarPage.tsx +++ b/webui/src/features/calendar/CalendarPage.tsx @@ -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([]); const [syncSources, setSyncSources] = useState([]); const [visibleCalendarIds, setVisibleCalendarIds] = useState([]); @@ -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 { diff --git a/webui/src/features/calendar/CalendarQuickAccess.tsx b/webui/src/features/calendar/CalendarQuickAccess.tsx new file mode 100644 index 0000000..211c8fc --- /dev/null +++ b/webui/src/features/calendar/CalendarQuickAccess.tsx @@ -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 ( + + {error ? ( + {error} + ) : null} +

+ i18n:govoplan-calendar.quick_access_range: {rangeLabel(rangeStart, rangeEnd)} +

+ + {items.length ? ( + + {items.map((event) => ( + setSelectedKey(eventKey(event))} + > + + ))} + + ) : !loading && !error ? ( +

i18n:govoplan-calendar.no_events.e339ba73

+ ) : null} + + {selected ? ( +
+ {selected.summary} + {eventTimeLabel(selected)} + {selected.location ? ( + + ) : null} + {selected.description ?

{selected.description}

: null} +
+ + selectForHost(selected)} + > +
+
+ ) : null} + + {canCreate ? ( +
+ +
+ ) : null} +
+ ); +} + +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)}`; +} diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index f046f24..9086410 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -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", diff --git a/webui/src/module.ts b/webui/src/module.ts index 4b013ec..6becb3e 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -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) } ] }; diff --git a/webui/src/styles/calendar.css b/webui/src/styles/calendar.css index cc5ea63..a7be786 100644 --- a/webui/src/styles/calendar.css +++ b/webui/src/styles/calendar.css @@ -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; diff --git a/webui/tsconfig.calendar-page-tests.json b/webui/tsconfig.calendar-page-tests.json index 466a469..3680057 100644 --- a/webui/tsconfig.calendar-page-tests.json +++ b/webui/tsconfig.calendar-page-tests.json @@ -36,6 +36,7 @@ "src/features/calendar/CalendarPage.tsx", "src/features/calendar/CalendarSettingsPanel.tsx", "src/features/calendar/UpcomingEventsWidget.tsx", + "src/features/calendar/CalendarQuickAccess.tsx", "src/features/calendar/CalendarViews.tsx", "src/features/calendar/CalendarCollectionDialogs.tsx", "src/features/calendar/CalendarEventDialog.tsx",