refactor: harden calendar sync and add dashboard widget

This commit is contained in:
2026-07-29 14:16:28 +02:00
parent 77072d057a
commit 2cb3aca27c
5 changed files with 651 additions and 95 deletions

View File

@@ -25,6 +25,7 @@ from govoplan_core.core.modules import (
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
@@ -204,6 +205,15 @@ manifest = ModuleManifest(
),
),
nav_items=(NavItem(path="/calendar", label="Calendar", icon="calendar", required_any=("calendar:event:read",), order=55),),
view_surfaces=(
ViewSurface(
id="calendar.widget.upcoming",
module_id="calendar",
kind="section",
label="Upcoming events widget",
order=40,
),
),
),
migration_spec=MigrationSpec(
module_id="calendar",

View File

@@ -34,7 +34,7 @@ from govoplan_core.security.credential_envelopes import (
)
from govoplan_core.audit.logging import audit_event
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVReportResult, CalDAVSyncUnsupported, ensure_collection_url
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVObject, CalDAVReportResult, CalDAVSyncUnsupported, ensure_collection_url
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarSyncCredential, CalendarSyncSource
from govoplan_calendar.backend.ical import expand_event_occurrences, parse_vevent, parse_vevents
from govoplan_calendar.backend.runtime import get_registry
@@ -74,6 +74,7 @@ CALENDAR_MODULE_ID = "calendar"
CALENDAR_EVENTS_COLLECTION = "calendar.events"
CALENDAR_EVENT_RESOURCE = "calendar_event"
SOURCE_EVENT_CLEANUP_BATCH_SIZE = 500
REMOTE_SYNC_MAX_ITEMS = 10_000
def calendar_credential_context(
@@ -169,6 +170,36 @@ class CalendarCalDavDueSyncResult:
error: str | None = None
@dataclass(frozen=True, slots=True)
class _CalDAVSyncSnapshot:
source_id: str
tenant_id: str
calendar_id: str
collection_url: str
auth_type: str
username: str | None
credential_ref: str | None
sync_direction: str
conflict_policy: str
sync_token: str | None
ctag: str | None
@dataclass(frozen=True, slots=True)
class _RemoteSyncSnapshot:
source_id: str
tenant_id: str
calendar_id: str
source_kind: str
collection_url: str
auth_type: str
username: str | None
credential_ref: str | None
sync_token: str | None
ctag: str | None
metadata: dict[str, Any]
def slugify(value: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return slug or "calendar"
@@ -1963,8 +1994,16 @@ def sync_source(
bearer_token: str | None = None,
force_full: bool = False,
) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]:
source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id)
if source.source_kind == "caldav":
# Sync is an explicit unit-of-work boundary. Commit configuration changes
# and release any request/authentication transaction before remote I/O.
session.commit()
with Session(bind=session.get_bind()) as preparation_session:
source_kind = get_sync_source(
preparation_session,
tenant_id=tenant_id,
source_id=source_id,
).source_kind
if source_kind == "caldav":
return sync_caldav_source(
session,
tenant_id=tenant_id,
@@ -1974,14 +2013,13 @@ def sync_source(
bearer_token=bearer_token,
force_full=force_full,
)
get_calendar(session, tenant_id=tenant_id, calendar_id=source.calendar_id)
if source.source_kind in {"ics", "webcal"}:
if source_kind in {"ics", "webcal"}:
return sync_ics_source(session, tenant_id=tenant_id, user_id=user_id, source_id=source_id, password=password, bearer_token=bearer_token, force_full=force_full)
if source.source_kind == "graph":
if source_kind == "graph":
return sync_graph_source(session, tenant_id=tenant_id, user_id=user_id, source_id=source_id, bearer_token=bearer_token, force_full=force_full)
if source.source_kind == "ews":
if source_kind == "ews":
return sync_ews_source(session, tenant_id=tenant_id, user_id=user_id, source_id=source_id, password=password, bearer_token=bearer_token, force_full=force_full)
raise CalendarError(f"Unsupported calendar sync source kind: {source.source_kind}")
raise CalendarError(f"Unsupported calendar sync source kind: {source_kind}")
def http_request(
@@ -2131,37 +2169,26 @@ def sync_caldav_source(
bearer_token: str | None = None,
force_full: bool = False,
) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]:
source = get_caldav_source(session, tenant_id=tenant_id, source_id=source_id)
# Serialize inbound application with local desired-state snapshots and
# outbound delivery for this source. The lock intentionally spans the
# REPORT and its application so an older report cannot land after a newer
# outbound write.
source = (
session.query(CalendarSyncSource)
.filter(
CalendarSyncSource.id == source.id,
CalendarSyncSource.tenant_id == tenant_id,
CalendarSyncSource.source_kind == "caldav",
CalendarSyncSource.deleted_at.is_(None),
session.commit()
snapshot, client = _prepare_caldav_sync(
session,
tenant_id=tenant_id,
source_id=source_id,
client=client,
password=password,
bearer_token=bearer_token,
)
.populate_existing()
.with_for_update()
.one()
)
get_calendar(session, tenant_id=tenant_id, calendar_id=source.calendar_id)
client = client or caldav_client_for_source(session, source, password=password, bearer_token=bearer_token)
stats = CalendarCalDavSyncStats()
collection_props = CalDAVReportResult()
source.last_attempt_at = utcnow()
try:
try:
collection_props = client.propfind_collection()
except CalDAVError as exc:
stats.errors.append(f"Collection PROPFIND failed; continuing with REPORT: {exc}")
if source.sync_token and not force_full:
if snapshot.sync_token and not force_full:
try:
report = client.sync_collection(source.sync_token)
report = client.sync_collection(snapshot.sync_token)
stats.used_sync_token = True
except CalDAVSyncUnsupported as exc:
stats.errors.append(f"Sync token REPORT failed; falling back to full sync: {exc}")
@@ -2171,7 +2198,16 @@ def sync_caldav_source(
report = client.list_objects()
stats.full_sync = True
apply_caldav_report(session, source=source, client=client, report=report, stats=stats, user_id=user_id)
report = _hydrate_caldav_report(client, report=report, stats=stats)
source = _lock_caldav_sync_source(session, snapshot)
source.last_attempt_at = utcnow()
apply_caldav_report(
session,
source=source,
report=report,
stats=stats,
user_id=user_id,
)
source.sync_token = report.sync_token or collection_props.sync_token or source.sync_token
source.ctag = report.ctag or collection_props.ctag or source.ctag
_finalize_sync_success(
@@ -2183,10 +2219,262 @@ def sync_caldav_source(
)
return source, stats
except Exception as exc:
_finalize_sync_error(session, source=source, error=exc)
session.rollback()
_record_caldav_sync_error(
session,
snapshot=snapshot,
error=exc,
)
session.commit()
raise
def _prepare_caldav_sync(
session: Session,
*,
tenant_id: str,
source_id: str,
client: CalDAVClient | None,
password: str | None,
bearer_token: str | None,
) -> tuple[_CalDAVSyncSnapshot, CalDAVClient]:
"""Resolve source configuration without retaining a database connection."""
with Session(bind=session.get_bind()) as preparation_session:
source = get_caldav_source(
preparation_session,
tenant_id=tenant_id,
source_id=source_id,
)
get_calendar(
preparation_session,
tenant_id=tenant_id,
calendar_id=source.calendar_id,
)
resolved_client = client or caldav_client_for_source(
preparation_session,
source,
password=password,
bearer_token=bearer_token,
)
snapshot = _CalDAVSyncSnapshot(
source_id=source.id,
tenant_id=source.tenant_id,
calendar_id=source.calendar_id,
collection_url=source.collection_url,
auth_type=source.auth_type,
username=source.username,
credential_ref=source.credential_ref,
sync_direction=source.sync_direction,
conflict_policy=source.conflict_policy,
sync_token=source.sync_token,
ctag=source.ctag,
)
return snapshot, resolved_client
def _lock_caldav_sync_source(
session: Session,
snapshot: _CalDAVSyncSnapshot,
) -> CalendarSyncSource:
source = (
session.query(CalendarSyncSource)
.filter(
CalendarSyncSource.id == snapshot.source_id,
CalendarSyncSource.tenant_id == snapshot.tenant_id,
CalendarSyncSource.source_kind == "caldav",
CalendarSyncSource.deleted_at.is_(None),
)
.populate_existing()
.with_for_update()
.one()
)
current = (
source.calendar_id,
source.collection_url,
source.auth_type,
source.username,
source.credential_ref,
source.sync_direction,
source.conflict_policy,
source.sync_token,
source.ctag,
)
expected = (
snapshot.calendar_id,
snapshot.collection_url,
snapshot.auth_type,
snapshot.username,
snapshot.credential_ref,
snapshot.sync_direction,
snapshot.conflict_policy,
snapshot.sync_token,
snapshot.ctag,
)
if current != expected:
raise CalendarError(
"CalDAV source changed while the remote response was being fetched; retry the sync."
)
return source
def _record_caldav_sync_error(
session: Session,
*,
snapshot: _CalDAVSyncSnapshot,
error: Exception,
) -> None:
try:
source = _lock_caldav_sync_source(session, snapshot)
except Exception:
return
source.last_attempt_at = utcnow()
_finalize_sync_error(session, source=source, error=error)
def _hydrate_caldav_report(
client: CalDAVClient,
*,
report: CalDAVReportResult,
stats: CalendarCalDavSyncStats,
) -> CalDAVReportResult:
objects: list[CalDAVObject] = []
for item in report.objects:
if item.deleted or item.calendar_data is not None:
objects.append(item)
continue
try:
calendar_data = client.fetch_object(item.href)
stats.fetched += 1
objects.append(
CalDAVObject(
href=item.href,
etag=item.etag,
calendar_data=calendar_data,
)
)
except CalDAVNotFound:
objects.append(
CalDAVObject(
href=item.href,
etag=item.etag,
deleted=True,
)
)
stats.errors.append(
"CalDAV object disappeared during sync and was treated as deleted: "
f"{item.href}"
)
return CalDAVReportResult(
objects=objects,
sync_token=report.sync_token,
ctag=report.ctag,
)
def _prepare_remote_sync(
session: Session,
*,
tenant_id: str,
source_id: str,
expected_kinds: set[str],
password: str | None = None,
bearer_token: str | None = None,
) -> tuple[_RemoteSyncSnapshot, dict[str, str]]:
with Session(bind=session.get_bind()) as preparation_session:
source = get_sync_source(
preparation_session,
tenant_id=tenant_id,
source_id=source_id,
)
if source.source_kind not in expected_kinds:
expected = "/".join(sorted(expected_kinds))
raise CalendarError(f"{expected} sync source not found")
get_calendar(
preparation_session,
tenant_id=tenant_id,
calendar_id=source.calendar_id,
)
headers = source_auth_headers(
preparation_session,
source,
password=password,
bearer_token=bearer_token,
)
snapshot = _RemoteSyncSnapshot(
source_id=source.id,
tenant_id=source.tenant_id,
calendar_id=source.calendar_id,
source_kind=source.source_kind,
collection_url=source.collection_url,
auth_type=source.auth_type,
username=source.username,
credential_ref=source.credential_ref,
sync_token=source.sync_token,
ctag=source.ctag,
metadata=dict(source.metadata_ or {}),
)
return snapshot, headers
def _lock_remote_sync_source(
session: Session,
snapshot: _RemoteSyncSnapshot,
) -> CalendarSyncSource:
source = (
session.query(CalendarSyncSource)
.filter(
CalendarSyncSource.id == snapshot.source_id,
CalendarSyncSource.tenant_id == snapshot.tenant_id,
CalendarSyncSource.source_kind == snapshot.source_kind,
CalendarSyncSource.deleted_at.is_(None),
)
.populate_existing()
.with_for_update()
.one()
)
current = (
source.calendar_id,
source.collection_url,
source.auth_type,
source.username,
source.credential_ref,
source.sync_token,
source.ctag,
dict(source.metadata_ or {}),
)
expected = (
snapshot.calendar_id,
snapshot.collection_url,
snapshot.auth_type,
snapshot.username,
snapshot.credential_ref,
snapshot.sync_token,
snapshot.ctag,
snapshot.metadata,
)
if current != expected:
raise CalendarError(
f"{sync_source_label(snapshot.source_kind)} source changed while "
"the remote response was being fetched; retry the sync."
)
return source
def _record_remote_sync_error(
session: Session,
*,
snapshot: _RemoteSyncSnapshot,
error: Exception,
) -> None:
try:
source = _lock_remote_sync_source(session, snapshot)
except Exception:
return
source.last_attempt_at = utcnow()
_finalize_sync_error(session, source=source, error=error)
def sync_ics_source(
session: Session,
*,
@@ -2197,20 +2485,30 @@ def sync_ics_source(
bearer_token: str | None = None,
force_full: bool = False,
) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]:
source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id)
if source.source_kind not in {"ics", "webcal"}:
raise CalendarError("ICS/webcal sync source not found")
session.commit()
snapshot, auth_headers = _prepare_remote_sync(
session,
tenant_id=tenant_id,
source_id=source_id,
expected_kinds={"ics", "webcal"},
password=password,
bearer_token=bearer_token,
)
stats = CalendarCalDavSyncStats(full_sync=True)
source.last_attempt_at = utcnow()
headers = {"Accept": "text/calendar, application/calendar+json;q=0.5, */*;q=0.1", "User-Agent": "govoplan-calendar-sync/0.1"}
headers.update(source_auth_headers(session, source, password=password, bearer_token=bearer_token))
if source.ctag and not force_full:
headers["If-None-Match"] = source.ctag
last_modified = (source.metadata_ or {}).get("last_modified") if isinstance(source.metadata_, dict) else None
headers.update(auth_headers)
if snapshot.ctag and not force_full:
headers["If-None-Match"] = snapshot.ctag
last_modified = snapshot.metadata.get("last_modified")
if isinstance(last_modified, str) and last_modified and not force_full:
headers["If-Modified-Since"] = last_modified
try:
status_code, response_headers, body = http_request(source.collection_url, headers=headers)
status_code, response_headers, body = http_request(
snapshot.collection_url,
headers=headers,
)
source = _lock_remote_sync_source(session, snapshot)
source.last_attempt_at = utcnow()
if status_code == 304:
_finalize_sync_success(session, source=source, stats=stats)
return source, stats
@@ -2230,7 +2528,9 @@ def sync_ics_source(
_finalize_sync_success(session, source=source, stats=stats, mark_calendar=mark_calendar_sync_source)
return source, stats
except Exception as exc:
_finalize_sync_error(session, source=source, error=exc)
session.rollback()
_record_remote_sync_error(session, snapshot=snapshot, error=exc)
session.commit()
raise
@@ -2329,58 +2629,90 @@ def sync_graph_source(
bearer_token: str | None = None,
force_full: bool = False,
) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]:
source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id)
if source.source_kind != "graph":
raise CalendarError("Microsoft Graph sync source not found")
session.commit()
snapshot, auth_headers = _prepare_remote_sync(
session,
tenant_id=tenant_id,
source_id=source_id,
expected_kinds={"graph"},
bearer_token=bearer_token,
)
headers = {"Accept": "application/json", "User-Agent": "govoplan-calendar-graph/0.1", "Prefer": 'outlook.timezone="UTC"'}
headers.update(source_auth_headers(session, source, bearer_token=bearer_token))
stats = CalendarCalDavSyncStats(full_sync=force_full or not bool(source.sync_token), used_sync_token=bool(source.sync_token and not force_full))
source.last_attempt_at = utcnow()
headers.update(auth_headers)
stats = CalendarCalDavSyncStats(
full_sync=force_full or not bool(snapshot.sync_token),
used_sync_token=bool(snapshot.sync_token and not force_full),
)
try:
next_url = source.sync_token if source.sync_token and not force_full else source.collection_url
seen_hrefs: set[str] = set()
next_url = (
snapshot.sync_token
if snapshot.sync_token and not force_full
else snapshot.collection_url
)
provider_items: list[dict[str, Any]] = []
delta_link: str | None = None
for _page in range(50):
next_url = same_origin_http_url(
source.collection_url,
snapshot.collection_url,
next_url,
label="Microsoft Graph continuation URL",
)
_status, _headers, body = http_request(
next_url,
headers=headers,
credential_origin=source.collection_url,
credential_origin=snapshot.collection_url,
)
payload = json.loads(body or "{}")
for item in payload.get("value", []):
href = str(item.get("id") or "")
if not href:
continue
if item.get("@removed"):
stats.deleted += soft_delete_source_href(session, source=source, href=href)
continue
seen_hrefs.add(href)
created, updated, unchanged = import_provider_event(session, source=source, href=href, provider_event=graph_event_payload(item), user_id=user_id)
stats.created += created
stats.updated += updated
stats.unchanged += unchanged
if isinstance(item, dict):
provider_items.append(item)
if len(provider_items) > REMOTE_SYNC_MAX_ITEMS:
raise CalendarError(
"Microsoft Graph sync exceeded the configured item limit."
)
next_link = payload.get("@odata.nextLink")
raw_delta_link = payload.get("@odata.deltaLink")
if raw_delta_link:
delta_link = same_origin_http_url(
source.collection_url,
snapshot.collection_url,
str(raw_delta_link),
label="Microsoft Graph delta URL",
)
if not next_link:
break
next_url = same_origin_http_url(
source.collection_url,
snapshot.collection_url,
str(next_link),
label="Microsoft Graph continuation URL",
)
else:
raise CalendarError("Microsoft Graph sync returned too many pages")
source = _lock_remote_sync_source(session, snapshot)
source.last_attempt_at = utcnow()
seen_hrefs: set[str] = set()
stats.fetched = len(provider_items)
for item in provider_items:
href = str(item.get("id") or "")
if not href:
continue
if item.get("@removed"):
stats.deleted += soft_delete_source_href(
session,
source=source,
href=href,
)
continue
seen_hrefs.add(href)
created, updated, unchanged = import_provider_event(
session,
source=source,
href=href,
provider_event=graph_event_payload(item),
user_id=user_id,
)
stats.created += created
stats.updated += updated
stats.unchanged += unchanged
if stats.full_sync:
stats.deleted += soft_delete_unseen_source_events(session, source=source, seen_hrefs=seen_hrefs)
source.sync_token = delta_link or source.sync_token
@@ -2392,11 +2724,9 @@ def sync_graph_source(
session.flush()
return source, stats
except Exception as exc:
source.last_attempt_at = utcnow()
source.last_status = "error"
source.last_error = str(exc)
schedule_next_caldav_sync(source)
session.flush()
session.rollback()
_record_remote_sync_error(session, snapshot=snapshot, error=exc)
session.commit()
raise
@@ -2493,10 +2823,16 @@ def sync_ews_source(
bearer_token: str | None = None,
force_full: bool = False,
) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]:
source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id)
if source.source_kind != "ews":
raise CalendarError("Exchange Web Services sync source not found")
metadata = source.metadata_ or {}
session.commit()
snapshot, auth_headers = _prepare_remote_sync(
session,
tenant_id=tenant_id,
source_id=source_id,
expected_kinds={"ews"},
password=password,
bearer_token=bearer_token,
)
metadata = snapshot.metadata
window_before = int(metadata.get("sync_window_days_before", 365)) if isinstance(metadata, dict) else 365
window_after = int(metadata.get("sync_window_days_after", 730)) if isinstance(metadata, dict) else 730
now = utcnow()
@@ -2507,12 +2843,26 @@ def sync_ews_source(
"Content-Type": "text/xml; charset=utf-8",
"User-Agent": "govoplan-calendar-ews/0.1",
}
headers.update(source_auth_headers(session, source, password=password, bearer_token=bearer_token))
headers.update(auth_headers)
stats = CalendarCalDavSyncStats(full_sync=True)
source.last_attempt_at = utcnow()
try:
_status, _headers, body = http_request(source.collection_url, method="POST", headers=headers, body=ews_find_item_body(start=start, end=end, mailbox=metadata.get("mailbox") if isinstance(metadata, dict) else None))
_status, _headers, body = http_request(
snapshot.collection_url,
method="POST",
headers=headers,
body=ews_find_item_body(
start=start,
end=end,
mailbox=metadata.get("mailbox"),
),
)
items = parse_ews_calendar_items(body)
if len(items) > REMOTE_SYNC_MAX_ITEMS:
raise CalendarError(
"Exchange Web Services sync exceeded the configured item limit."
)
source = _lock_remote_sync_source(session, snapshot)
source.last_attempt_at = utcnow()
seen_hrefs: set[str] = set()
for item in items:
href = item["href"]
@@ -2532,11 +2882,9 @@ def sync_ews_source(
session.flush()
return source, stats
except Exception as exc:
source.last_attempt_at = utcnow()
source.last_status = "error"
source.last_error = str(exc)
schedule_next_caldav_sync(source)
session.flush()
session.rollback()
_record_remote_sync_error(session, snapshot=snapshot, error=exc)
session.commit()
raise
@@ -2909,7 +3257,23 @@ def _sync_due_sources(
)
if tenant_id is not None:
query = query.filter(CalendarSyncSource.tenant_id == tenant_id)
sources = query.order_by(CalendarSyncSource.next_sync_at.asc(), CalendarSyncSource.created_at.asc()).limit(limit).all()
sources = (
query.order_by(
CalendarSyncSource.next_sync_at.asc(),
CalendarSyncSource.created_at.asc(),
)
.with_for_update(skip_locked=True)
.limit(max(1, min(int(limit), 200)))
.all()
)
# next_sync_at doubles as a recoverable lease. A worker that dies during
# remote I/O leaves the source eligible again after this timeout.
lease_until = due_at + timedelta(minutes=10)
for source in sources:
source.next_sync_at = lease_until
source.last_attempt_at = due_at
session.commit()
results: list[CalendarCalDavDueSyncResult] = []
for source in sources:
previous_status = source.last_status
@@ -2917,10 +3281,17 @@ def _sync_due_sources(
refreshed, stats = _sync_due_source(session, source=source, user_id=user_id, client_factory=client_factory)
results.append(CalendarCalDavDueSyncResult(source_id=refreshed.id, calendar_id=refreshed.calendar_id, status="ok", stats=stats))
_emit_calendar_sync_notification(session, source=refreshed, status="ok", previous_status=previous_status, stats=stats)
session.commit()
except Exception as exc:
session.rollback()
refreshed = get_sync_source(
session,
tenant_id=source.tenant_id,
source_id=source.id,
)
results.append(CalendarCalDavDueSyncResult(source_id=source.id, calendar_id=source.calendar_id, status="error", error=str(exc)))
_emit_calendar_sync_notification(session, source=source, status="error", previous_status=previous_status, error=str(exc))
session.flush()
_emit_calendar_sync_notification(session, source=refreshed, status="error", previous_status=previous_status, error=str(exc))
session.commit()
return results
@@ -2949,7 +3320,7 @@ def apply_caldav_report(
session: Session,
*,
source: CalendarSyncSource,
client: CalDAVClient,
client: CalDAVClient | None = None,
report: CalDAVReportResult,
stats: CalendarCalDavSyncStats,
user_id: str | None,
@@ -2971,13 +3342,9 @@ def apply_caldav_report(
seen_hrefs.add(href)
calendar_data = item.calendar_data
if calendar_data is None:
try:
calendar_data = client.fetch_object(item.href)
stats.fetched += 1
except CalDAVNotFound:
stats.deleted += soft_delete_caldav_href(session, source=source, href=href)
stats.errors.append(f"CalDAV object disappeared during sync and was treated as deleted: {href}")
continue
raise CalendarError(
"CalDAV reports must be hydrated before database application."
)
created, updated, unchanged, deleted = import_caldav_resource(
session,
source=source,

View File

@@ -21,7 +21,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react-router-dom": ">=7.18.2 <8",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.6"

View File

@@ -0,0 +1,110 @@
import { useCallback } from "react";
import { CalendarDays, MapPin } from "lucide-react";
import { Link } from "react-router-dom";
import {
DashboardWidgetList,
DismissibleAlert,
LoadingFrame,
useDashboardWidgetData,
type ApiSettings,
type DashboardWidgetConfiguration
} from "@govoplan/core-webui";
import {
listCalendarEvents,
type CalendarEvent
} from "../../api/calendar";
export default function UpcomingEventsWidget({
settings,
refreshKey,
configuration
}: {
settings: ApiSettings;
refreshKey: number;
configuration: DashboardWidgetConfiguration;
}) {
const maxItems = numberSetting(configuration.maxItems, 5, 1, 12);
const daysAhead = numberSetting(configuration.daysAhead, 14, 1, 90);
const showLocation = configuration.showLocation !== false;
const load = useCallback(async () => {
const start = new Date();
const end = new Date(start);
end.setDate(end.getDate() + daysAhead);
const response = await listCalendarEvents(settings, {
start_at: start.toISOString(),
end_at: end.toISOString()
});
return [...response.events]
.filter((event) => event.status !== "cancelled")
.sort(
(left, right) =>
new Date(left.start_at).getTime() - new Date(right.start_at).getTime()
)
.slice(0, maxItems);
}, [daysAhead, maxItems, settings]);
const { data: events, loading, error } = useDashboardWidgetData(
load,
refreshKey
);
return (
<LoadingFrame loading={loading} label="Loading upcoming calendar events">
{error && (
<DismissibleAlert tone="warning" resetKey={error}>
{error}
</DismissibleAlert>
)}
<DashboardWidgetList
emptyText={`No events in the next ${daysAhead} days.`}
items={(events ?? []).map((event) => ({
id: event.id,
title: event.summary,
detail:
showLocation && event.location ? (
<>
<MapPin size={12} aria-hidden="true" /> {event.location}
</>
) : undefined,
meta: eventTimeLabel(event),
leading: <CalendarDays size={17} aria-hidden="true" />,
to: "/calendar"
}))}
/>
<div className="dashboard-contribution-footer">
<Link className="btn btn-secondary" to="/calendar">
Open calendar
</Link>
</div>
</LoadingFrame>
);
}
function eventTimeLabel(event: CalendarEvent): string {
const start = new Date(event.start_at);
if (event.all_day) {
return new Intl.DateTimeFormat(undefined, {
weekday: "short",
day: "2-digit",
month: "short"
}).format(start);
}
return new Intl.DateTimeFormat(undefined, {
weekday: "short",
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit"
}).format(start);
}
function numberSetting(
value: unknown,
fallback: number,
minimum: number,
maximum: number
): number {
const numeric = typeof value === "number" ? value : Number(value);
return Number.isFinite(numeric)
? Math.max(minimum, Math.min(maximum, Math.floor(numeric)))
: fallback;
}

View File

@@ -1,8 +1,13 @@
import { createElement, lazy } from "react";
import type { CalendarPickerUiCapability, PlatformWebModule } from "@govoplan/core-webui";
import type {
CalendarPickerUiCapability,
DashboardWidgetsUiCapability,
PlatformWebModule
} from "@govoplan/core-webui";
import "./styles/calendar.css";
import { generatedTranslations } from "./i18n/generatedTranslations";
import CalendarPicker from "./features/calendar/CalendarPicker";
import UpcomingEventsWidget from "./features/calendar/UpcomingEventsWidget";
const CalendarPage = lazy(() => import("./features/calendar/CalendarPage"));
@@ -13,6 +18,60 @@ const translations = {
};
const calendarPicker: CalendarPickerUiCapability = { CalendarPicker };
const calendarDashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
id: "calendar.upcoming",
surfaceId: "calendar.widget.upcoming",
title: "Upcoming events",
description: "The next events across visible calendars.",
moduleId: "calendar",
category: "Planning",
order: 40,
defaultVisible: false,
defaultSize: "medium",
supportedSizes: ["medium", "wide"],
anyOf: eventRead,
refreshIntervalMs: 60_000,
defaultConfiguration: {
maxItems: 5,
daysAhead: 14,
showLocation: true
},
configurationFields: [
{
id: "maxItems",
label: "Maximum events",
kind: "number",
min: 1,
max: 12,
step: 1,
required: true
},
{
id: "daysAhead",
label: "Days ahead",
kind: "number",
min: 1,
max: 90,
step: 1,
required: true
},
{
id: "showLocation",
label: "Show locations",
kind: "boolean"
}
],
render: ({ settings, refreshKey, configuration }) =>
createElement(UpcomingEventsWidget, {
settings,
refreshKey,
configuration
})
}
]
};
export const calendarModule: PlatformWebModule = {
id: "calendar",
@@ -21,11 +80,21 @@ export const calendarModule: PlatformWebModule = {
dependencies: ["access"],
optionalDependencies: ["mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"],
translations,
viewSurfaces: [
{
id: "calendar.widget.upcoming",
moduleId: "calendar",
kind: "section",
label: "Upcoming events widget",
order: 40
}
],
navItems: [{ to: "/calendar", label: "i18n:govoplan-calendar.calendar.adab5090", iconName: "calendar", anyOf: eventRead, order: 55 }],
routes: [
{ path: "/calendar", anyOf: eventRead, order: 55, render: ({ settings, auth }) => createElement(CalendarPage, { settings, auth }) }],
uiCapabilities: {
"calendar.picker": calendarPicker
"calendar.picker": calendarPicker,
"dashboard.widgets": calendarDashboardWidgets
}
};