feat(webui): complete calendar quick access
This commit is contained in:
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
+88
-1
@@ -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(
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user