25 Commits
Author SHA1 Message Date
zemion ccdf12162a Add correlated calendar invitation capability 2026-07-31 22:48:06 +02:00
zemion 001ebd3b6d refactor: target workflow engine runtime 2026-07-31 16:59:21 +02:00
zemion f00cff1d8c Complete recurrence editing and calendar preferences 2026-07-31 05:46:50 +02:00
zemion 1e063f51cc Align calendar WebUI runtime dependencies 2026-07-31 02:48:56 +02:00
zemion b1cf001452 Bound continuous calendar event loading 2026-07-30 05:22:09 +02:00
zemion 56b387a784 refactor: split calendar page components 2026-07-29 19:53:11 +02:00
zemion 4011e61a63 refactor: split calendar source setup orchestration 2026-07-29 19:14:06 +02:00
zemion 2cb3aca27c refactor: harden calendar sync and add dashboard widget 2026-07-29 14:16:28 +02:00
zemion 77072d057a Declare calendar route View surface 2026-07-28 21:04:55 +02:00
zemion 202eb78ae4 Integrate calendar sources with credential envelopes 2026-07-28 19:33:04 +02:00
zemion bf59cd830c test(calendar): commit retirement before engine inspection 2026-07-22 03:21:20 +02:00
zemion 041e2159e7 docs: define CalDAV secret retirement 2026-07-21 15:54:47 +02:00
zemion d64de50802 fix(secrets): redact provider failure details 2026-07-21 15:47:50 +02:00
zemion 7843fe88e5 fix(installer): delete provider secrets before retirement 2026-07-21 15:46:24 +02:00
zemion 39b199d7e6 fix(secrets): fail closed on connector credential deletion 2026-07-21 15:45:43 +02:00
zemion 74495e56cb fix(webui): require Core 0.1.9 for table actions 2026-07-21 13:46:20 +02:00
zemion 6ca1816c95 refactor(webui): centralize Calendar icon actions 2026-07-21 12:36:13 +02:00
zemion 2d7d105ba3 Use explicit outbox source validation 2026-07-21 12:34:34 +02:00
zemion 41b9426670 Refactor calendar event update flow 2026-07-21 12:33:43 +02:00
zemion baf624c7a6 Refactor calendar sync source updates 2026-07-21 12:33:34 +02:00
zemion d10570fc0c Refactor synchronized calendar deletion 2026-07-21 12:33:07 +02:00
zemion 30f6d79f9e Refactor calendar outbox delivery paths 2026-07-21 12:30:03 +02:00
zemion 760a87ab99 Align Calendar runtime version metadata 2026-07-21 12:13:12 +02:00
zemion 7098751f7f Remove caller-managed Calendar secret references 2026-07-21 12:12:35 +02:00
zemion 1df2a1a730 Harden Calendar connector trust boundaries 2026-07-21 12:11:58 +02:00
39 changed files with 9609 additions and 3293 deletions
+21 -7
View File
@@ -40,9 +40,21 @@ The first backend implementation stores VEVENT data in two layers:
CalDAV sync is implemented as a calendar-owned backend primitive. A CalDAV source
records the remote collection URL, sync token, ETag/ctag state, username,
credential reference, sync interval, sync direction, and conflict policy.
Credentials can be supplied transiently for manual sync, referenced from
environment variables with `env:NAME`, stored through a platform secret provider
when one is available, or stored as encrypted calendar-owned credentials.
Credentials can be supplied transiently for manual sync. Persisted API-managed
sources accept only a password or token: Calendar stores an opaque, tenant- and
source-bound local reference, backed by the platform secret provider when one is
available or by an encrypted calendar-owned credential otherwise. Caller-selected
environment and external-provider references are rejected. Trusted deployment
code may resolve an `env:NAME` reference only through the separate deployment
configuration helper.
Deleting a source or its calendar immediately scrubs Calendar-owned ciphertext
and provider references and emits non-secret audit evidence. If an external
secret provider cannot confirm deletion, the operation fails closed without
retiring the source or cancelling its queued work; retry is idempotent after a
database rollback. Destructive module retirement first deletes and audits all
active and legacy retained provider secrets and stops before table removal on
provider failure.
Inbound sync uses CalDAV `calendar-query` for full sync and `sync-collection`
when a sync token exists. It imports all VEVENT components in a resource and
@@ -53,10 +65,12 @@ and the user must sync before retrying. A calendar-owned task,
`govoplan_calendar.sync_due_caldav_sources`, can run due sources in a worker or
cron-style scheduler.
This is not yet a full CalDAV network server. Scheduling inbox/outbox behavior,
CalDAV server endpoints, UI credential management, and full user-facing
recurrence editing remain follow-up work. The backend now includes a free/busy
API primitive for scheduling and appointment modules.
Calendar exposes collection and credential management, sync status/actions,
durable per-user view preferences, recurrence expansion, detached occurrence
overrides, instance/series editing, and a free/busy API primitive for scheduling
and appointment modules. It is not yet a CalDAV network server: external clients
cannot use GovOPlaN itself as their CalDAV endpoint, and scheduling inbox/outbox
delivery remains owned by the scheduling and mail integration work.
## Development
+15 -6
View File
@@ -6,7 +6,7 @@
- calendar collections
- VEVENT storage and iCalendar import/export
- event recurrence data, recurrence exceptions, and future recurrence expansion
- event recurrence data, recurrence expansion, and recurrence exceptions
- availability and free/busy semantics
- resources such as rooms, shared equipment, and service desks
- groupware calendar adapter boundaries, including CalDAV and Open-Xchange
@@ -22,7 +22,10 @@ The first standalone module provides:
- normalized query fields: start, end, summary, location, all-day flag, status, transparency, classification, calendar ID, UID, recurrence ID, sequence, source, and ETag
- iCalendar preservation: raw VEVENT properties, parameters, and generated `text/calendar` export
- API endpoints for listing calendars, creating/updating/deleting events, importing iCalendar, and exporting event ICS
- free/busy API primitive with recurrence expansion for scheduling and appointment conflict checks
- bounded occurrence expansion with RRULE, RDATE, EXDATE, detached overrides,
instance/series editing, and free/busy reconciliation
- durable per-user calendar view preferences exposed through the platform
Settings surface
- calendar-owned CalDAV sync sources with credential references, scheduled due-sync metadata, full/incremental inbound sync, two-way PUT/DELETE writes, and ETag conflict handling
- a Calendar-owned durable desired-state outbox for two-way CalDAV writes. Event
state and the exact external resource snapshot commit atomically; workers use
@@ -30,7 +33,10 @@ The first standalone module provides:
exponential retry, and semantic GET reconciliation after ambiguous outcomes
- WebUI views: month, week, workweek, day, and continuous week-row scrolling
The first implementation is not yet a full CalDAV network server. It is the internal calendar storage, sync, availability, and UI foundation on which Open-Xchange integration, richer recurrence editing, scheduling inbox/outbox behavior, and CalDAV server endpoints can be built.
The implementation is not yet a full CalDAV network server. It is the internal
calendar storage, recurrence, sync, availability, and UI foundation on which
Open-Xchange integration, scheduling inbox/outbox behavior, and CalDAV server
endpoints can be built.
## Outbound delivery operations
@@ -69,6 +75,12 @@ bindings or unresolved delivery. Credential rotation does not discard committed
desired state. Public event mutation cannot set sync-owned source hrefs, kinds,
or ETags. Deleting a synchronized collection or retiring its source is a local
unlink: it never deletes the remote collection or its remaining remote events.
The unlink immediately scrubs Calendar-owned credential ciphertext and external
provider references and audits the deletion. External provider failure blocks
retirement before queued work is changed; a later retry tolerates a provider
secret already removed by an earlier attempt whose database transaction rolled
back. Provider errors and audit details never contain credential values or
secret references.
The current singular ownership model permits one active sync source per
calendar; multi-source fan-in will require per-event source routing. Celery beat
triggers recovery every minute, while root-transaction after-commit dispatch
@@ -191,9 +203,6 @@ The connector boundary should hand calendar a configured profile and credentials
## Follow-Up Work
- Full recurrence expansion for RRULE, RDATE, EXDATE, RECURRENCE-ID, overridden instances, and detached instances.
- User-facing recurrence editing for RRULE, RDATE, EXDATE, RECURRENCE-ID, overridden instances, and detached instances.
- UI management for CalDAV credentials, sync status, sync direction, and conflict resolution.
- CalDAV server endpoints if GovOPlaN should expose calendars to external clients rather than only syncing remote collections.
- Open-Xchange adapter that maps OX calendars, attendees, resources, recurrence, and free/busy to the internal calendar model.
- Attendee RSVP workflow and mail/notification bridge.
+1 -1
View File
@@ -19,7 +19,7 @@
"LICENSE"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+1 -1
View File
@@ -11,7 +11,7 @@ requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.8",
"govoplan-core>=0.1.9",
"govoplan-access>=0.1.8",
"defusedxml>=0.7,<1",
"icalendar>=7.2",
+1 -1
View File
@@ -2,4 +2,4 @@
__all__ = ["__version__"]
__version__ = "0.1.4"
__version__ = "0.1.8"
+23 -5
View File
@@ -9,6 +9,12 @@ from dataclasses import dataclass, field
from typing import Any, Mapping, Protocol
from defusedxml import ElementTree as SafeElementTree
from govoplan_core.security.outbound_http import (
OutboundHttpError,
bounded_response_bytes,
build_outbound_http_opener,
validate_outbound_http_url,
)
class CalDAVError(RuntimeError):
@@ -353,20 +359,31 @@ class CalDAVClient:
def urllib_transport(method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]:
url = validate_http_url(url)
try:
url = validate_outbound_http_url(url, label="CalDAV URL")
request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined.
url,
data=body,
headers=dict(headers),
method=method,
)
opener = urllib.request.build_opener(_SameOriginRedirectHandler(url))
opener = build_outbound_http_opener(_SameOriginRedirectHandler(url))
with opener.open(request, timeout=timeout) as response: # noqa: S310 - validated CalDAV URL; redirects remain on origin. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
return response.status, dict(response.headers.items()), response.read()
response_headers = dict(response.headers.items())
return response.status, response_headers, bounded_response_bytes(
response,
headers=response_headers,
label="CalDAV response",
)
except urllib.error.HTTPError as exc:
return exc.code, dict(exc.headers.items()), exc.read()
response_headers = dict(exc.headers.items())
try:
payload = bounded_response_bytes(exc, headers=response_headers, label="CalDAV error response")
except OutboundHttpError as policy_exc:
raise CalDAVError(f"{method} {url} failed: {policy_exc}") from policy_exc
return exc.code, response_headers, payload
except urllib.error.URLError as exc:
raise CalDAVError(f"{method} {url} failed: {exc.reason}") from exc
except ValueError as exc:
except (OutboundHttpError, ValueError) as exc:
raise CalDAVError(f"{method} {url} failed: {exc}") from exc
@@ -542,7 +559,8 @@ class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
del fp, msg, headers
try:
candidate = validate_http_url(newurl)
except CalDAVError:
candidate = validate_outbound_http_url(candidate, label="CalDAV redirect URL")
except (CalDAVError, OutboundHttpError):
return None
if _url_origin(urllib.parse.urlparse(candidate)) != self._source_origin:
return None
+360 -9
View File
@@ -1,16 +1,163 @@
from __future__ import annotations
import hashlib
from collections.abc import Mapping, Sequence
from datetime import datetime
from sqlalchemy.orm import Session
from govoplan_core.core.calendar import (
CalendarCapabilityError,
CalendarEventRef,
CalendarEventRequest,
CalendarInvitationAttendeeRequest,
CalendarInvitationProvider,
CalendarInvitationRef,
CalendarInvitationRequest,
CalendarSchedulingProvider,
)
from govoplan_calendar.backend.schemas import CalendarEventCreateRequest
from govoplan_calendar.backend.service import CalendarError, create_event, list_freebusy
from govoplan_core.security.time import utc_now
from govoplan_calendar.backend.db.models import CalendarEvent
from govoplan_calendar.backend.schemas import (
CalendarEventCreateRequest,
CalendarEventUpdateRequest,
)
from govoplan_calendar.backend.service import (
CalendarError,
create_event,
list_freebusy,
update_event,
)
_PARTICIPATION_STATUSES = {
"NEEDS-ACTION",
"ACCEPTED",
"DECLINED",
"TENTATIVE",
"DELEGATED",
}
def _external_state(event: CalendarEvent) -> tuple[str, str | None]:
caldav_metadata = (event.metadata_ or {}).get("caldav")
if not isinstance(caldav_metadata, dict):
return "local", None
state = str(caldav_metadata.get("external_state") or "queued")
operation_id = caldav_metadata.get("outbox_operation_id")
return state, str(operation_id) if operation_id else None
def _attendee_address(item: Mapping[str, object]) -> str:
raw = item.get("email") or item.get("address") or item.get("value") or ""
value = str(raw).strip()
if value.casefold().startswith("mailto:"):
value = value[7:]
return value.casefold()
def _attendee_record(
attendee: CalendarInvitationAttendeeRequest,
) -> dict[str, object]:
status = attendee.participation_status.strip().upper()
if status not in _PARTICIPATION_STATUSES:
raise CalendarCapabilityError(
f"Unsupported attendee participation status: {status}"
)
params: dict[str, list[str]] = {
"ROLE": [attendee.role.strip().upper() or "REQ-PARTICIPANT"],
"PARTSTAT": [status],
"RSVP": ["TRUE" if attendee.rsvp else "FALSE"],
}
if attendee.name:
params["CN"] = [attendee.name]
return {
"value": f"mailto:{attendee.address.strip()}",
"params": params,
"response_at": None,
}
def _attendee_status(item: Mapping[str, object]) -> str:
direct = item.get("participation_status") or item.get("response_status")
if direct:
return str(direct).upper()
params = item.get("params")
if isinstance(params, Mapping):
value = params.get("PARTSTAT")
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
return str(value[0]).upper() if value else "NEEDS-ACTION"
if value:
return str(value).upper()
return "NEEDS-ACTION"
def _merge_attendees(
current: Sequence[Mapping[str, object]],
requested: Sequence[CalendarInvitationAttendeeRequest],
) -> list[dict[str, object]]:
prior = {_attendee_address(item): item for item in current}
merged: list[dict[str, object]] = []
for attendee in requested:
record = _attendee_record(attendee)
existing = prior.get(attendee.address.strip().casefold())
if existing is not None and _attendee_status(existing) != "NEEDS-ACTION":
params = dict(record["params"])
params["PARTSTAT"] = [_attendee_status(existing)]
record["params"] = params
record["response_at"] = existing.get("response_at")
if existing.get("response_evidence") is not None:
record["response_evidence"] = existing["response_evidence"]
merged.append(record)
return merged
def _invitation_uid(request: CalendarInvitationRequest) -> str:
identity = ":".join(
(
request.source_module,
request.source_resource_type,
request.source_resource_id or "",
request.correlation_id,
)
)
digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:40]
return f"invitation-{digest}@govoplan.local"
def _invitation_metadata(
request: CalendarInvitationRequest,
previous: Mapping[str, object] | None = None,
) -> dict[str, object]:
metadata = dict(previous or {})
metadata.update(dict(request.metadata))
metadata["calendar_invitation"] = {
"correlation_id": request.correlation_id,
"source_module": request.source_module,
"source_resource_type": request.source_resource_type,
"source_resource_id": request.source_resource_id,
}
return metadata
def _invitation_ref(event: CalendarEvent) -> CalendarInvitationRef:
state, operation_id = _external_state(event)
return CalendarInvitationRef(
event_id=event.id,
calendar_id=event.calendar_id,
uid=event.uid,
correlation_id=event.correlation_key or "",
source_module=event.producer_module or "unknown",
source_resource_type=event.producer_resource_type or "unknown",
source_resource_id=event.producer_resource_id,
attendees=tuple(dict(item) for item in event.attendees or []),
external_state=state,
outbox_operation_id=operation_id,
degraded_reasons=(
"Recurring campaign invitations require a separate series workflow.",
"Mail-delivered iCalendar replies require a Mail adapter to call record_response.",
),
)
class SqlCalendarSchedulingProvider(CalendarSchedulingProvider):
@@ -66,13 +213,7 @@ class SqlCalendarSchedulingProvider(CalendarSchedulingProvider):
event = create_event(session, tenant_id=tenant_id, user_id=user_id, payload=payload)
except CalendarError as exc:
raise CalendarCapabilityError(str(exc)) from exc
caldav_metadata = (event.metadata_ or {}).get("caldav")
external_state = "local"
outbox_operation_id = None
if isinstance(caldav_metadata, dict):
external_state = str(caldav_metadata.get("external_state") or "queued")
raw_operation_id = caldav_metadata.get("outbox_operation_id")
outbox_operation_id = str(raw_operation_id) if raw_operation_id else None
external_state, outbox_operation_id = _external_state(event)
return CalendarEventRef(
id=event.id,
calendar_id=event.calendar_id,
@@ -80,3 +221,213 @@ class SqlCalendarSchedulingProvider(CalendarSchedulingProvider):
external_state=external_state,
outbox_operation_id=outbox_operation_id,
)
class SqlCalendarInvitationProvider(CalendarInvitationProvider):
def upsert_invitation(
self,
session: object,
*,
tenant_id: str,
user_id: str | None,
request: CalendarInvitationRequest,
) -> CalendarInvitationRef:
db = self._session(session)
self._validate_request(request)
existing = (
db.query(CalendarEvent)
.filter(
CalendarEvent.tenant_id == tenant_id,
CalendarEvent.correlation_key == request.correlation_id,
CalendarEvent.deleted_at.is_(None),
)
.one_or_none()
)
attendees = _merge_attendees(
existing.attendees if existing is not None else (),
request.attendees,
)
metadata = _invitation_metadata(
request,
existing.metadata_ if existing is not None else None,
)
try:
if existing is None:
event = create_event(
db,
tenant_id=tenant_id,
user_id=user_id,
payload=CalendarEventCreateRequest(
calendar_id=request.calendar_id,
uid=_invitation_uid(request),
summary=request.summary,
description=request.description,
location=request.location,
classification=request.classification,
start_at=request.start_at,
end_at=request.end_at,
timezone=request.timezone,
organizer=(
dict(request.organizer) if request.organizer else None
),
attendees=attendees,
categories=list(request.categories),
metadata=metadata,
),
)
else:
event = update_event(
db,
tenant_id=tenant_id,
user_id=user_id,
event_id=existing.id,
payload=CalendarEventUpdateRequest(
calendar_id=request.calendar_id,
summary=request.summary,
description=request.description,
location=request.location,
classification=request.classification,
start_at=request.start_at,
end_at=request.end_at,
timezone=request.timezone,
organizer=(
dict(request.organizer) if request.organizer else None
),
attendees=attendees,
categories=list(request.categories),
metadata=metadata,
),
)
except (CalendarError, TypeError, ValueError) as exc:
raise CalendarCapabilityError(str(exc)) from exc
event.correlation_key = request.correlation_id
event.producer_module = request.source_module
event.producer_resource_type = request.source_resource_type
event.producer_resource_id = request.source_resource_id
db.flush()
return _invitation_ref(event)
def get_invitation(
self,
session: object,
*,
tenant_id: str,
correlation_id: str,
) -> CalendarInvitationRef | None:
event = (
self._session(session)
.query(CalendarEvent)
.filter(
CalendarEvent.tenant_id == tenant_id,
CalendarEvent.correlation_key == correlation_id,
CalendarEvent.deleted_at.is_(None),
)
.one_or_none()
)
return _invitation_ref(event) if event is not None else None
def record_response(
self,
session: object,
*,
tenant_id: str,
attendee_address: str,
participation_status: str,
correlation_id: str | None = None,
uid: str | None = None,
responded_at: datetime | None = None,
evidence: Mapping[str, object] | None = None,
) -> CalendarInvitationRef:
if bool(correlation_id) == bool(uid):
raise CalendarCapabilityError(
"Specify exactly one invitation correlation_id or uid."
)
status = participation_status.strip().upper()
if status not in _PARTICIPATION_STATUSES - {"NEEDS-ACTION"}:
raise CalendarCapabilityError(
f"Unsupported attendee participation status: {status}"
)
db = self._session(session)
query = db.query(CalendarEvent).filter(
CalendarEvent.tenant_id == tenant_id,
CalendarEvent.deleted_at.is_(None),
)
query = query.filter(
CalendarEvent.correlation_key == correlation_id
if correlation_id
else CalendarEvent.uid == uid
)
event = query.one_or_none()
if event is None:
raise CalendarCapabilityError("Calendar invitation was not found.")
target = attendee_address.strip().casefold()
attendees: list[dict[str, object]] = []
matched = False
response_time = responded_at or utc_now()
for raw in event.attendees or []:
item = dict(raw)
if _attendee_address(item) == target:
params = dict(item.get("params") or {})
params["PARTSTAT"] = [status]
item["params"] = params
item["response_at"] = response_time.isoformat()
item["response_evidence"] = dict(evidence or {})
matched = True
attendees.append(item)
if not matched:
raise CalendarCapabilityError(
"The reply address is not an attendee of this invitation."
)
metadata = dict(event.metadata_ or {})
invitation_metadata = dict(metadata.get("calendar_invitation") or {})
invitation_metadata["last_response_at"] = response_time.isoformat()
metadata["calendar_invitation"] = invitation_metadata
try:
event = update_event(
db,
tenant_id=tenant_id,
user_id=None,
event_id=event.id,
payload=CalendarEventUpdateRequest(
attendees=attendees,
metadata=metadata,
),
)
except CalendarError as exc:
raise CalendarCapabilityError(str(exc)) from exc
return _invitation_ref(event)
@staticmethod
def _session(session: object) -> Session:
if not isinstance(session, Session):
raise CalendarCapabilityError(
"Calendar invitations require a SQLAlchemy Session."
)
return session
@staticmethod
def _validate_request(request: CalendarInvitationRequest) -> None:
for label, value, maximum in (
("correlation_id", request.correlation_id, 255),
("source_module", request.source_module, 100),
("source_resource_type", request.source_resource_type, 100),
("summary", request.summary, 500),
):
if not value.strip() or len(value) > maximum:
raise CalendarCapabilityError(
f"Invitation {label} must contain 1 to {maximum} characters."
)
if request.source_resource_id and len(request.source_resource_id) > 255:
raise CalendarCapabilityError(
"Invitation source_resource_id must not exceed 255 characters."
)
if not request.attendees:
raise CalendarCapabilityError(
"Calendar invitations require at least one attendee."
)
addresses = [item.address.strip().casefold() for item in request.attendees]
if any(not item for item in addresses) or len(set(addresses)) != len(addresses):
raise CalendarCapabilityError(
"Invitation attendee addresses must be non-empty and unique."
)
@@ -52,6 +52,11 @@ class CalendarEvent(Base, TimestampMixin):
UniqueConstraint("calendar_id", "uid", "recurrence_id", name="uq_calendar_events_calendar_uid_recurrence"),
Index("ix_calendar_events_range", "tenant_id", "calendar_id", "start_at", "end_at"),
Index("ix_calendar_events_tenant_uid", "tenant_id", "uid"),
Index(
"ix_calendar_events_tenant_correlation",
"tenant_id",
"correlation_key",
),
Index("ix_calendar_events_tenant_status", "tenant_id", "status"),
)
@@ -59,6 +64,18 @@ class CalendarEvent(Base, TimestampMixin):
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
calendar_id: Mapped[str] = mapped_column(ForeignKey("calendar_collections.id", ondelete="CASCADE"), nullable=False, index=True)
uid: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
correlation_key: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
producer_module: Mapped[str | None] = mapped_column(
String(100), nullable=True, index=True
)
producer_resource_type: Mapped[str | None] = mapped_column(
String(100), nullable=True
)
producer_resource_id: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
recurrence_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
sequence: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
summary: Mapped[str] = mapped_column(String(500), nullable=False)
@@ -94,6 +111,36 @@ class CalendarEvent(Base, TimestampMixin):
calendar: Mapped[CalendarCollection] = relationship(back_populates="events")
class CalendarViewPreference(Base, TimestampMixin):
__tablename__ = "calendar_view_preferences"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"user_id",
name="uq_calendar_view_preferences_tenant_user",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(
String(36),
nullable=False,
index=True,
)
user_id: Mapped[str] = mapped_column(
ForeignKey("access_users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
dim_weekends: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
dim_off_hours: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
workday_start_hour: Mapped[int | None] = mapped_column(Integer, nullable=True)
workday_end_hour: Mapped[int | None] = mapped_column(Integer, nullable=True)
continuous_virtualization: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
continuous_overscan_weeks: Mapped[int | None] = mapped_column(Integer, nullable=True)
alternate_continuous_months: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
class CalendarSyncSource(Base, TimestampMixin):
__tablename__ = "calendar_sync_sources"
__table_args__ = (
@@ -215,5 +262,6 @@ __all__ = [
"CalendarOutboxOperation",
"CalendarSyncCredential",
"CalendarSyncSource",
"CalendarViewPreference",
"new_uuid",
]
+236
View File
@@ -0,0 +1,236 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from defusedxml import ElementTree as SafeElementTree
EWS_SOAP_NS = "http://schemas.xmlsoap.org/soap/envelope/"
EWS_MESSAGES_NS = (
"http://schemas.microsoft.com/exchange/services/2006/messages"
)
EWS_TYPES_NS = "http://schemas.microsoft.com/exchange/services/2006/types"
class EwsAdapterError(ValueError):
"""Raised when an EWS response cannot be translated safely."""
def ews_find_item_body(
*,
start: datetime,
end: datetime,
mailbox: Any | None = None,
) -> str:
start_text = normalize_datetime(start).isoformat().replace("+00:00", "Z")
end_text = normalize_datetime(end).isoformat().replace("+00:00", "Z")
mailbox_xml = ""
if mailbox:
mailbox_xml = (
"<t:Mailbox><t:EmailAddress>"
f"{xml_escape(str(mailbox))}"
"</t:EmailAddress></t:Mailbox>"
)
return f"""<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="{EWS_SOAP_NS}" xmlns:m="{EWS_MESSAGES_NS}" xmlns:t="{EWS_TYPES_NS}">
<s:Header>
<t:RequestServerVersion Version="Exchange2013_SP1" />
</s:Header>
<s:Body>
<m:FindItem Traversal="Shallow">
<m:ItemShape>
<t:BaseShape>AllProperties</t:BaseShape>
</m:ItemShape>
<m:CalendarView StartDate="{start_text}" EndDate="{end_text}" />
<m:ParentFolderIds>
<t:DistinguishedFolderId Id="calendar">{mailbox_xml}</t:DistinguishedFolderId>
</m:ParentFolderIds>
</m:FindItem>
</s:Body>
</s:Envelope>"""
def parse_ews_calendar_items(xml_text: str) -> list[dict[str, Any]]:
try:
root = SafeElementTree.fromstring(xml_text)
except SafeElementTree.ParseError as exc:
raise EwsAdapterError(f"Invalid EWS response XML: {exc}") from exc
items: list[dict[str, Any]] = []
for item in root.findall(f".//{{{EWS_TYPES_NS}}}CalendarItem"):
item_id = item.find(f"./{{{EWS_TYPES_NS}}}ItemId")
href = item_id.get("Id") if item_id is not None else None
if not href:
continue
change_key = (
item_id.get("ChangeKey") if item_id is not None else None
)
start = parse_ews_datetime(text_of(item, "Start"))
end_text = text_of(item, "End")
end = parse_ews_datetime(end_text) if end_text else start
uid = text_of(item, "UID") or href
subject = text_of(item, "Subject") or "(Untitled event)"
all_day = text_of(item, "IsAllDayEvent") == "true"
free_busy = text_of(item, "LegacyFreeBusyStatus") or "Busy"
sensitivity = text_of(item, "Sensitivity") or "Normal"
body = item.find(f"./{{{EWS_TYPES_NS}}}Body")
provider_payload = element_to_dict(item)
items.append(
{
"href": href,
"uid": uid,
"recurrence_id": (
href
if text_of(item, "CalendarItemType")
in {"Occurrence", "Exception"}
else None
),
"sequence": int_or_default(
text_of(item, "AppointmentSequenceNumber"),
0,
),
"summary": subject,
"description": body.text if body is not None else None,
"location": text_of(item, "Location"),
"status": (
"CANCELLED"
if text_of(item, "IsCancelled") == "true"
else "CONFIRMED"
),
"transparency": (
"TRANSPARENT"
if free_busy.lower() == "free"
else "OPAQUE"
),
"classification": (
"PRIVATE"
if sensitivity.lower() == "private"
else "PUBLIC"
),
"start_at": start,
"end_at": end,
"duration_seconds": int((end - start).total_seconds()),
"all_day": all_day,
"timezone": "UTC",
"organizer": ews_mailbox_record(
item.find(
f"./{{{EWS_TYPES_NS}}}Organizer/"
f"{{{EWS_TYPES_NS}}}Mailbox"
)
),
"attendees": ews_attendees(item),
"categories": [
category.text
for category in item.findall(
f"./{{{EWS_TYPES_NS}}}Categories/"
f"{{{EWS_TYPES_NS}}}String"
)
if category.text
],
"rrule": None,
"rdate": [],
"exdate": [],
"reminders": ews_reminders(item),
"attachments": [],
"related_to": [],
"etag": change_key,
"icalendar": {
"component": "VEVENT",
"schema_version": 1,
"provider": "ews",
"ews": provider_payload,
},
"metadata": {"ews": provider_payload},
}
)
return items
def parse_ews_datetime(value: str | None) -> datetime:
if not value:
return datetime.now(timezone.utc)
return normalize_datetime(
datetime.fromisoformat(value.replace("Z", "+00:00"))
)
def text_of(item: Any, name: str) -> str | None:
child = item.find(f"./{{{EWS_TYPES_NS}}}{name}")
return child.text if child is not None else None
def ews_mailbox_record(mailbox: Any | None) -> dict[str, Any] | None:
if mailbox is None:
return None
return {
"name": text_of(mailbox, "Name") or "",
"email": text_of(mailbox, "EmailAddress") or "",
"routing_type": text_of(mailbox, "RoutingType") or "",
}
def ews_attendees(item: Any) -> list[dict[str, Any]]:
attendees: list[dict[str, Any]] = []
for role, path in (
("required", "RequiredAttendees"),
("optional", "OptionalAttendees"),
):
for attendee in item.findall(
f"./{{{EWS_TYPES_NS}}}{path}/"
f"{{{EWS_TYPES_NS}}}Attendee"
):
mailbox = attendee.find(f"./{{{EWS_TYPES_NS}}}Mailbox")
record = ews_mailbox_record(mailbox) or {}
record["role"] = role
response = text_of(attendee, "ResponseType")
if response:
record["status"] = response
attendees.append(record)
return attendees
def ews_reminders(item: Any) -> list[dict[str, Any]]:
if text_of(item, "ReminderIsSet") == "true":
minutes = int_or_default(
text_of(item, "ReminderMinutesBeforeStart"),
15,
)
return [{"action": "DISPLAY", "trigger_minutes_before": minutes}]
return []
def element_to_dict(element: Any) -> dict[str, Any]:
tag = element.tag.rsplit("}", 1)[-1]
children = list(element)
result: dict[str, Any] = {"tag": tag}
if element.attrib:
result["attributes"] = dict(element.attrib)
if element.text and element.text.strip():
result["text"] = element.text.strip()
if children:
result["children"] = [
element_to_dict(child) for child in children
]
return result
def int_or_default(value: str | None, default: int) -> int:
try:
return int(value) if value is not None else default
except ValueError:
return default
def xml_escape(value: str) -> str:
return (
value.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
def normalize_datetime(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
+157
View File
@@ -0,0 +1,157 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
import uuid
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
def graph_event_payload(item: dict[str, Any]) -> dict[str, Any]:
"""Translate one Microsoft Graph event into the calendar event contract."""
start = graph_datetime(item.get("start"))
end = graph_datetime(item.get("end"))
all_day = bool(item.get("isAllDay"))
uid = str(
item.get("iCalUId")
or item.get("uid")
or item.get("id")
or uuid.uuid4()
)
return {
"uid": uid,
"recurrence_id": (
str(item.get("id"))
if item.get("type") == "occurrence"
else None
),
"sequence": int(item.get("sequence") or 0),
"summary": str(item.get("subject") or "(Untitled event)"),
"description": graph_body_text(item),
"location": graph_location_text(item.get("location")),
"status": (
"CANCELLED" if item.get("isCancelled") else "CONFIRMED"
),
"transparency": (
"TRANSPARENT"
if item.get("showAs") in {"free", "workingElsewhere"}
else "OPAQUE"
),
"classification": (
"PRIVATE"
if item.get("sensitivity") == "private"
else "PUBLIC"
),
"start_at": start,
"end_at": end,
"duration_seconds": int((end - start).total_seconds()),
"all_day": all_day,
"timezone": (
(item.get("start") or {}).get("timeZone")
if isinstance(item.get("start"), dict)
else None
)
or "UTC",
"organizer": graph_party(item.get("organizer")),
"attendees": [
graph_party(attendee)
for attendee in item.get("attendees") or []
],
"categories": [
str(category) for category in item.get("categories") or []
],
"rrule": (
item.get("recurrence")
if isinstance(item.get("recurrence"), dict)
else None
),
"rdate": [],
"exdate": [],
"reminders": graph_reminders(item),
"attachments": [],
"related_to": [],
"etag": item.get("@odata.etag"),
"icalendar": {
"component": "VEVENT",
"schema_version": 1,
"provider": "graph",
"graph": item,
},
"metadata": {"graph": item},
}
def graph_datetime(value: Any) -> datetime:
if not isinstance(value, dict) or not value.get("dateTime"):
return datetime.now(timezone.utc)
raw = str(value["dateTime"]).replace("Z", "+00:00")
parsed = datetime.fromisoformat(raw)
tz_name = str(value.get("timeZone") or "UTC")
if parsed.tzinfo is None:
try:
parsed = parsed.replace(tzinfo=ZoneInfo(tz_name))
except ZoneInfoNotFoundError:
parsed = parsed.replace(tzinfo=timezone.utc)
return normalize_datetime(parsed)
def graph_body_text(item: dict[str, Any]) -> str | None:
body = item.get("body")
if isinstance(body, dict) and body.get("content"):
return str(body["content"])
return str(item.get("bodyPreview")) if item.get("bodyPreview") else None
def graph_location_text(value: Any) -> str | None:
if isinstance(value, dict) and value.get("displayName"):
return str(value["displayName"])
return None
def graph_party(value: Any) -> dict[str, Any]:
if not isinstance(value, dict):
return {}
email = (
value.get("emailAddress")
if isinstance(value.get("emailAddress"), dict)
else value
)
result = {
"name": (
str(email.get("name") or "")
if isinstance(email, dict)
else ""
),
"email": (
str(email.get("address") or "")
if isinstance(email, dict)
else ""
),
}
if value.get("type"):
result["role"] = value["type"]
if value.get("status"):
result["status"] = value["status"]
return result
def graph_reminders(item: dict[str, Any]) -> list[dict[str, Any]]:
if (
item.get("isReminderOn")
and item.get("reminderMinutesBeforeStart") is not None
):
return [
{
"action": "DISPLAY",
"trigger_minutes_before": int(
item["reminderMinutesBeforeStart"]
),
}
]
return []
def normalize_datetime(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
+42 -1
View File
@@ -302,6 +302,30 @@ def recurrence_id_value(value: Any | None) -> str | None:
return f"TZID={tzid}:{raw_value}" if tzid else raw_value
def recurrence_id_datetime(value: str | None) -> datetime | None:
"""Parse the canonical/raw RECURRENCE-ID forms stored by the module."""
if not value:
return None
raw_value = value
params: dict[str, Any] = {}
if value.startswith("TZID=") and ":" in value:
tzid, raw_value = value.removeprefix("TZID=").split(":", 1)
params["TZID"] = [tzid]
parsed = parse_ical_temporal(raw_value, params)
if parsed is not None:
return parsed
try:
return normalize_datetime(datetime.fromisoformat(raw_value))
except ValueError:
return None
def normalized_recurrence_id(value: str | None) -> str | None:
parsed = recurrence_id_datetime(value)
return format_ical_datetime(parsed) if parsed is not None else value
def recurrence_rule(value: Any | None) -> dict[str, Any] | None:
if value is None:
return None
@@ -653,13 +677,30 @@ def occurrence_overlaps(start_at: datetime, duration: timedelta, range_start: da
def occurrence_payload(start_at: datetime, duration: timedelta, event: Any) -> dict[str, Any]:
return {
"uid": event.uid,
"recurrence_id": format_ical_datetime(start_at),
"recurrence_id": occurrence_recurrence_id(start_at, event),
"start_at": start_at,
"end_at": start_at + duration if duration else None,
"all_day": event.all_day,
}
def occurrence_recurrence_id(start_at: datetime, event: Any) -> str:
start_at = normalize_datetime(start_at)
if event.all_day:
return start_at.strftime("%Y%m%d")
timezone_id = getattr(event, "timezone", None)
if timezone_id:
try:
local_start = start_at.astimezone(ZoneInfo(timezone_id))
return (
f"TZID={timezone_id}:"
f"{local_start.strftime('%Y%m%dT%H%M%S')}"
)
except ZoneInfoNotFoundError:
pass
return format_ical_datetime(start_at)
def int_or_zero(value: str | None) -> int:
try:
return int(value or "0")
+80 -9
View File
@@ -1,18 +1,23 @@
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
from sqlalchemy import inspect
from govoplan_calendar.backend.db import models as calendar_models # noqa: F401 - populate Calendar ORM metadata
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.calendar import (
CALENDAR_AVAILABILITY_READ_SCOPE,
CALENDAR_EVENT_WRITE_SCOPE,
CAPABILITY_CALENDAR_INVITATIONS,
CAPABILITY_CALENDAR_OUTBOX,
CAPABILITY_CALENDAR_SCHEDULING,
)
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import (
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
@@ -21,9 +26,46 @@ from govoplan_core.core.modules import (
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
_calendar_table_retirement_provider = drop_table_retirement_provider(
calendar_models.CalendarCollection,
calendar_models.CalendarEvent,
calendar_models.CalendarOutboxOperation,
calendar_models.CalendarSyncCredential,
calendar_models.CalendarSyncSource,
calendar_models.CalendarViewPreference,
label="Calendar",
)
def _calendar_retirement_provider(session: object | None, module_id: str):
plan = _calendar_table_retirement_provider(session, module_id)
base_executor = plan.destroy_data_executor
if base_executor is None:
return plan
def executor(execute_session: object, execute_module_id: str) -> None:
if not hasattr(execute_session, "get_bind") or not hasattr(execute_session, "query"):
raise RuntimeError("No database session is available for Calendar credential retirement.")
if inspect(execute_session.get_bind()).has_table(calendar_models.CalendarSyncCredential.__tablename__):
from govoplan_calendar.backend.service import delete_calendar_credentials_for_retirement
delete_calendar_credentials_for_retirement(execute_session)
base_executor(execute_session, execute_module_id)
return replace(
plan,
destroy_data_warnings=(
*plan.destroy_data_warnings,
"Calendar-owned credentials are deleted immediately before tables are dropped; retirement fails if an external secret provider is unavailable.",
),
destroy_data_executor=executor,
)
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
@@ -87,6 +129,7 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
CalendarOutboxOperation,
CalendarSyncCredential,
CalendarSyncSource,
CalendarViewPreference,
)
return {
@@ -94,6 +137,7 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
"calendar_events": session.query(CalendarEvent).filter(CalendarEvent.tenant_id == tenant_id, CalendarEvent.deleted_at.is_(None)).count(),
"calendar_sync_sources": session.query(CalendarSyncSource).filter(CalendarSyncSource.tenant_id == tenant_id, CalendarSyncSource.deleted_at.is_(None)).count(),
"calendar_sync_credentials": session.query(CalendarSyncCredential).filter(CalendarSyncCredential.tenant_id == tenant_id, CalendarSyncCredential.deleted_at.is_(None)).count(),
"calendar_view_preferences": session.query(CalendarViewPreference).filter(CalendarViewPreference.tenant_id == tenant_id).count(),
"calendar_outbox_pending": session.query(CalendarOutboxOperation)
.filter(
CalendarOutboxOperation.tenant_id == tenant_id,
@@ -119,6 +163,13 @@ def _calendar_scheduling_provider(context: ModuleContext) -> object:
return SqlCalendarSchedulingProvider()
def _calendar_invitation_provider(context: ModuleContext) -> object:
del context
from govoplan_calendar.backend.capabilities import SqlCalendarInvitationProvider
return SqlCalendarInvitationProvider()
def _calendar_outbox_provider(context: ModuleContext) -> object:
from govoplan_calendar.backend.outbox import (
OUTBOX_DEFAULT_TERMINAL_RETENTION_DAYS,
@@ -139,10 +190,11 @@ manifest = ModuleManifest(
name="Calendar",
version="0.1.8",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"),
optional_dependencies=("mail", "tasks", "scheduling", "appointments", "workflow_engine", "notifications", "dms", "connectors"),
provides_interfaces=(
ModuleInterfaceProvider(name="calendar.outbox", version="0.1.8"),
ModuleInterfaceProvider(name="calendar.scheduling", version="0.1.8"),
ModuleInterfaceProvider(name="calendar.invitations", version="0.1.0"),
),
permissions=PERMISSIONS,
route_factory=_calendar_router,
@@ -151,26 +203,44 @@ manifest = ModuleManifest(
capability_factories={
CAPABILITY_CALENDAR_OUTBOX: _calendar_outbox_provider,
CAPABILITY_CALENDAR_SCHEDULING: _calendar_scheduling_provider,
CAPABILITY_CALENDAR_INVITATIONS: _calendar_invitation_provider,
},
nav_items=(NavItem(path="/calendar", label="Calendar", icon="calendar", required_any=("calendar:event:read",), order=55),),
frontend=FrontendModule(
module_id="calendar",
package_name="@govoplan/calendar-webui",
routes=(
FrontendRoute(
path="/calendar",
component="CalendarPage",
required_any=("calendar:event:read",),
order=55,
),
),
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,
),
ViewSurface(
id="calendar.settings.preferences",
module_id="calendar",
kind="section",
label="Calendar preferences",
order=45,
),
),
),
migration_spec=MigrationSpec(
module_id="calendar",
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
calendar_models.CalendarCollection,
calendar_models.CalendarEvent,
calendar_models.CalendarOutboxOperation,
calendar_models.CalendarSyncCredential,
calendar_models.CalendarSyncSource,
label="Calendar",
),
retirement_provider=_calendar_retirement_provider,
retirement_notes="Destructive retirement drops calendar-owned database tables after the installer captures a database snapshot.",
),
uninstall_guard_providers=(
@@ -180,6 +250,7 @@ manifest = ModuleManifest(
calendar_models.CalendarOutboxOperation,
calendar_models.CalendarSyncCredential,
calendar_models.CalendarSyncSource,
calendar_models.CalendarViewPreference,
label="Calendar",
),
),
@@ -0,0 +1,28 @@
"""Add durable per-user calendar view preferences on the development track.
Revision ID: b02c3d4e5f60
Revises: af1b2c3d4e5f
Create Date: 2026-07-31 00:00:00.000000
"""
from __future__ import annotations
from govoplan_calendar.backend.migrations.versions.b02c3d4e5f60_calendar_view_preferences import (
downgrade as _downgrade,
)
from govoplan_calendar.backend.migrations.versions.b02c3d4e5f60_calendar_view_preferences import (
upgrade as _upgrade,
)
revision = "b02c3d4e5f60"
down_revision = "af1b2c3d4e5f"
branch_labels = None
depends_on = "4f2a9c8e7b6d"
def upgrade() -> None:
_upgrade()
def downgrade() -> None:
_downgrade()
@@ -0,0 +1,83 @@
"""Add durable per-user calendar view preferences.
Revision ID: b02c3d4e5f60
Revises: af1b2c3d4e5f
Create Date: 2026-07-31 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "b02c3d4e5f60"
down_revision = "af1b2c3d4e5f"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"calendar_view_preferences",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("dim_weekends", sa.Boolean(), nullable=True),
sa.Column("dim_off_hours", sa.Boolean(), nullable=True),
sa.Column("workday_start_hour", sa.Integer(), nullable=True),
sa.Column("workday_end_hour", sa.Integer(), nullable=True),
sa.Column("continuous_virtualization", sa.Boolean(), nullable=True),
sa.Column("continuous_overscan_weeks", sa.Integer(), nullable=True),
sa.Column("alternate_continuous_months", sa.Boolean(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["tenant_id"],
["core_scopes.id"],
name=op.f(
"fk_calendar_view_preferences_tenant_id_core_scopes"
),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["user_id"],
["access_users.id"],
name=op.f(
"fk_calendar_view_preferences_user_id_access_users"
),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_calendar_view_preferences"),
),
sa.UniqueConstraint(
"tenant_id",
"user_id",
name="uq_calendar_view_preferences_tenant_user",
),
)
op.create_index(
op.f("ix_calendar_view_preferences_tenant_id"),
"calendar_view_preferences",
["tenant_id"],
unique=False,
)
op.create_index(
op.f("ix_calendar_view_preferences_user_id"),
"calendar_view_preferences",
["user_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index(
op.f("ix_calendar_view_preferences_user_id"),
table_name="calendar_view_preferences",
)
op.drop_index(
op.f("ix_calendar_view_preferences_tenant_id"),
table_name="calendar_view_preferences",
)
op.drop_table("calendar_view_preferences")
@@ -0,0 +1,59 @@
"""calendar invitation correlation
Revision ID: c13d4e5f6071
Revises: b02c3d4e5f60
Create Date: 2026-07-31 18:30:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "c13d4e5f6071"
down_revision = "b02c3d4e5f60"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("calendar_events") as batch_op:
batch_op.add_column(
sa.Column("correlation_key", sa.String(length=255), nullable=True)
)
batch_op.add_column(
sa.Column("producer_module", sa.String(length=100), nullable=True)
)
batch_op.add_column(
sa.Column(
"producer_resource_type", sa.String(length=100), nullable=True
)
)
batch_op.add_column(
sa.Column("producer_resource_id", sa.String(length=255), nullable=True)
)
batch_op.create_index(
"ix_calendar_events_correlation_key", ("correlation_key",)
)
batch_op.create_index(
"ix_calendar_events_producer_module", ("producer_module",)
)
batch_op.create_index(
"ix_calendar_events_producer_resource_id", ("producer_resource_id",)
)
batch_op.create_index(
"ix_calendar_events_tenant_correlation",
("tenant_id", "correlation_key"),
)
def downgrade() -> None:
with op.batch_alter_table("calendar_events") as batch_op:
batch_op.drop_index("ix_calendar_events_tenant_correlation")
batch_op.drop_index("ix_calendar_events_producer_resource_id")
batch_op.drop_index("ix_calendar_events_producer_module")
batch_op.drop_index("ix_calendar_events_correlation_key")
batch_op.drop_column("producer_resource_id")
batch_op.drop_column("producer_resource_type")
batch_op.drop_column("producer_module")
batch_op.drop_column("correlation_key")
+208 -73
View File
@@ -858,15 +858,18 @@ def _fail_operation(
session.flush()
def execute_calendar_outbox_operation(
def _lock_leased_outbox_operation(
session: Session,
*,
operation_id: str,
lease_token: str,
client_factory: Callable[[Session, CalendarSyncSource], object] | None = None,
) -> CalendarOutboxOperation:
) -> tuple[CalendarOutboxOperation, CalendarSyncSource | None]:
operation = session.get(CalendarOutboxOperation, operation_id)
if operation is None or operation.status != "in_progress" or operation.lease_token != lease_token:
if (
operation is None
or operation.status != "in_progress"
or operation.lease_token != lease_token
):
raise ValueError("Calendar outbox lease is no longer valid")
source = (
session.query(CalendarSyncSource)
@@ -882,36 +885,86 @@ def execute_calendar_outbox_operation(
)
if operation.status != "in_progress" or operation.lease_token != lease_token:
raise ValueError("Calendar outbox lease is no longer valid")
operation_metadata = operation.metadata_ or {}
unavailable_reason = _operation_source_unavailable_reason(operation, source)
if unavailable_reason:
return operation, source
def _cancel_undeliverable_operation(
session: Session,
*,
operation: CalendarOutboxOperation,
source: CalendarSyncSource | None,
reason: str,
) -> None:
if source is not None and source.tenant_id == operation.tenant_id:
_fail_operation(
session,
operation=operation,
source=source,
error=unavailable_reason,
error=reason,
terminal_status="cancelled",
)
else:
return
operation.status = "cancelled"
operation.completed_at = utcnow()
operation.last_error = unavailable_reason
operation.last_error = reason
operation.lease_token = None
operation.lease_expires_at = None
session.flush()
return operation
overwrite = bool(operation_metadata.get("overwrite")) if isinstance(operation_metadata, dict) else False
client: object | None = None
try:
if client_factory is None:
def _calendar_outbox_client(
session: Session,
*,
source: CalendarSyncSource,
client_factory: Callable[[Session, CalendarSyncSource], object] | None,
) -> object:
if client_factory is not None:
return client_factory(session, source)
from govoplan_calendar.backend.service import caldav_client_for_source
client = caldav_client_for_source(session, source)
else:
client = client_factory(session, source)
if operation.operation_kind == "put":
return caldav_client_for_source(session, source)
def _try_remote_match(
client: object,
operation: CalendarOutboxOperation,
) -> tuple[bool, str | None] | None:
try:
return _remote_matches_operation(client, operation)
except Exception:
return None
def _complete_if_remote_match_is_usable(
session: Session,
*,
operation: CalendarOutboxOperation,
source: CalendarSyncSource,
remote_match: tuple[bool, str | None] | None,
) -> bool:
if remote_match is None:
return False
matched, remote_etag = remote_match
if not matched or (operation.operation_kind != "delete" and not remote_etag):
return False
_complete_operation(
session,
operation=operation,
source=source,
remote_etag=remote_etag,
reconciled=True,
)
return True
def _execute_calendar_outbox_put(
session: Session,
*,
operation: CalendarOutboxOperation,
source: CalendarSyncSource,
client: object,
overwrite: bool,
) -> None:
result = client.put_object(
operation.resource_href,
operation.payload_ics or "",
@@ -920,41 +973,48 @@ def execute_calendar_outbox_operation(
overwrite=overwrite,
)
remote_etag = result.etag
reconciled = False
if not remote_etag:
try:
matched, remote_etag = _remote_matches_operation(client, operation)
except Exception:
matched, remote_etag = False, None
if not matched or not remote_etag:
if remote_etag:
_complete_operation(
session,
operation=operation,
source=source,
remote_etag=remote_etag,
reconciled=False,
)
return
remote_match = _try_remote_match(client, operation)
if _complete_if_remote_match_is_usable(
session,
operation=operation,
source=source,
remote_match=remote_match,
):
return
_fail_operation(
session,
operation=operation,
source=source,
error="CalDAV PUT succeeded without an ETag and could not be reconciled safely",
)
return operation
reconciled = True
_complete_operation(
session,
operation=operation,
source=source,
remote_etag=remote_etag,
reconciled=reconciled,
)
return operation
def _execute_calendar_outbox_delete(
session: Session,
*,
operation: CalendarOutboxOperation,
source: CalendarSyncSource,
client: object,
overwrite: bool,
) -> None:
if not operation.expected_etag and not overwrite:
matched, remote_etag = _remote_matches_operation(client, operation)
if matched:
_complete_operation(
remote_match = _remote_matches_operation(client, operation)
if _complete_if_remote_match_is_usable(
session,
operation=operation,
source=source,
remote_etag=remote_etag,
reconciled=True,
)
return operation
remote_match=remote_match,
):
return
_fail_operation(
session,
operation=operation,
@@ -965,7 +1025,7 @@ def execute_calendar_outbox_operation(
),
terminal_status="conflict",
)
return operation
return
result = client.delete_object(
operation.resource_href,
etag=operation.expected_etag,
@@ -978,25 +1038,28 @@ def execute_calendar_outbox_operation(
remote_etag=result.etag,
reconciled=result.status == 404,
)
return operation
except CalDAVPreconditionFailed as exc:
if client is None:
_fail_operation(session, operation=operation, source=source, error=exc)
return operation
try:
matched, remote_etag = _remote_matches_operation(client, operation)
except Exception:
_fail_operation(session, operation=operation, source=source, error=exc)
else:
if matched and (operation.operation_kind == "delete" or remote_etag):
_complete_operation(
def _handle_outbox_precondition_failure(
session: Session,
*,
operation: CalendarOutboxOperation,
source: CalendarSyncSource,
client: object | None,
error: CalDAVPreconditionFailed,
) -> None:
remote_match = _try_remote_match(client, operation) if client is not None else None
if _complete_if_remote_match_is_usable(
session,
operation=operation,
source=source,
remote_etag=remote_etag,
reconciled=True,
)
else:
remote_match=remote_match,
):
return
if remote_match is None:
_fail_operation(session, operation=operation, source=source, error=error)
return
matched, _remote_etag = remote_match
_fail_operation(
session,
operation=operation,
@@ -1008,24 +1071,96 @@ def execute_calendar_outbox_operation(
),
terminal_status=None if matched else "conflict",
)
return operation
except Exception as exc:
matched, remote_etag = False, None
if client is not None:
try:
matched, remote_etag = _remote_matches_operation(client, operation)
except Exception:
matched, remote_etag = False, None
if matched and (operation.operation_kind == "delete" or remote_etag):
_complete_operation(
def _handle_outbox_delivery_failure(
session: Session,
*,
operation: CalendarOutboxOperation,
source: CalendarSyncSource,
client: object | None,
error: Exception,
) -> None:
remote_match = _try_remote_match(client, operation) if client is not None else None
if _complete_if_remote_match_is_usable(
session,
operation=operation,
source=source,
remote_etag=remote_etag,
reconciled=True,
remote_match=remote_match,
):
return
_fail_operation(session, operation=operation, source=source, error=error)
def execute_calendar_outbox_operation(
session: Session,
*,
operation_id: str,
lease_token: str,
client_factory: Callable[[Session, CalendarSyncSource], object] | None = None,
) -> CalendarOutboxOperation:
operation, source = _lock_leased_outbox_operation(
session,
operation_id=operation_id,
lease_token=lease_token,
)
unavailable_reason = _operation_source_unavailable_reason(operation, source)
if unavailable_reason:
_cancel_undeliverable_operation(
session,
operation=operation,
source=source,
reason=unavailable_reason,
)
return operation
if source is None:
raise ValueError("Calendar outbox source is no longer available")
operation_metadata = operation.metadata_ or {}
overwrite = (
bool(operation_metadata.get("overwrite"))
if isinstance(operation_metadata, dict)
else False
)
client: object | None = None
try:
client = _calendar_outbox_client(
session,
source=source,
client_factory=client_factory,
)
if operation.operation_kind == "put":
_execute_calendar_outbox_put(
session,
operation=operation,
source=source,
client=client,
overwrite=overwrite,
)
else:
_fail_operation(session, operation=operation, source=source, error=exc)
_execute_calendar_outbox_delete(
session,
operation=operation,
source=source,
client=client,
overwrite=overwrite,
)
except CalDAVPreconditionFailed as exc:
_handle_outbox_precondition_failure(
session,
operation=operation,
source=source,
client=client,
error=exc,
)
except Exception as exc:
_handle_outbox_delivery_failure(
session,
operation=operation,
source=source,
client=client,
error=exc,
)
return operation
+217 -6
View File
@@ -28,9 +28,13 @@ from govoplan_calendar.backend.schemas import (
CalendarCollectionListResponse,
CalendarCollectionResponse,
CalendarCollectionUpdateRequest,
CalendarCredentialEnvelopeListResponse,
CalendarCredentialEnvelopeResponse,
CalendarEventCreateRequest,
CalendarEventDeltaResponse,
CalendarEventListResponse,
CalendarEventOccurrenceDeleteRequest,
CalendarEventOccurrenceUpdateRequest,
CalendarEventResponse,
CalendarEventUpdateRequest,
CalendarFreeBusyRequest,
@@ -47,6 +51,8 @@ from govoplan_calendar.backend.schemas import (
CalendarSyncSourceSyncRequest,
CalendarSyncSourceSyncResponse,
CalendarSyncSourceUpdateRequest,
CalendarViewPreferencesResponse,
CalendarViewPreferencesUpdateRequest,
)
from govoplan_calendar.backend.outbox import (
calendar_outbox_operation_response,
@@ -61,6 +67,7 @@ from govoplan_calendar.backend.service import (
CALENDAR_EVENT_RESOURCE,
CALENDAR_MODULE_ID,
CalendarError,
available_calendar_credentials,
caldav_source_response,
caldav_sync_response,
calendar_response,
@@ -72,14 +79,17 @@ from govoplan_calendar.backend.service import (
delete_caldav_source,
delete_sync_source,
delete_event,
delete_event_occurrence,
discover_caldav_calendars,
event_response,
get_calendar_view_preferences,
get_event,
import_ics_event,
list_freebusy,
list_caldav_sources,
list_calendars,
list_events,
list_event_occurrences,
list_sync_sources,
normalize_datetime,
sync_due_sources,
@@ -90,12 +100,23 @@ from govoplan_calendar.backend.service import (
update_caldav_source,
update_sync_source,
update_event,
update_event_occurrence,
update_calendar_view_preferences,
)
from govoplan_core.db.session import get_session
router = APIRouter(prefix="/calendar", tags=["calendar"])
def _caldav_discovery_http_error(
exc: CalendarError | CalDAVError,
) -> HTTPException:
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
)
def _require_scope(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
@@ -129,6 +150,25 @@ def _parse_payload_datetime(value) -> datetime | None:
return None
@router.get("/credentials", response_model=CalendarCredentialEnvelopeListResponse)
def api_list_calendar_credentials(
source_id: str | None = None,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
return CalendarCredentialEnvelopeListResponse(
credentials=[
CalendarCredentialEnvelopeResponse.model_validate(item)
for item in available_calendar_credentials(
session,
tenant_id=principal.tenant_id,
source_id=source_id,
)
]
)
def _event_interval_overlaps(payload: dict, *, prefix: str, start_at: datetime | None, end_at: datetime | None) -> bool:
event_start = _parse_payload_datetime(payload.get(f"{prefix}start_at"))
event_end = _parse_payload_datetime(payload.get(f"{prefix}end_at")) or event_start
@@ -298,7 +338,14 @@ def api_delete_calendar(
):
_require_scope(principal, "calendar:calendar:admin")
try:
delete_calendar(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, payload=payload)
delete_calendar(
session,
tenant_id=principal.tenant_id,
calendar_id=calendar_id,
payload=payload,
user_id=principal.user.id,
api_key_id=principal.api_key_id,
)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
except CalendarError as exc:
@@ -344,7 +391,14 @@ def api_update_sync_source(
):
_require_scope(principal, "calendar:calendar:admin")
try:
source = update_sync_source(session, tenant_id=principal.tenant_id, source_id=source_id, payload=payload)
source = update_sync_source(
session,
tenant_id=principal.tenant_id,
source_id=source_id,
payload=payload,
user_id=principal.user.id,
api_key_id=principal.api_key_id,
)
session.commit()
session.refresh(source)
return _sync_source_response(source)
@@ -361,7 +415,13 @@ def api_delete_sync_source(
):
_require_scope(principal, "calendar:calendar:admin")
try:
delete_sync_source(session, tenant_id=principal.tenant_id, source_id=source_id)
delete_sync_source(
session,
tenant_id=principal.tenant_id,
source_id=source_id,
user_id=principal.user.id,
api_key_id=principal.api_key_id,
)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
except CalendarError as exc:
@@ -450,7 +510,7 @@ def api_discover_caldav_calendars(
calendars = discover_caldav_calendars(session, tenant_id=principal.tenant_id, payload=payload)
return CalendarCalDavDiscoveryResponse(calendars=calendars)
except (CalendarError, CalDAVError) as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
raise _caldav_discovery_http_error(exc) from exc
@router.post("/caldav/sources", response_model=CalendarCalDavSourceResponse, status_code=status.HTTP_201_CREATED)
@@ -479,7 +539,14 @@ def api_update_caldav_source(
):
_require_scope(principal, "calendar:calendar:admin")
try:
source = update_caldav_source(session, tenant_id=principal.tenant_id, source_id=source_id, payload=payload)
source = update_caldav_source(
session,
tenant_id=principal.tenant_id,
source_id=source_id,
payload=payload,
user_id=principal.user.id,
api_key_id=principal.api_key_id,
)
session.commit()
session.refresh(source)
return _caldav_source_response(source)
@@ -496,7 +563,13 @@ def api_delete_caldav_source(
):
_require_scope(principal, "calendar:calendar:admin")
try:
delete_caldav_source(session, tenant_id=principal.tenant_id, source_id=source_id)
delete_caldav_source(
session,
tenant_id=principal.tenant_id,
source_id=source_id,
user_id=principal.user.id,
api_key_id=principal.api_key_id,
)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
except CalendarError as exc:
@@ -679,10 +752,39 @@ def api_list_events(
calendar_id: str | None = None,
start_at: datetime | None = Query(default=None),
end_at: datetime | None = Query(default=None),
expand_recurring: bool = Query(default=False),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:event:read")
if expand_recurring:
if start_at is None or end_at is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=(
"start_at and end_at are required when expanding "
"recurring events"
),
)
try:
occurrences = list_event_occurrences(
session,
tenant_id=principal.tenant_id,
calendar_id=calendar_id,
start_at=start_at,
end_at=end_at,
)
return CalendarEventListResponse(
events=[
CalendarEventResponse.model_validate(item)
for item in occurrences
]
)
except CalendarError as exc:
raise HTTPException(
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)
return CalendarEventListResponse(events=[_event_response(event) for event in events])
@@ -786,6 +888,70 @@ def api_update_event(
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.patch(
"/events/{series_event_id}/occurrence",
response_model=CalendarEventResponse,
)
def api_update_event_occurrence(
series_event_id: str,
payload: CalendarEventOccurrenceUpdateRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
try:
event = update_event_occurrence(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
series_event_id=series_event_id,
payload=payload,
)
session.commit()
session.refresh(event)
return _event_response(event)
except CalendarError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
@router.delete(
"/events/{series_event_id}/occurrence",
response_model=CalendarEventResponse,
)
def api_delete_event_occurrence(
series_event_id: str,
payload: CalendarEventOccurrenceDeleteRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_any_scope(
principal,
"calendar:event:delete",
CALENDAR_EVENT_WRITE_SCOPE,
)
try:
event = delete_event_occurrence(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
series_event_id=series_event_id,
recurrence_id=payload.recurrence_id,
)
session.commit()
session.refresh(event)
return _event_response(event)
except CalendarError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
@router.delete("/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
def api_delete_event(
event_id: str,
@@ -802,6 +968,51 @@ def api_delete_event(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.get(
"/preferences/view",
response_model=CalendarViewPreferencesResponse,
)
def api_get_calendar_view_preferences(
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:event:read")
return CalendarViewPreferencesResponse.model_validate(
get_calendar_view_preferences(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
)
)
@router.patch(
"/preferences/view",
response_model=CalendarViewPreferencesResponse,
)
def api_update_calendar_view_preferences(
payload: CalendarViewPreferencesUpdateRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:event:read")
try:
response = update_calendar_view_preferences(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
payload=payload,
)
session.commit()
return CalendarViewPreferencesResponse.model_validate(response)
except CalendarError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
@router.post("/events/import-ics", response_model=CalendarEventResponse, status_code=status.HTTP_201_CREATED)
def api_import_ics_event(
payload: CalendarIcsImportRequest,
+59
View File
@@ -136,6 +136,7 @@ class CalendarSyncSourceResponse(BaseModel):
auth_type: str
username: str | None = None
credential_ref: str | None = None
credential_envelope_id: str | None = None
has_credential: bool = False
sync_enabled: bool
sync_interval_seconds: int
@@ -165,6 +166,26 @@ class CalendarCalDavSourceListResponse(BaseModel):
sources: list[CalendarCalDavSourceResponse] = Field(default_factory=list)
class CalendarCredentialEnvelopeResponse(BaseModel):
id: str
scope_type: str
scope_id: str | None = None
name: str
description: str | None = None
credential_kind: str
public_data: dict[str, Any] = Field(default_factory=dict)
secret_keys: list[str] = Field(default_factory=list)
secret_configured: bool = False
allowed_modules: list[str] = Field(default_factory=list)
inherit_to_lower_scopes: bool = False
is_active: bool = True
revision: str
class CalendarCredentialEnvelopeListResponse(BaseModel):
credentials: list[CalendarCredentialEnvelopeResponse] = Field(default_factory=list)
class CalendarCalDavDiscoveryRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -354,6 +375,16 @@ class CalendarEventUpdateRequest(BaseModel):
metadata: dict[str, Any] | None = None
class CalendarEventOccurrenceUpdateRequest(CalendarEventUpdateRequest):
recurrence_id: str = Field(min_length=1, max_length=255)
class CalendarEventOccurrenceDeleteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
recurrence_id: str = Field(min_length=1, max_length=255)
class CalendarEventResponse(BaseModel):
id: str
tenant_id: str
@@ -388,6 +419,10 @@ class CalendarEventResponse(BaseModel):
created_at: datetime
updated_at: datetime
metadata: dict[str, Any] = Field(default_factory=dict)
instance_id: str | None = None
series_event_id: str | None = None
is_occurrence: bool = False
is_override: bool = False
class CalendarEventListResponse(BaseModel):
@@ -402,6 +437,30 @@ class CalendarEventDeltaResponse(BaseModel):
full: bool = False
class CalendarViewPreferencesUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
dim_weekends: bool | None = None
dim_off_hours: bool | None = None
workday_start_hour: int | None = Field(default=None, ge=0, le=23)
workday_end_hour: int | None = Field(default=None, ge=1, le=24)
continuous_virtualization: bool | None = None
continuous_overscan_weeks: int | None = Field(default=None, ge=2, le=20)
alternate_continuous_months: bool | None = None
class CalendarViewPreferencesResponse(BaseModel):
dim_weekends: bool
dim_off_hours: bool
workday_start_hour: int
workday_end_hour: int
continuous_virtualization: bool
continuous_overscan_weeks: int
alternate_continuous_months: bool
overridden_fields: list[str] = Field(default_factory=list)
defaults: dict[str, bool | int] = Field(default_factory=dict)
class CalendarFreeBusyRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
File diff suppressed because it is too large Load Diff
+673 -3
View File
@@ -4,30 +4,61 @@ import unittest
from datetime import datetime, timezone
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy import create_engine, inspect
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate users/accounts tables
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVObject, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVWriteResult, parse_multistatus
from govoplan_calendar.backend.caldav import (
CalDAVClient,
CalDAVDiscoveryCalendar,
CalDAVError,
CalDAVNotFound,
CalDAVObject,
CalDAVPreconditionFailed,
CalDAVReportResult,
CalDAVWriteResult,
parse_discovery_multistatus,
parse_multistatus,
)
from govoplan_calendar.backend.db.models import CalendarEvent, CalendarOutboxOperation, CalendarSyncCredential
from govoplan_calendar.backend.manifest import manifest
from govoplan_calendar.backend.outbox import dispatch_calendar_outbox
from govoplan_calendar.backend.schemas import CalendarCalDavSourceCreateRequest, CalendarCollectionCreateRequest, CalendarEventCreateRequest, CalendarEventUpdateRequest
from govoplan_calendar.backend.schemas import (
CalendarCalDavDiscoveryRequest,
CalendarCalDavSourceCreateRequest,
CalendarCalDavSourceUpdateRequest,
CalendarCollectionCreateRequest,
CalendarEventCreateRequest,
CalendarEventUpdateRequest,
)
from govoplan_calendar.backend.service import (
CALDAV_INTERNAL_CREDENTIAL_PREFIX,
CalendarError,
_CalDAVDiscoveryAuth,
_caldav_discovery_client,
_caldav_discovery_response,
_plan_sync_source_creation,
caldav_client_for_source,
caldav_source_response,
create_calendar,
create_caldav_source,
create_event,
delete_caldav_source,
delete_calendar,
delete_event,
discover_caldav_calendars,
list_caldav_sources,
list_freebusy,
resolve_caldav_credential_ref,
resolve_trusted_deployment_caldav_credential_ref,
sync_caldav_source,
sync_due_caldav_sources,
update_caldav_source,
update_event,
)
from govoplan_calendar.backend.router import _caldav_discovery_http_error
from govoplan_core.db.base import Base
from govoplan_core.security.credential_envelopes import create_credential_envelope
from govoplan_core.tenancy.scope import create_scope_tables
from govoplan_tenancy.backend.db.models import Tenant
@@ -93,6 +124,84 @@ class FakeNotificationProvider:
class CalDAVParsingTests(unittest.TestCase):
def test_discovery_parser_is_independent_from_transport(self) -> None:
result = parse_discovery_multistatus(
b"""<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:response>
<D:href>/calendars/ada/work/</D:href>
<D:propstat>
<D:prop>
<D:displayname>Work</D:displayname>
<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>
<C:supported-calendar-component-set>
<C:comp name="VEVENT"/>
</C:supported-calendar-component-set>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>"""
)
self.assertEqual(1, len(result))
self.assertEqual("Work", result[0].display_name)
self.assertTrue(result[0].is_calendar)
self.assertEqual(("VEVENT",), result[0].supported_components)
def test_discovery_auth_client_and_response_do_not_expose_secret(self) -> None:
auth = _CalDAVDiscoveryAuth(
auth_type="basic",
username="ada",
credential_ref=None,
secret="calendar-secret",
)
client = _caldav_discovery_client(
"https://dav.example.test/cal",
auth,
)
response = _caldav_discovery_response(
(
CalDAVDiscoveryCalendar(
collection_url="https://dav.example.test/cal/work/",
href="/cal/work/",
display_name="Work",
),
)
)
self.assertNotIn("calendar-secret", repr(auth))
self.assertEqual("calendar-secret", client.password)
self.assertEqual("Work", response[0]["display_name"])
self.assertNotIn("password", response[0])
self.assertNotIn("credential_ref", response[0])
def test_discovery_error_translation_is_stable(self) -> None:
error = _caldav_discovery_http_error(
CalDAVError("CalDAV endpoint rejected PROPFIND")
)
self.assertEqual(422, error.status_code)
self.assertEqual(
"CalDAV endpoint rejected PROPFIND",
error.detail,
)
def test_sync_source_creation_plan_is_secret_free(self) -> None:
plan = _plan_sync_source_creation(
CalendarCalDavSourceCreateRequest(
calendar_id="calendar-1",
collection_url="dav.example.test/cal",
auth_type="basic",
username="ada",
password="calendar-secret",
)
)
self.assertEqual("https://dav.example.test/cal/", plan.collection_url)
self.assertTrue(plan.has_inline_secret)
self.assertNotIn("calendar-secret", repr(plan))
def test_parse_multistatus_extracts_objects_sync_token_and_deletions(self) -> None:
result = parse_multistatus(
b"""<?xml version="1.0" encoding="utf-8"?>
@@ -286,6 +395,259 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual(client.username, "ada")
self.assertEqual(client.password, "secret")
def test_source_can_resolve_reusable_core_credential(self) -> None:
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
credential = create_credential_envelope(
session,
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Shared DAV login",
credential_kind="username_password",
public_data={"username": "ada"},
secret_data={"password": "secret"},
allowed_modules=["calendar"],
inherit_to_lower_scopes=True,
)
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/cal",
auth_type="basic",
credential_ref=f"credential-envelope:{credential.id}",
),
)
session.commit()
client = caldav_client_for_source(session, source)
response = caldav_source_response(source)
self.assertEqual(client.username, "ada")
self.assertEqual(client.password, "secret")
self.assertEqual(response["credential_envelope_id"], credential.id)
self.assertEqual(session.query(CalendarSyncCredential).count(), 0)
def test_api_source_and_discovery_reject_caller_selected_credential_references(self) -> None:
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
with self.assertRaisesRegex(CalendarError, "Caller-supplied credential references"):
create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://attacker.example.test/cal",
auth_type="bearer",
credential_ref="env:MASTER_KEY_B64",
),
)
with self.assertRaisesRegex(CalendarError, "Caller-supplied credential references"):
discover_caldav_calendars(
session,
tenant_id="tenant-1",
payload=CalendarCalDavDiscoveryRequest(
url="https://attacker.example.test/cal",
auth_type="bearer",
credential_ref="vault:another-tenant",
),
)
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/cal",
),
)
with self.assertRaisesRegex(CalendarError, "Caller-supplied credential references"):
update_caldav_source(
session,
tenant_id="tenant-1",
source_id=source.id,
payload=CalendarCalDavSourceUpdateRequest(
credential_ref="env:MASTER_KEY_B64",
),
)
def test_provider_credentials_are_wrapped_in_tenant_and_source_owned_rows(self) -> None:
class Provider:
def __init__(self) -> None:
self.values: dict[str, str] = {}
self.deleted: list[str] = []
def store_secret(self, *, scope: str, name: str, value: str) -> str:
reference = f"vault:{scope}:{name}"
self.values[reference] = value
return reference
def read_secret(self, secret_ref: str) -> str | None:
return self.values.get(secret_ref)
def delete_secret(self, secret_ref: str) -> None:
self.deleted.append(secret_ref)
self.values.pop(secret_ref, None)
provider = Provider()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/cal",
auth_type="basic",
username="ada",
password="secret",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
provider_ref = str((credential.metadata_ or {})["provider_ref"])
self.assertTrue(source.credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX))
self.assertNotEqual(source.credential_ref, provider_ref)
self.assertEqual(caldav_client_for_source(session, source).password, "secret")
self.assertIsNone(caldav_source_response(source)["credential_ref"])
self.assertTrue(caldav_source_response(source)["has_credential"])
self.assertIsNone(
resolve_caldav_credential_ref(
session,
tenant_id="another-tenant",
source_id=source.id,
credential_ref=source.credential_ref,
)
)
self.assertIsNone(
resolve_caldav_credential_ref(
session,
tenant_id="tenant-1",
source_id="another-source",
credential_ref=source.credential_ref,
)
)
other_calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Other remote"),
)
other_source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=other_calendar.id,
collection_url="https://dav.example.test/other-cal",
),
)
other_source.auth_type = "basic"
other_source.username = "mallory"
other_source.credential_ref = source.credential_ref
with self.assertRaisesRegex(CalendarError, "not a server-owned credential"):
caldav_client_for_source(session, other_source)
delete_calendar(session, tenant_id="tenant-1", calendar_id=other_calendar.id)
self.assertEqual(provider.deleted, [])
with patch("govoplan_calendar.backend.service.audit_event") as audit:
delete_calendar(session, tenant_id="tenant-1", calendar_id=calendar.id)
self.assertEqual(provider.deleted, [provider_ref])
self.assertNotIn(provider_ref, provider.values)
self.assertIsNone(source.credential_ref)
self.assertIsNone(credential.secret_encrypted)
self.assertNotIn("provider_ref", credential.metadata_ or {})
audit.assert_called_once()
audit_call = audit.call_args.kwargs
self.assertEqual(audit_call["action"], "calendar.sync_credential_deleted")
self.assertEqual(audit_call["object_id"], credential.id)
self.assertEqual(audit_call["details"]["storage_backend"], "external_secret_provider")
self.assertEqual(audit_call["details"]["deletion_reason"], "calendar_deleted")
self.assertNotIn("credential_ref", audit_call["details"])
self.assertNotIn("provider_ref", audit_call["details"])
self.assertNotIn(provider_ref, repr(audit_call["details"]))
def test_legacy_unowned_refs_are_neither_read_nor_deleted(self) -> None:
class Provider:
def __init__(self) -> None:
self.read: list[str] = []
self.deleted: list[str] = []
def read_secret(self, secret_ref: str) -> str | None:
self.read.append(secret_ref)
return "other-tenant-secret"
def delete_secret(self, secret_ref: str) -> None:
self.deleted.append(secret_ref)
provider = Provider()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/cal",
),
)
source.auth_type = "bearer"
source.credential_ref = "vault:another-tenant"
session.commit()
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
with self.assertRaisesRegex(CalendarError, "not a server-owned credential"):
caldav_client_for_source(session, source)
delete_calendar(session, tenant_id="tenant-1", calendar_id=calendar.id)
self.assertEqual(provider.read, [])
self.assertEqual(provider.deleted, [])
def test_trusted_deployment_env_resolution_is_explicit_and_separate(self) -> None:
with patch.dict("os.environ", {"CALDAV_DEPLOYMENT_TOKEN": "trusted-token"}):
self.assertEqual(
resolve_trusted_deployment_caldav_credential_ref("env:CALDAV_DEPLOYMENT_TOKEN"),
"trusted-token",
)
with self.assertRaisesRegex(CalendarError, "must use the env: prefix"):
resolve_trusted_deployment_caldav_credential_ref("vault:token")
def test_delete_calendar_retires_caldav_source_and_credential(self) -> None:
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
@@ -309,9 +671,317 @@ class CalDAVSyncTests(unittest.TestCase):
credential = session.query(CalendarSyncCredential).one()
self.assertIsNotNone(source.deleted_at)
self.assertIsNone(source.credential_ref)
self.assertIsNotNone(credential.deleted_at)
self.assertIsNone(credential.secret_encrypted)
self.assertEqual(credential.metadata_, {"source_id": source.id})
self.assertEqual(list_caldav_sources(session, tenant_id="tenant-1"), [])
def test_external_credential_deletion_fails_closed_when_provider_is_unavailable(self) -> None:
class Provider:
def __init__(self) -> None:
self.values: dict[str, str] = {}
def store_secret(self, *, scope: str, name: str, value: str) -> str:
reference = f"vault:{scope}:{name}"
self.values[reference] = value
return reference
def read_secret(self, secret_ref: str) -> str | None:
return self.values.get(secret_ref)
def delete_secret(self, secret_ref: str) -> None:
self.values.pop(secret_ref, None)
provider = Provider()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/provider-down",
auth_type="bearer",
bearer_token="do-not-log-this",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
provider_ref = str((credential.metadata_ or {})["provider_ref"])
with patch("govoplan_calendar.backend.service.secret_provider", return_value=None), self.assertRaisesRegex(
CalendarError,
"secret provider is unavailable",
):
delete_caldav_source(session, tenant_id="tenant-1", source_id=source.id)
session.rollback()
self.assertIsNone(source.deleted_at)
self.assertEqual(source.credential_ref, f"{CALDAV_INTERNAL_CREDENTIAL_PREFIX}{credential.id}")
self.assertIsNone(credential.deleted_at)
self.assertEqual((credential.metadata_ or {})["provider_ref"], provider_ref)
self.assertIn(provider_ref, provider.values)
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider), patch.object(
provider,
"delete_secret",
side_effect=RuntimeError(f"provider failed for {provider_ref}"),
):
with self.assertRaises(CalendarError) as raised:
delete_caldav_source(session, tenant_id="tenant-1", source_id=source.id)
self.assertNotIn(provider_ref, str(raised.exception))
session.rollback()
self.assertIsNone(source.deleted_at)
self.assertIsNone(credential.deleted_at)
def test_disabling_auth_immediately_scrubs_and_audits_database_credential(self) -> None:
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/disable-auth",
auth_type="basic",
username="ada",
password="do-not-log-this",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
self.assertIsNotNone(credential.secret_encrypted)
with patch("govoplan_calendar.backend.service.audit_event") as audit:
update_caldav_source(
session,
tenant_id="tenant-1",
source_id=source.id,
payload=CalendarCalDavSourceUpdateRequest(auth_type="none"),
)
session.commit()
self.assertIsNone(source.credential_ref)
self.assertIsNone(credential.secret_encrypted)
self.assertIsNotNone(credential.deleted_at)
audit.assert_called_once()
self.assertEqual(
audit.call_args.kwargs["details"]["deletion_reason"],
"authentication_disabled",
)
self.assertNotIn("do-not-log-this", repr(audit.call_args))
def test_external_credential_replacement_fails_closed_when_provider_is_unavailable(self) -> None:
class Provider:
def __init__(self) -> None:
self.values: dict[str, str] = {}
def store_secret(self, *, scope: str, name: str, value: str) -> str:
reference = f"vault:{scope}:{name}"
self.values[reference] = value
return reference
def read_secret(self, secret_ref: str) -> str | None:
return self.values.get(secret_ref)
def delete_secret(self, secret_ref: str) -> None:
self.values.pop(secret_ref, None)
provider = Provider()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/replacement-provider-down",
auth_type="bearer",
bearer_token="old-secret",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
provider_ref = str((credential.metadata_ or {})["provider_ref"])
with patch("govoplan_calendar.backend.service.secret_provider", return_value=None), self.assertRaisesRegex(
CalendarError,
"cannot be replaced.*provider is unavailable",
):
update_caldav_source(
session,
tenant_id="tenant-1",
source_id=source.id,
payload=CalendarCalDavSourceUpdateRequest(bearer_token="new-secret"),
)
session.rollback()
self.assertEqual(source.credential_ref, f"{CALDAV_INTERNAL_CREDENTIAL_PREFIX}{credential.id}")
self.assertEqual((credential.metadata_ or {})["provider_ref"], provider_ref)
self.assertEqual(provider.values[provider_ref], "old-secret")
def test_external_credential_delete_is_retryable_after_database_rollback(self) -> None:
class Provider:
def __init__(self) -> None:
self.values: dict[str, str] = {}
self.deletes: list[str] = []
def store_secret(self, *, scope: str, name: str, value: str) -> str:
reference = f"vault:{scope}:{name}"
self.values[reference] = value
return reference
def read_secret(self, secret_ref: str) -> str | None:
return self.values.get(secret_ref)
def delete_secret(self, secret_ref: str) -> None:
self.deletes.append(secret_ref)
if secret_ref not in self.values:
raise KeyError("already deleted")
self.values.pop(secret_ref, None)
provider = Provider()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/retry-delete",
auth_type="basic",
username="ada",
password="do-not-log-this",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
provider_ref = str((credential.metadata_ or {})["provider_ref"])
with patch("govoplan_calendar.backend.service.audit_event"):
delete_caldav_source(session, tenant_id="tenant-1", source_id=source.id)
self.assertNotIn(provider_ref, provider.values)
self.assertIsNotNone(credential.deleted_at)
self.assertIsNone(source.credential_ref)
session.rollback()
self.assertIsNone(source.deleted_at)
session.refresh(credential)
self.assertIsNone(credential.deleted_at)
self.assertEqual((credential.metadata_ or {})["provider_ref"], provider_ref)
self.assertIsNone(resolve_caldav_credential_ref(
session,
tenant_id="tenant-1",
source_id=source.id,
credential_ref=source.credential_ref,
))
with patch("govoplan_calendar.backend.service.audit_event") as audit:
delete_caldav_source(session, tenant_id="tenant-1", source_id=source.id)
session.commit()
self.assertEqual(provider.deletes, [provider_ref, provider_ref])
self.assertIsNotNone(source.deleted_at)
self.assertIsNone(source.credential_ref)
self.assertIsNotNone(credential.deleted_at)
self.assertEqual(audit.call_count, 1)
def test_destructive_module_retirement_deletes_external_credentials_before_tables(self) -> None:
class Provider:
def __init__(self) -> None:
self.values: dict[str, str] = {}
self.deleted: list[str] = []
def store_secret(self, *, scope: str, name: str, value: str) -> str:
reference = f"vault:{scope}:{name}"
self.values[reference] = value
return reference
def read_secret(self, secret_ref: str) -> str | None:
return self.values.get(secret_ref)
def delete_secret(self, secret_ref: str) -> None:
self.deleted.append(secret_ref)
self.values.pop(secret_ref, None)
provider = Provider()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/module-retirement",
auth_type="bearer",
bearer_token="do-not-log-this",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
provider_ref = str((credential.metadata_ or {})["provider_ref"])
retirement_provider = manifest.migration_spec.retirement_provider
assert retirement_provider is not None
plan = retirement_provider(session, "calendar")
assert plan.destroy_data_executor is not None
with patch("govoplan_calendar.backend.service.audit_event") as audit:
plan.destroy_data_executor(session, "calendar")
# Retirement intentionally participates in the caller's database
# transaction. Commit before inspecting through a new Engine
# connection; otherwise SQLite rolls back the uncommitted DDL when
# the temporary inspector connection is returned to the pool.
session.commit()
self.assertEqual(provider.deleted, [provider_ref])
self.assertEqual(provider.values, {})
self.assertEqual(audit.call_count, 1)
self.assertEqual(
audit.call_args.kwargs["details"]["deletion_reason"],
"module_data_retired",
)
self.assertFalse(inspect(self.engine).has_table("calendar_sync_credentials"))
self.assertFalse(inspect(self.engine).has_table("calendar_sync_sources"))
def test_create_source_retires_orphaned_source_for_deleted_calendar(self) -> None:
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
+17
View File
@@ -5,6 +5,7 @@ import threading
import unittest
from collections.abc import Iterator
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from unittest.mock import patch
from govoplan_calendar.backend.caldav import (
CalDAVClient,
@@ -29,6 +30,22 @@ def running_http_server(handler: type[BaseHTTPRequestHandler]) -> Iterator[str]:
class CalDAVUrlSecurityTests(unittest.TestCase):
def test_transport_revalidates_dns_at_connection_time(self) -> None:
public = [(2, 1, 6, "", ("93.184.216.34", 443))]
private = [(2, 1, 6, "", ("127.0.0.1", 443))]
with patch.dict(
"os.environ",
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false"},
), patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
side_effect=(public, private),
), patch("govoplan_core.security.outbound_http.socket.socket") as socket_factory, self.assertRaisesRegex(
CalDAVError,
"non-public network",
):
urllib_transport("GET", "https://dav.example.test/event.ics", {}, None, 2)
socket_factory.assert_not_called()
def test_discovery_href_must_remain_on_configured_origin(self) -> None:
base_url = "https://dav.example.test/calendars/ada/"
+109 -2
View File
@@ -3,8 +3,25 @@ from __future__ import annotations
import unittest
from datetime import datetime, timezone
from govoplan_calendar.backend.capabilities import SqlCalendarSchedulingProvider
from govoplan_core.core.calendar import CalendarCapabilityError, CalendarEventRequest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db import models as access_models # noqa: F401
from govoplan_calendar.backend.capabilities import (
SqlCalendarInvitationProvider,
SqlCalendarSchedulingProvider,
)
from govoplan_calendar.backend.schemas import CalendarCollectionCreateRequest
from govoplan_calendar.backend.service import create_calendar
from govoplan_core.core.calendar import (
CalendarCapabilityError,
CalendarEventRequest,
CalendarInvitationAttendeeRequest,
CalendarInvitationRequest,
)
from govoplan_core.db.base import Base
from govoplan_core.tenancy.scope import create_scope_tables
from govoplan_tenancy.backend.db.models import Tenant
class CalendarSchedulingCapabilityTests(unittest.TestCase):
@@ -23,5 +40,95 @@ class CalendarSchedulingCapabilityTests(unittest.TestCase):
)
class CalendarInvitationCapabilityTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
create_scope_tables(self.engine)
Base.metadata.create_all(bind=self.engine)
self.Session = sessionmaker(bind=self.engine)
self.session = self.Session()
self.session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
self.calendar = create_calendar(
self.session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Invitations"),
)
self.provider = SqlCalendarInvitationProvider()
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def request(self, *, summary: str = "Planning") -> CalendarInvitationRequest:
return CalendarInvitationRequest(
correlation_id="campaign-1:recipient-1",
source_module="campaigns",
source_resource_type="campaign_recipient",
source_resource_id="recipient-1",
calendar_id=self.calendar.id,
summary=summary,
start_at=datetime(2026, 8, 5, 9, tzinfo=timezone.utc),
end_at=datetime(2026, 8, 5, 10, tzinfo=timezone.utc),
attendees=(
CalendarInvitationAttendeeRequest(
address="ada@example.test",
name="Ada",
),
),
)
def test_upsert_and_response_reconciliation_preserve_correlation(self) -> None:
created = self.provider.upsert_invitation(
self.session,
tenant_id="tenant-1",
user_id=None,
request=self.request(),
)
response = self.provider.record_response(
self.session,
tenant_id="tenant-1",
correlation_id=created.correlation_id,
attendee_address="ADA@example.test",
participation_status="accepted",
evidence={"transport": "ical-reply"},
)
updated = self.provider.upsert_invitation(
self.session,
tenant_id="tenant-1",
user_id=None,
request=self.request(summary="Updated planning"),
)
loaded = self.provider.get_invitation(
self.session,
tenant_id="tenant-1",
correlation_id=created.correlation_id,
)
self.assertEqual(created.event_id, updated.event_id)
self.assertEqual("ACCEPTED", response.attendees[0]["params"]["PARTSTAT"][0])
self.assertEqual("ACCEPTED", updated.attendees[0]["params"]["PARTSTAT"][0])
self.assertEqual(updated, loaded)
self.assertFalse(updated.recurrence_supported)
self.assertTrue(updated.degraded_reasons)
def test_reply_must_match_an_existing_attendee(self) -> None:
created = self.provider.upsert_invitation(
self.session,
tenant_id="tenant-1",
user_id=None,
request=self.request(),
)
with self.assertRaisesRegex(CalendarCapabilityError, "not an attendee"):
self.provider.record_response(
self.session,
tenant_id="tenant-1",
uid=created.uid,
attendee_address="mallory@example.test",
participation_status="DECLINED",
)
if __name__ == "__main__":
unittest.main()
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from govoplan_calendar.backend.ews import (
EwsAdapterError,
ews_find_item_body,
parse_ews_calendar_items,
)
from govoplan_calendar.backend.graph import graph_event_payload
class ProviderAdapterTests(unittest.TestCase):
def test_graph_event_fixture_maps_provider_fields(self) -> None:
payload = graph_event_payload(
{
"id": "graph-event-1",
"iCalUId": "uid-1@example.test",
"@odata.etag": 'W/"etag-1"',
"type": "occurrence",
"subject": "Planning",
"body": {"content": "Agenda"},
"start": {
"dateTime": "2026-07-08T09:00:00",
"timeZone": "Europe/Berlin",
},
"end": {
"dateTime": "2026-07-08T10:30:00",
"timeZone": "Europe/Berlin",
},
"organizer": {
"emailAddress": {
"name": "Ada",
"address": "ada@example.test",
}
},
"attendees": [
{
"type": "required",
"emailAddress": {
"name": "Lin",
"address": "lin@example.test",
},
}
],
"isReminderOn": True,
"reminderMinutesBeforeStart": 15,
}
)
self.assertEqual(payload["uid"], "uid-1@example.test")
self.assertEqual(payload["recurrence_id"], "graph-event-1")
self.assertEqual(
payload["start_at"],
datetime(2026, 7, 8, 7, 0, tzinfo=timezone.utc),
)
self.assertEqual(payload["duration_seconds"], 5_400)
self.assertEqual(
payload["attendees"][0]["email"],
"lin@example.test",
)
self.assertEqual(
payload["reminders"][0]["trigger_minutes_before"],
15,
)
def test_ews_calendar_fixture_maps_item_and_escapes_mailbox(self) -> None:
response_xml = """<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages"
xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types">
<s:Body><m:FindItemResponse><m:ResponseMessages>
<m:FindItemResponseMessage ResponseClass="Success"><m:RootFolder>
<t:Items><t:CalendarItem>
<t:ItemId Id="ews-event-1" ChangeKey="ews-etag-1" />
<t:Subject>EWS item</t:Subject>
<t:Start>2026-07-08T09:00:00Z</t:Start>
<t:End>2026-07-08T10:00:00Z</t:End>
<t:IsAllDayEvent>false</t:IsAllDayEvent>
<t:UID>ews-uid-1</t:UID>
<t:RequiredAttendees><t:Attendee><t:Mailbox>
<t:Name>Ada</t:Name>
<t:EmailAddress>ada@example.test</t:EmailAddress>
</t:Mailbox></t:Attendee></t:RequiredAttendees>
</t:CalendarItem></t:Items>
</m:RootFolder></m:FindItemResponseMessage>
</m:ResponseMessages></m:FindItemResponse></s:Body>
</s:Envelope>"""
items = parse_ews_calendar_items(response_xml)
self.assertEqual(len(items), 1)
self.assertEqual(items[0]["href"], "ews-event-1")
self.assertEqual(items[0]["etag"], "ews-etag-1")
self.assertEqual(
items[0]["attendees"][0]["email"],
"ada@example.test",
)
request_body = ews_find_item_body(
start=datetime(2026, 7, 1, tzinfo=timezone.utc),
end=datetime(2026, 8, 1, tzinfo=timezone.utc),
mailbox='ops&"<@example.test',
)
self.assertIn("ops&amp;&quot;&lt;@example.test", request_body)
def test_ews_parser_rejects_invalid_xml(self) -> None:
with self.assertRaisesRegex(EwsAdapterError, "Invalid EWS"):
parse_ews_calendar_items("<not-closed>")
if __name__ == "__main__":
unittest.main()
+256
View File
@@ -0,0 +1,256 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db import models as access_models # noqa: F401
from govoplan_calendar.backend.db.models import CalendarEvent
from govoplan_calendar.backend.schemas import (
CalendarCollectionCreateRequest,
CalendarEventCreateRequest,
CalendarEventOccurrenceUpdateRequest,
CalendarViewPreferencesUpdateRequest,
)
from govoplan_calendar.backend.service import (
CalendarError,
create_calendar,
create_event,
delete_event,
delete_event_occurrence,
get_calendar_view_preferences,
list_event_occurrences,
list_freebusy,
update_calendar_view_preferences,
update_event_occurrence,
)
from govoplan_core.db.base import Base
from govoplan_core.tenancy.scope import create_scope_tables
from govoplan_tenancy.backend.db.models import Tenant
class CalendarRecurrenceAndPreferenceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
create_scope_tables(self.engine)
Base.metadata.create_all(bind=self.engine)
self.Session = sessionmaker(bind=self.engine)
self.session = self.Session()
self.session.add(
Tenant(id="tenant-1", slug="tenant-1", name="Tenant")
)
self.calendar = create_calendar(
self.session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Calendar"),
)
self.session.flush()
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def recurring_master(self) -> CalendarEvent:
return create_event(
self.session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarEventCreateRequest(
calendar_id=self.calendar.id,
uid="series@example.test",
summary="Planning",
start_at=datetime(
2026, 7, 8, 9, 0, tzinfo=timezone.utc
),
end_at=datetime(
2026, 7, 8, 10, 0, tzinfo=timezone.utc
),
rrule={"FREQ": "WEEKLY", "COUNT": "3"},
),
)
def test_occurrence_override_and_cancellation_reconcile_list_and_freebusy(
self,
) -> None:
master = self.recurring_master()
override = update_event_occurrence(
self.session,
tenant_id="tenant-1",
user_id=None,
series_event_id=master.id,
payload=CalendarEventOccurrenceUpdateRequest(
recurrence_id="20260715T090000Z",
summary="Moved planning",
start_at=datetime(
2026, 7, 15, 13, 0, tzinfo=timezone.utc
),
end_at=datetime(
2026, 7, 15, 14, 30, tzinfo=timezone.utc
),
),
)
cancelled = delete_event_occurrence(
self.session,
tenant_id="tenant-1",
user_id=None,
series_event_id=master.id,
recurrence_id="20260722T090000Z",
)
self.session.commit()
events = list_event_occurrences(
self.session,
tenant_id="tenant-1",
start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
)
self.assertEqual(
[event["summary"] for event in events],
["Planning", "Moved planning"],
)
self.assertEqual(events[1]["id"], override.id)
self.assertEqual(events[1]["series_event_id"], master.id)
self.assertTrue(events[1]["is_override"])
self.assertEqual(
events[1]["start_at"],
datetime(2026, 7, 15, 13, 0, tzinfo=timezone.utc),
)
self.assertEqual(cancelled.status, "CANCELLED")
busy = list_freebusy(
self.session,
tenant_id="tenant-1",
start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
)
self.assertEqual(len(busy), 2)
self.assertEqual(
busy[1]["start_at"],
datetime(2026, 7, 15, 13, 0, tzinfo=timezone.utc),
)
def test_deleting_series_removes_its_overrides(self) -> None:
master = self.recurring_master()
update_event_occurrence(
self.session,
tenant_id="tenant-1",
user_id=None,
series_event_id=master.id,
payload=CalendarEventOccurrenceUpdateRequest(
recurrence_id="20260715T090000Z",
summary="Override",
),
)
delete_event(
self.session,
tenant_id="tenant-1",
event_id=master.id,
)
active = (
self.session.query(CalendarEvent)
.filter(CalendarEvent.deleted_at.is_(None))
.count()
)
self.assertEqual(active, 0)
def test_all_day_occurrence_retains_date_recurrence_id(self) -> None:
master = create_event(
self.session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarEventCreateRequest(
calendar_id=self.calendar.id,
uid="all-day-series@example.test",
summary="All-day planning",
start_at=datetime(
2026, 7, 8, tzinfo=timezone.utc
),
end_at=datetime(
2026, 7, 9, tzinfo=timezone.utc
),
all_day=True,
rrule={"FREQ": "WEEKLY", "COUNT": "2"},
),
)
events = list_event_occurrences(
self.session,
tenant_id="tenant-1",
start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
)
self.assertEqual(
[event["recurrence_id"] for event in events],
["20260708", "20260715"],
)
override = update_event_occurrence(
self.session,
tenant_id="tenant-1",
user_id=None,
series_event_id=master.id,
payload=CalendarEventOccurrenceUpdateRequest(
recurrence_id=events[1]["recurrence_id"],
summary="Moved all-day planning",
),
)
self.assertEqual(override.recurrence_id, "20260715")
def test_calendar_preferences_have_defaults_and_durable_user_overrides(
self,
) -> None:
defaults = get_calendar_view_preferences(
self.session,
tenant_id="tenant-1",
user_id="user-1",
)
self.assertTrue(defaults["dim_weekends"])
self.assertEqual(defaults["overridden_fields"], [])
updated = update_calendar_view_preferences(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=CalendarViewPreferencesUpdateRequest(
dim_weekends=False,
workday_start_hour=8,
workday_end_hour=18,
),
)
self.session.commit()
reloaded = get_calendar_view_preferences(
self.session,
tenant_id="tenant-1",
user_id="user-1",
)
self.assertEqual(updated, reloaded)
self.assertFalse(reloaded["dim_weekends"])
self.assertEqual(reloaded["workday_start_hour"], 8)
self.assertEqual(reloaded["workday_end_hour"], 18)
self.assertEqual(
set(reloaded["overridden_fields"]),
{"dim_weekends", "workday_start_hour", "workday_end_hour"},
)
def test_calendar_preferences_reject_inverted_workday(self) -> None:
with self.assertRaisesRegex(CalendarError, "end hour"):
update_calendar_view_preferences(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=CalendarViewPreferencesUpdateRequest(
workday_start_hour=18,
workday_end_hour=8,
),
)
if __name__ == "__main__":
unittest.main()
+8 -7
View File
@@ -14,17 +14,18 @@
"./styles/calendar.css": "./src/styles/calendar.css"
},
"scripts": {
"test:calendar-picker": "rm -rf .calendar-picker-test-build && mkdir -p .calendar-picker-test-build && printf '{\"type\":\"commonjs\"}\\n' > .calendar-picker-test-build/package.json && tsc -p tsconfig.calendar-picker-tests.json && node .calendar-picker-test-build/tests/calendar-picker.test.js && node tests/calendar-picker-structure.test.mjs"
"test:calendar-picker": "rm -rf .calendar-picker-test-build && mkdir -p .calendar-picker-test-build && printf '{\"type\":\"commonjs\"}\\n' > .calendar-picker-test-build/package.json && tsc -p tsconfig.calendar-picker-tests.json && node .calendar-picker-test-build/tests/calendar-picker.test.js && node tests/calendar-picker-structure.test.mjs",
"test:calendar-page": "tsc -p tsconfig.calendar-page-tests.json && node --experimental-strip-types tests/calendar-view-model.test.ts && node tests/calendar-page-structure.test.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"@vitejs/plugin-react": "^4.3.4",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9",
"@vitejs/plugin-react": "^5.2.0",
"typescript": "^5.7.2",
"vite": "^6.0.6"
"vite": "^7.3.6"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
+107 -2
View File
@@ -51,6 +51,10 @@ export type CalendarEvent = {
created_at: string;
updated_at: string;
metadata?: Record<string, unknown> | null;
instance_id?: string | null;
series_event_id?: string | null;
is_occurrence?: boolean;
is_override?: boolean;
};
export type CalendarCollectionListResponse = { calendars: CalendarCollection[] };
@@ -78,7 +82,7 @@ export type CalendarSyncSource = {
display_name?: string | null;
auth_type: CalendarCalDavAuthType;
username?: string | null;
credential_ref?: string | null;
credential_envelope_id?: string | null;
has_credential: boolean;
sync_enabled: boolean;
sync_interval_seconds: number;
@@ -143,6 +147,26 @@ export type CalendarCalDavSourceCreatePayload = CalendarSyncSourceCreatePayload;
export type CalendarCalDavSourceUpdatePayload = Partial<CalendarCalDavSourceCreatePayload>;
export type CalendarSyncSourceUpdatePayload = Partial<CalendarSyncSourceCreatePayload>;
export type CalendarCredentialEnvelope = {
id: string;
scope_type: string;
scope_id?: string | null;
name: string;
description?: string | null;
credential_kind: string;
public_data: Record<string, unknown>;
secret_keys: string[];
secret_configured: boolean;
allowed_modules: string[];
inherit_to_lower_scopes: boolean;
is_active: boolean;
revision: string;
};
export type CalendarCredentialEnvelopeListResponse = {
credentials: CalendarCredentialEnvelope[];
};
export type CalendarSyncSourceSyncPayload = {
password?: string | null;
bearer_token?: string | null;
@@ -223,6 +247,29 @@ export type CalendarEventCreatePayload = {
};
export type CalendarEventUpdatePayload = Partial<CalendarEventCreatePayload>;
export type CalendarViewPreferences = {
dim_weekends: boolean;
dim_off_hours: boolean;
workday_start_hour: number;
workday_end_hour: number;
continuous_virtualization: boolean;
continuous_overscan_weeks: number;
alternate_continuous_months: boolean;
overridden_fields: string[];
defaults: Record<string, boolean | number>;
};
export type CalendarViewPreferencesUpdatePayload = Partial<
Pick<
CalendarViewPreferences,
| "dim_weekends"
| "dim_off_hours"
| "workday_start_hour"
| "workday_end_hour"
| "continuous_virtualization"
| "continuous_overscan_weeks"
| "alternate_continuous_months"
>
>;
export function listCalendars(settings: ApiSettings): Promise<CalendarCollectionListResponse> {
return apiFetch<CalendarCollectionListResponse>(settings, "/api/v1/calendar/calendars");
@@ -255,6 +302,13 @@ export function listCalDavSources(settings: ApiSettings, params: { calendar_id?:
return apiFetch<CalendarCalDavSourceListResponse>(settings, `/api/v1/calendar/caldav/sources${suffix}`);
}
export function listCalendarCredentials(settings: ApiSettings, sourceId?: string | null): Promise<CalendarCredentialEnvelopeListResponse> {
const search = new URLSearchParams();
if (sourceId) search.set("source_id", sourceId);
const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<CalendarCredentialEnvelopeListResponse>(settings, `/api/v1/calendar/credentials${suffix}`);
}
export function discoverCalDavCalendars(settings: ApiSettings, payload: CalendarCalDavDiscoveryPayload): Promise<CalendarCalDavDiscoveryResponse> {
return apiFetch<CalendarCalDavDiscoveryResponse>(settings, "/api/v1/calendar/caldav/discover", { method: "POST", body: JSON.stringify(payload) });
}
@@ -293,12 +347,13 @@ export function syncCalDavSource(settings: ApiSettings, sourceId: string, payloa
export function listCalendarEvents(
settings: ApiSettings,
params: { calendar_id?: string; start_at?: string; end_at?: string } = {}
params: { calendar_id?: string; start_at?: string; end_at?: string; expand_recurring?: boolean } = {}
): Promise<CalendarEventListResponse> {
const search = new URLSearchParams();
if (params.calendar_id) search.set("calendar_id", params.calendar_id);
if (params.start_at) search.set("start_at", params.start_at);
if (params.end_at) search.set("end_at", params.end_at);
if (params.expand_recurring) search.set("expand_recurring", "true");
const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<CalendarEventListResponse>(settings, `/api/v1/calendar/events${suffix}`);
}
@@ -317,6 +372,10 @@ export function listCalendarEventsDelta(
return apiFetch<CalendarEventDeltaResponse>(settings, `/api/v1/calendar/events/delta${suffix}`);
}
export function getCalendarEvent(settings: ApiSettings, eventId: string): Promise<CalendarEvent> {
return apiFetch<CalendarEvent>(settings, `/api/v1/calendar/events/${eventId}`);
}
export function createCalendarEvent(settings: ApiSettings, payload: CalendarEventCreatePayload): Promise<CalendarEvent> {
return apiFetch<CalendarEvent>(settings, "/api/v1/calendar/events", { method: "POST", body: JSON.stringify(payload) });
}
@@ -328,3 +387,49 @@ export function updateCalendarEvent(settings: ApiSettings, eventId: string, payl
export function deleteCalendarEvent(settings: ApiSettings, eventId: string): Promise<void> {
return apiFetch<void>(settings, `/api/v1/calendar/events/${eventId}`, { method: "DELETE" });
}
export function updateCalendarEventOccurrence(
settings: ApiSettings,
seriesEventId: string,
recurrenceId: string,
payload: CalendarEventUpdatePayload
): Promise<CalendarEvent> {
return apiFetch<CalendarEvent>(
settings,
`/api/v1/calendar/events/${seriesEventId}/occurrence`,
{
method: "PATCH",
body: JSON.stringify({ ...payload, recurrence_id: recurrenceId })
}
);
}
export function deleteCalendarEventOccurrence(
settings: ApiSettings,
seriesEventId: string,
recurrenceId: string
): Promise<CalendarEvent> {
return apiFetch<CalendarEvent>(
settings,
`/api/v1/calendar/events/${seriesEventId}/occurrence`,
{
method: "DELETE",
body: JSON.stringify({ recurrence_id: recurrenceId })
}
);
}
export function getCalendarViewPreferences(settings: ApiSettings): Promise<CalendarViewPreferences> {
return apiFetch<CalendarViewPreferences>(settings, "/api/v1/calendar/preferences/view");
}
export function updateCalendarViewPreferences(
settings: ApiSettings,
payload: CalendarViewPreferencesUpdatePayload
): Promise<CalendarViewPreferences> {
return apiFetch<CalendarViewPreferences>(
settings,
"/api/v1/calendar/preferences/view",
{ method: "PATCH", body: JSON.stringify(payload) }
);
}
@@ -0,0 +1,827 @@
import {
useEffect,
useMemo,
useState,
type ChangeEvent,
type FormEvent,
} from "react";
import { RefreshCw, Trash2 } from "lucide-react";
import {
Button,
ColorPickerField,
Dialog,
PasswordField,
SegmentedControl,
ToggleSwitch,
i18nMessage,
useUnsavedDraftGuard,
type ApiSettings,
} from "@govoplan/core-webui";
import {
listCalendarCredentials,
type CalendarBulkMoveExternalAction,
type CalendarCalDavAuthType,
type CalendarCalDavConflictPolicy,
type CalendarCalDavDiscoveryCandidate,
type CalendarCalDavDiscoveryPayload,
type CalendarCalDavSyncDirection,
type CalendarCollection,
type CalendarCollectionDeletePayload,
type CalendarCredentialEnvelope,
type CalendarDeleteEventAction,
type CalendarSyncSource,
type CalendarSyncSourceCreatePayload,
type CalendarSyncSourceKind,
type CalendarSyncSourceUpdatePayload,
} from "../../api/calendar";
import {
DEFAULT_CALENDAR_COLOR,
calendarDraftKey,
dateTimeLabel,
errorText,
normalizeHexColor,
} from "./calendarViewModel";
export type CalendarSourceMode = "local" | CalendarSyncSourceKind;
type CalendarSourceSwitchMode =
| "local"
| "caldav"
| "ics"
| "graph"
| "ews";
export type CalendarCollectionDialogState =
| { kind: "create" }
| {
kind: "edit";
calendar: CalendarCollection;
eventCount: number | null;
loadingEventCount: boolean;
};
export type CalendarDeleteDialogState = {
calendar: CalendarCollection;
eventCount: number | null;
loadingEventCount: boolean;
};
export type CalendarCalDavFormPayload = {
collection_url: string;
dav_url: string;
display_name: string;
auth_type: CalendarCalDavAuthType;
username: string;
credential_envelope_id: string;
password: string;
bearer_token: string;
sync_enabled: boolean;
sync_interval_seconds: number;
sync_direction: CalendarCalDavSyncDirection;
conflict_policy: CalendarCalDavConflictPolicy;
};
export type CalendarCollectionFormPayload = {
sourceMode: CalendarSourceMode;
name: string;
color: string;
caldav: CalendarCalDavFormPayload;
};
export function CalendarCollectionDialog({
state,
settings,
source,
saving,
syncingSourceId,
canWrite,
canDelete,
canManageSources,
canSyncSources,
onCancel,
onSave,
onRequestDelete,
onSync,
onDiscover
}: {state: CalendarCollectionDialogState;settings: ApiSettings;source: CalendarSyncSource | null;saving: boolean;syncingSourceId: string;canWrite: boolean;canDelete: boolean;canManageSources: boolean;canSyncSources: boolean;onCancel: () => void;onSave: (payload: CalendarCollectionFormPayload) => Promise<boolean>;onRequestDelete: (calendar: CalendarCollection, eventCount: number | null, loadingEventCount: boolean) => void;onSync: (source: CalendarSyncSource, payload?: {password?: string | null;bearer_token?: string | null;force_full?: boolean;}) => Promise<void>;onDiscover: (payload: CalendarCalDavDiscoveryPayload) => Promise<{calendars: CalendarCalDavDiscoveryCandidate[];}>;}) {
const calendar = state.kind === "edit" ? state.calendar : null;
const isEdit = Boolean(calendar);
const [sourceMode, setSourceMode] = useState<CalendarSourceMode>(source ? source.source_kind : "local");
const [name, setName] = useState(calendar?.name ?? "");
const [color, setColor] = useState(normalizeHexColor(calendar?.color) || DEFAULT_CALENDAR_COLOR);
const [davUrl, setDavUrl] = useState(source?.collection_url ?? "");
const [collectionUrl, setCollectionUrl] = useState(source?.collection_url ?? "");
const [displayName, setDisplayName] = useState(source?.display_name ?? "");
const [authType, setAuthType] = useState<CalendarCalDavAuthType>(source?.auth_type ?? "basic");
const [username, setUsername] = useState(source?.username ?? "");
const [credentialEnvelopeId, setCredentialEnvelopeId] = useState(source?.credential_envelope_id ?? "");
const [availableCredentials, setAvailableCredentials] = useState<CalendarCredentialEnvelope[]>([]);
const [credentialsError, setCredentialsError] = useState("");
const [password, setPassword] = useState("");
const [bearerToken, setBearerToken] = useState("");
const [syncEnabled, setSyncEnabled] = useState(source?.sync_enabled ?? true);
const [syncIntervalMinutes, setSyncIntervalMinutes] = useState(Math.max(1, Math.round((source?.sync_interval_seconds ?? 900) / 60)));
const [syncDirection, setSyncDirection] = useState<CalendarCalDavSyncDirection>(source?.sync_direction ?? "two_way");
const [conflictPolicy, setConflictPolicy] = useState<CalendarCalDavConflictPolicy>(source?.conflict_policy ?? "etag");
const [discoveredCalendars, setDiscoveredCalendars] = useState<CalendarCalDavDiscoveryCandidate[]>([]);
const [selectedDiscoveredUrl, setSelectedDiscoveredUrl] = useState(source?.collection_url ?? "");
const [discovering, setDiscovering] = useState(false);
const [discoveryError, setDiscoveryError] = useState("");
const formId = "calendar-collection-form";
const isExistingSyncSource = isEdit && Boolean(source);
const canEditSource = canManageSources;
const canEditMutableSourceSettings = canEditSource && !isExistingSyncSource;
const effectiveCollectionUrl = (collectionUrl || davUrl).trim();
const effectiveAuthType = sourceMode === "graph" ? "bearer" : authType;
const needsSourceSecret = sourceMode !== "local" && (
effectiveAuthType === "basic" && !source?.has_credential && !credentialEnvelopeId && !password.trim() ||
effectiveAuthType === "bearer" && !source?.has_credential && !credentialEnvelopeId && !bearerToken.trim());
const sourceDetailsInvalid = sourceMode !== "local" && (
!isExistingSyncSource && !canEditSource ||
canEditSource && (!effectiveCollectionUrl || effectiveAuthType === "basic" && !username.trim() || needsSourceSecret));
const saveDisabled =
saving ||
!canWrite ||
!name.trim() ||
sourceDetailsInvalid;
const syncing = source ? syncingSourceId === source.id : false;
const collectionDraft = {
sourceMode,
name,
color,
davUrl,
collectionUrl,
displayName,
authType,
username,
credentialEnvelopeId,
password,
bearerToken,
syncEnabled,
syncIntervalMinutes,
syncDirection,
conflictPolicy
};
const initialCollectionDraftKey = useMemo(() => calendarDraftKey(collectionDraft), []);
const collectionDirty = calendarDraftKey(collectionDraft) !== initialCollectionDraftKey;
useUnsavedDraftGuard({
dirty: collectionDirty,
onSave: saveCurrent,
onDiscard: onCancel
});
useEffect(() => {
if (sourceMode === "local" || !canManageSources) {
setAvailableCredentials([]);
setCredentialsError("");
return;
}
let active = true;
listCalendarCredentials(settings, source?.id)
.then((response) => {
if (active) setAvailableCredentials(response.credentials);
})
.catch((err) => {
if (active) {
setAvailableCredentials([]);
setCredentialsError(errorText(err));
}
});
return () => {
active = false;
};
}, [canManageSources, settings.accessToken, settings.apiBaseUrl, settings.apiKey, source?.id, sourceMode]);
function currentPayload(): CalendarCollectionFormPayload {
return {
sourceMode,
name,
color,
caldav: {
collection_url: effectiveCollectionUrl,
dav_url: davUrl,
display_name: displayName,
auth_type: effectiveAuthType,
username,
credential_envelope_id: credentialEnvelopeId,
password,
bearer_token: bearerToken,
sync_enabled: syncEnabled,
sync_interval_seconds: syncIntervalMinutes * 60,
sync_direction: syncDirection,
conflict_policy: conflictPolicy
}
};
}
async function saveCurrent(): Promise<boolean> {
if (saveDisabled) return false;
return onSave(currentPayload());
}
function submit(formEvent: FormEvent<HTMLFormElement>) {
formEvent.preventDefault();
void saveCurrent();
}
function selectSourceMode(next: CalendarSourceMode) {
setSourceMode(next);
setDiscoveryError("");
setDiscoveredCalendars([]);
if (next === "graph") {
setAuthType("bearer");
setSyncDirection("inbound");
if (!davUrl.trim()) handleDavUrlChange("me/calendar");
return;
}
if (next === "ews") {
setAuthType("basic");
setSyncDirection("inbound");
if (!davUrl.trim()) handleDavUrlChange("https://exchange.example.org/EWS/Exchange.asmx");
return;
}
if (next === "ics" || next === "webcal") {
setAuthType("none");
setSyncDirection("inbound");
return;
}
if (next === "caldav") {
setSyncDirection("two_way");
}
}
function handleDavUrlChange(next: string) {
setDavUrl(next);
setCollectionUrl(next);
setSelectedDiscoveredUrl("");
setDiscoveredCalendars([]);
setDiscoveryError("");
}
function applyDiscoveredCalendar(candidate: CalendarCalDavDiscoveryCandidate) {
setSelectedDiscoveredUrl(candidate.collection_url);
setCollectionUrl(candidate.collection_url);
if (candidate.display_name) {
if (!isExistingSyncSource) setDisplayName(candidate.display_name);
if (!isEdit && !name.trim()) setName(candidate.display_name);
}
const discoveredColor = normalizeHexColor(candidate.color);
if (!isEdit && discoveredColor) setColor(discoveredColor);
}
function handleDiscoveredCalendarChange(event: ChangeEvent<HTMLSelectElement>) {
const candidate = discoveredCalendars.find((item) => item.collection_url === event.target.value);
if (candidate) applyDiscoveredCalendar(candidate);
}
async function handleDiscover() {
const url = davUrl.trim();
if (!url) return;
setDiscovering(true);
setDiscoveryError("");
try {
const payload: CalendarCalDavDiscoveryPayload = {
url,
source_id: source?.id ?? null,
auth_type: authType,
username: authType === "basic" ? username.trim() || null : null
};
if (credentialEnvelopeId) payload.credential_ref = credentialEnvelopeRef(credentialEnvelopeId);
if (!credentialEnvelopeId && authType === "basic" && password.trim()) payload.password = password;
if (!credentialEnvelopeId && authType === "bearer" && bearerToken.trim()) payload.bearer_token = bearerToken;
const response = await onDiscover(payload);
setDiscoveredCalendars(response.calendars);
if (response.calendars.length > 0) {
applyDiscoveredCalendar(response.calendars[0]);
} else {
setDiscoveryError("i18n:govoplan-calendar.no_calendar_collections_found.6453624a");
}
} catch (err) {
setDiscoveryError(errorText(err));
} finally {
setDiscovering(false);
}
}
return (
<Dialog
open
title={isEdit ? "i18n:govoplan-calendar.edit_calendar.a47a2a7a" : "i18n:govoplan-calendar.add_calendar.8fadb5bc"}
className="calendar-event-dialog"
footerClassName="calendar-event-dialog-footer"
closeDisabled={saving}
onClose={onCancel}
footer={
<>
<div>
{calendar && canDelete &&
<Button type="button" variant="danger" onClick={() => onRequestDelete(calendar, state.kind === "edit" ? state.eventCount : null, state.kind === "edit" ? state.loadingEventCount : true)} disabled={saving}>
<Trash2 size={16} /> {calendarDeleteActionLabel(calendar)}
</Button>
}
</div>
<div className="calendar-dialog-actions">
<Button type="button" onClick={onCancel} disabled={saving}>i18n:govoplan-calendar.cancel.77dfd213</Button>
<Button type="submit" form={formId} variant="primary" disabled={saveDisabled}>{saving ? "i18n:govoplan-calendar.saving.ae7e8875" : isEdit ? "i18n:govoplan-calendar.save.efc007a3" : "i18n:govoplan-calendar.add.61cc55aa"}</Button>
</div>
</>
}>
<form id={formId} className="calendar-dialog-form" onSubmit={submit}>
{sourceMode !== "local" && !canManageSources && <p className="calendar-form-note">i18n:govoplan-calendar.managing_sync_sources_requires_calendar_administ.835e29fa</p>}
{!isEdit &&
<SegmentedControl
className="calendar-source-switch"
size="equal"
width="fill"
ariaLabel="i18n:govoplan-calendar.calendar_source_type.92cdb42f"
value={sourceSwitchMode(sourceMode)}
disabled={saving}
onChange={selectSourceMode}
options={[
{ id: "local", label: "i18n:govoplan-calendar.local.dc99d54d" },
{ id: "caldav", label: "i18n:govoplan-calendar.caldav.64f9720e", disabled: !canManageSources },
{ id: "ics", label: "i18n:govoplan-calendar.ics_webcal.9c55b570", disabled: !canManageSources },
{ id: "graph", label: "i18n:govoplan-calendar.graph.9a7405eb", disabled: !canManageSources },
{ id: "ews", label: "i18n:govoplan-calendar.exchange.5b13eac7", disabled: !canManageSources }
]}
/>
}
<div className="calendar-dialog-name-color-row">
<label>
<span>i18n:govoplan-calendar.name.709a2322</span>
<input value={name} onChange={(item) => setName(item.target.value)} required maxLength={255} autoFocus disabled={saving || !canWrite} />
</label>
<label className="calendar-dialog-color-label">
<span>i18n:govoplan-calendar.color.1d0c8304</span>
<ColorPickerField value={color} onChange={setColor} disabled={saving || !canWrite} />
</label>
</div>
{sourceMode === "local" ?
<section className="calendar-source-pane">
<h3>i18n:govoplan-calendar.local_calendar.ed3f72f8</h3>
<p className="calendar-form-note">i18n:govoplan-calendar.events_are_stored_in_govoplan_and_are_not_synced.3660e504</p>
</section> :
<section className="calendar-source-pane">
<div className="calendar-source-pane-heading">
<h3>{calendarSourcePaneTitle(sourceMode)}</h3>
{source &&
<span className={[
"calendar-sync-status",
syncing ? "is-syncing" : "",
source.last_status === "error" && !syncing ? "is-error" : ""].
filter(Boolean).join(" ")}>
{syncing ? "i18n:govoplan-calendar.syncing.4ae6fa22" : source.last_status || "i18n:govoplan-calendar.not_synced.4c205136"}
</span>
}
</div>
<div className="calendar-caldav-setup">
<label className="calendar-dialog-wide">
<span>{calendarSourceUrlLabel(sourceMode)}</span>
<input
value={davUrl}
onChange={(item) => handleDavUrlChange(item.target.value)}
placeholder={calendarSourceUrlPlaceholder(sourceMode)}
required
maxLength={1000}
disabled={saving || !canEditSource} />
</label>
<label>
<span>i18n:govoplan-calendar.authentication.ee1acfa5</span>
<select value={effectiveAuthType} onChange={(item) => setAuthType(item.target.value as CalendarCalDavAuthType)} disabled={saving || !canEditSource || sourceMode === "graph"}>
{sourceMode !== "graph" && <option value="basic">i18n:govoplan-calendar.basic.aa2c96da</option>}
{sourceMode !== "graph" && sourceMode !== "ews" && <option value="none">i18n:govoplan-calendar.none.6eef6648</option>}
<option value="bearer">i18n:govoplan-calendar.bearer_token.ffa64bcf</option>
</select>
</label>
{effectiveAuthType !== "none" &&
<label>
<span>Reusable credential</span>
<select
value={credentialEnvelopeId}
disabled={saving || !canEditSource}
onChange={(event) => {
const credentialId = event.target.value;
setCredentialEnvelopeId(credentialId);
setPassword("");
setBearerToken("");
const credential = availableCredentials.find((item) => item.id === credentialId);
const credentialUsername = credential?.public_data?.username;
if (credentialUsername) setUsername(String(credentialUsername));
}}>
<option value="">{source?.has_credential && !source.credential_envelope_id ? "Keep source-specific credential" : "Enter credentials below"}</option>
{availableCredentials.map((credential) =>
<option key={credential.id} value={credential.id}>
{credential.name}{credential.public_data?.username ? ` (${String(credential.public_data.username)})` : ""}
</option>
)}
</select>
</label>
}
{credentialsError && <p className="calendar-form-error calendar-dialog-wide">{credentialsError}</p>}
{effectiveAuthType === "basic" &&
<>
<label>
<span>i18n:govoplan-calendar.username.84c29015</span>
<input value={username} onChange={(item) => setUsername(item.target.value)} required maxLength={255} disabled={saving || !canEditSource || Boolean(credentialEnvelopeId)} />
</label>
{!credentialEnvelopeId &&
<label>
<span>{source?.has_credential ? "i18n:govoplan-calendar.replace_password.3f912c9c" : "i18n:govoplan-calendar.password.8be3c943"}</span>
<PasswordField value={password} onValueChange={setPassword} disabled={saving || !canEditSource} autoComplete="new-password" />
</label>
}
</>
}
{effectiveAuthType === "bearer" && !credentialEnvelopeId &&
<label>
<span>{source?.has_credential ? "i18n:govoplan-calendar.replace_token.bbeee6a9" : "i18n:govoplan-calendar.bearer_token.ffa64bcf"}</span>
<PasswordField value={bearerToken} onValueChange={setBearerToken} disabled={saving || !canEditSource} autoComplete="new-password" />
</label>
}
<div className={sourceMode === "caldav" ? "calendar-discovery-actions" : "calendar-discovery-actions is-readonly"}>
{sourceMode === "caldav" &&
<Button type="button" onClick={() => void handleDiscover()} disabled={saving || discovering || !canEditSource || !davUrl.trim() || effectiveAuthType === "basic" && !username.trim()}>
<RefreshCw size={16} /> {discovering ? "i18n:govoplan-calendar.discovering.1884f689" : "i18n:govoplan-calendar.discover.4827ea22"}
</Button>
}
{effectiveCollectionUrl && <span>{effectiveCollectionUrl}</span>}
</div>
</div>
{discoveryError && <p className="calendar-form-error">{discoveryError}</p>}
{sourceMode === "caldav" && discoveredCalendars.length > 0 &&
<label>
<span>i18n:govoplan-calendar.calendar.adab5090</span>
<select value={selectedDiscoveredUrl} onChange={handleDiscoveredCalendarChange} disabled={saving || !canEditSource}>
{discoveredCalendars.map((candidate) =>
<option key={candidate.collection_url} value={candidate.collection_url}>
{candidate.display_name || candidate.collection_url}
</option>
)}
</select>
</label>
}
<details className="calendar-advanced-settings">
<summary>i18n:govoplan-calendar.advanced.4d064726</summary>
<div className="calendar-dialog-grid-two">
<label>
<span>i18n:govoplan-calendar.display_name.c7874aaa</span>
<input value={displayName} onChange={(item) => setDisplayName(item.target.value)} maxLength={255} disabled={saving || !canEditMutableSourceSettings} />
</label>
</div>
<div className="calendar-sync-settings">
<ToggleSwitch label="i18n:govoplan-calendar.automatic_sync.084644b2" checked={syncEnabled} disabled={saving || !canEditMutableSourceSettings} onChange={setSyncEnabled} />
<label>
<span>i18n:govoplan-calendar.interval.011efcd5</span>
<input type="number" min={1} step={1} value={syncIntervalMinutes} onChange={(item) => setSyncIntervalMinutes(Math.max(1, Number(item.target.value) || 1))} disabled={saving || !canEditMutableSourceSettings} />
</label>
<label>
<span>i18n:govoplan-calendar.direction.fd8e45ba</span>
<select value={sourceMode === "caldav" ? syncDirection : "inbound"} onChange={(item) => setSyncDirection(item.target.value as CalendarCalDavSyncDirection)} disabled={saving || !canEditMutableSourceSettings || sourceMode !== "caldav"}>
<option value="two_way">i18n:govoplan-calendar.two_way.ee50a3e6</option>
<option value="inbound">i18n:govoplan-calendar.inbound_only.bf4269b0</option>
</select>
</label>
<label>
<span>i18n:govoplan-calendar.conflict_policy.5810e150</span>
<select value={conflictPolicy} onChange={(item) => setConflictPolicy(item.target.value as CalendarCalDavConflictPolicy)} disabled={saving || !canEditMutableSourceSettings || sourceMode !== "caldav"}>
<option value="etag">i18n:govoplan-calendar.require_matching_etag.0ab1ffe1</option>
<option value="overwrite">i18n:govoplan-calendar.overwrite_remote.39625e32</option>
</select>
</label>
</div>
</details>
{source &&
<div className="calendar-sync-status-panel">
<dl>
<div><dt>i18n:govoplan-calendar.last_attempt.82aee111</dt><dd>{source.last_attempt_at ? dateTimeLabel(new Date(source.last_attempt_at)) : "i18n:govoplan-calendar.never.80c3052d"}</dd></div>
<div><dt>i18n:govoplan-calendar.last_sync.ef0ef267</dt><dd>{source.last_synced_at ? dateTimeLabel(new Date(source.last_synced_at)) : "i18n:govoplan-calendar.never.80c3052d"}</dd></div>
<div><dt>i18n:govoplan-calendar.next_sync.88c7af72</dt><dd>{source.next_sync_at && source.sync_enabled ? dateTimeLabel(new Date(source.next_sync_at)) : "i18n:govoplan-calendar.not_scheduled.9c367369"}</dd></div>
<div><dt>i18n:govoplan-calendar.credential.8bede3ea</dt><dd>{source.has_credential ? "i18n:govoplan-calendar.configured.668c5fff" : "i18n:govoplan-calendar.not_configured.811931bb"}</dd></div>
</dl>
{source.last_error && <p className="calendar-form-error">{source.last_error}</p>}
<div className="calendar-sync-actions">
<Button
type="button"
className={syncing ? "calendar-sync-button is-syncing" : "calendar-sync-button"}
onClick={() => void onSync(source, syncTransientPayload(effectiveAuthType, password, bearerToken))}
disabled={saving || syncing || !canSyncSources}>
<RefreshCw size={16} className={syncing ? "calendar-sync-spin" : undefined} /> {syncing ? "i18n:govoplan-calendar.syncing.e5c7727a" : "i18n:govoplan-calendar.sync_now.2b7d938e"}
</Button>
<Button type="button" onClick={() => void onSync(source, { ...syncTransientPayload(effectiveAuthType, password, bearerToken), force_full: true })} disabled={saving || syncing || !canSyncSources}>
i18n:govoplan-calendar.full_sync.21b89c76
</Button>
</div>
</div>
}
{needsSourceSecret && <p className="calendar-form-note">i18n:govoplan-calendar.enter_a_password_or_token_for_this_source.74c09a54</p>}
</section>
}
</form>
</Dialog>);
}
export function CalendarCollectionDeleteDialog({
state,
calendars,
syncSources,
saving,
onCancel,
onDelete
}: {state: CalendarDeleteDialogState;calendars: CalendarCollection[];syncSources: CalendarSyncSource[];saving: boolean;onCancel: () => void;onDelete: (calendar: CalendarCollection, payload: CalendarCollectionDeletePayload) => Promise<void>;}) {
const { calendar, eventCount, loadingEventCount } = state;
const syncSourceByCalendarId = new Map(syncSources.map((source) => [source.calendar_id, source]));
const source = syncSourceByCalendarId.get(calendar.id) ?? null;
const moveTargets = calendars.filter((item) =>
item.id !== calendar.id && calendarMoveTargetIsSupported(source, syncSourceByCalendarId.get(item.id) ?? null)
);
const firstMoveTargetId = moveTargets[0]?.id || "";
const hasEvents = (eventCount ?? 0) > 0;
const canMoveEvents = hasEvents && moveTargets.length > 0;
const [eventAction, setEventAction] = useState<CalendarDeleteEventAction>("delete");
const [targetCalendarId, setTargetCalendarId] = useState(firstMoveTargetId);
const [makeTargetDefault, setMakeTargetDefault] = useState(calendar.is_default && Boolean(firstMoveTargetId));
const effectiveEventAction: CalendarDeleteEventAction = canMoveEvents ? eventAction : "delete";
const confirmDisabled = saving || loadingEventCount || canMoveEvents && effectiveEventAction === "move" && !targetCalendarId;
const actionLabel = calendarDeleteActionLabel(calendar);
const targetSource = syncSourceByCalendarId.get(targetCalendarId) ?? null;
const externalAction = calendarBulkMoveExternalAction(source, targetSource);
function confirm() {
void onDelete(calendar, {
event_action: effectiveEventAction,
target_calendar_id: effectiveEventAction === "move" ? targetCalendarId : null,
make_target_default: effectiveEventAction === "move" && calendar.is_default && makeTargetDefault,
external_action: effectiveEventAction === "move" ? externalAction : null
});
}
return (
<Dialog
open
role="alertdialog"
title={`${actionLabel} calendar`}
className="calendar-delete-dialog"
footerClassName="calendar-event-dialog-footer"
closeDisabled={saving}
onClose={onCancel}
footer={
<>
<Button type="button" onClick={onCancel} disabled={saving}>i18n:govoplan-calendar.cancel.77dfd213</Button>
<Button type="button" variant="danger" onClick={confirm} disabled={confirmDisabled}>
<Trash2 size={16} /> {saving ? "i18n:govoplan-calendar.working.049ac820" : actionLabel}
</Button>
</>
}>
<div className="calendar-delete-dialog-body">
<p className="calendar-delete-warning">
{loadingEventCount ?
"i18n:govoplan-calendar.loading_event_count.716ad3c2" :
calendarDeleteWarning(calendar, eventCount ?? 0, effectiveEventAction, targetCalendarId, moveTargets)}
</p>
{!loadingEventCount && eventCount === null && <p className="calendar-form-note">i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055</p>}
{canMoveEvents &&
<fieldset className="calendar-delete-options" disabled={saving || loadingEventCount}>
<legend>{eventCount} event{eventCount === 1 ? "" : "s"}</legend>
<label>
<input
type="radio"
name="calendar-delete-event-action"
value="delete"
checked={eventAction === "delete"}
onChange={() => setEventAction("delete")} />
<span>{calendarEventDeleteOptionLabel(calendar)}</span>
</label>
<label>
<input
type="radio"
name="calendar-delete-event-action"
value="move"
checked={eventAction === "move"}
onChange={() => setEventAction("move")} />
<span>{calendarBulkMoveOptionLabel(externalAction)}</span>
</label>
{eventAction === "move" &&
<>
<label className="calendar-delete-target-label">
<span>i18n:govoplan-calendar.target_calendar.e533fe1d</span>
<select value={targetCalendarId} onChange={(item) => setTargetCalendarId(item.target.value)} required>
{moveTargets.map((item) =>
<option key={item.id} value={item.id}>{item.name}</option>
)}
</select>
</label>
{calendar.is_default &&
<ToggleSwitch label="i18n:govoplan-calendar.make_target_calendar_the_default.10a3977b" checked={makeTargetDefault} onChange={setMakeTargetDefault} />
}
<p className="calendar-form-note">{calendarBulkMoveConsequence(externalAction)}</p>
</>
}
</fieldset>
}
{!loadingEventCount && hasEvents && moveTargets.length === 0 &&
<p className="calendar-form-note">{source ?
"i18n:govoplan-calendar.no_local_target_calendar_is_available_add_a_local_.fad3cb65" :
"i18n:govoplan-calendar.no_compatible_target_calendar_is_available_add_a_l.1cf6e47b"}</p>
}
</div>
</Dialog>);
}
export function syncSourceCreatePayload(sourceMode: CalendarSourceMode, payload: CalendarCalDavFormPayload): Omit<CalendarSyncSourceCreatePayload, "calendar_id"> {
const sourceKind = syncSourceKindForMode(sourceMode, payload.collection_url);
const result: Omit<CalendarSyncSourceCreatePayload, "calendar_id"> = {
source_kind: sourceKind,
collection_url: payload.collection_url.trim(),
display_name: payload.display_name.trim() || null,
auth_type: sourceKind === "graph" ? "bearer" : payload.auth_type,
username: sourceKind !== "graph" && payload.auth_type === "basic" ? payload.username.trim() || null : null,
sync_enabled: payload.sync_enabled,
sync_interval_seconds: Math.max(60, payload.sync_interval_seconds),
sync_direction: sourceKind === "caldav" ? payload.sync_direction : "inbound",
conflict_policy: payload.conflict_policy,
metadata: {}
};
if (payload.credential_envelope_id) {
result.credential_ref = credentialEnvelopeRef(payload.credential_envelope_id);
} else if (result.auth_type === "basic" && payload.password.trim()) {
result.password = payload.password;
} else if (result.auth_type === "bearer" && payload.bearer_token.trim()) {
result.bearer_token = payload.bearer_token;
}
return result;
}
export function syncSourceConnectionUpdatePayload(payload: CalendarCalDavFormPayload): CalendarSyncSourceUpdatePayload {
const result: CalendarSyncSourceUpdatePayload = {
collection_url: payload.collection_url.trim(),
auth_type: payload.auth_type,
username: payload.auth_type === "basic" ? payload.username.trim() || null : null
};
if (payload.credential_envelope_id) {
result.credential_ref = credentialEnvelopeRef(payload.credential_envelope_id);
} else if (payload.auth_type === "basic" && payload.password.trim()) {
result.password = payload.password;
} else if (payload.auth_type === "bearer" && payload.bearer_token.trim()) {
result.bearer_token = payload.bearer_token;
}
return result;
}
function credentialEnvelopeRef(credentialId: string): string {
return `credential-envelope:${credentialId}`;
}
function syncSourceKindForMode(sourceMode: CalendarSourceMode, collectionUrl: string): CalendarSyncSourceKind {
if (sourceMode === "ics" && collectionUrl.trim().toLowerCase().startsWith("webcal://")) return "webcal";
return sourceMode === "local" ? "caldav" : sourceMode;
}
function sourceSwitchMode(sourceMode: CalendarSourceMode): CalendarSourceSwitchMode {
return sourceMode === "webcal" ? "ics" : sourceMode;
}
function syncTransientPayload(authType: CalendarCalDavAuthType, password: string, bearerToken: string) {
if (authType === "basic" && password.trim()) return { password };
if (authType === "bearer" && bearerToken.trim()) return { bearer_token: bearerToken };
return {};
}
function calendarSourcePaneTitle(sourceMode: CalendarSourceMode): string {
if (sourceMode === "caldav") return "i18n:govoplan-calendar.caldav_source.459ed16a";
if (sourceMode === "ics" || sourceMode === "webcal") return "i18n:govoplan-calendar.ics_webcal_subscription.03bc0d63";
if (sourceMode === "graph") return "i18n:govoplan-calendar.microsoft_graph_source.ec4f1383";
if (sourceMode === "ews") return "i18n:govoplan-calendar.exchange_web_services_source.53caabf3";
return "i18n:govoplan-calendar.local_calendar.ed3f72f8";
}
function calendarSourceUrlLabel(sourceMode: CalendarSourceMode): string {
if (sourceMode === "caldav") return "i18n:govoplan-calendar.dav_url.4205e180";
if (sourceMode === "graph") return "i18n:govoplan-calendar.graph_calendar_url.e59607f3";
if (sourceMode === "ews") return "i18n:govoplan-calendar.ews_endpoint.a3273983";
return "i18n:govoplan-calendar.subscription_url.b8b6a1a0";
}
function calendarSourceUrlPlaceholder(sourceMode: CalendarSourceMode): string {
if (sourceMode === "caldav") return "https://cloud.example.org/remote.php/dav";
if (sourceMode === "graph") return "me/calendar or https://graph.microsoft.com/v1.0/me/calendar/events/delta";
if (sourceMode === "ews") return "https://exchange.example.org/EWS/Exchange.asmx";
return "webcal://example.org/calendar.ics or https://example.org/calendar.ics";
}
function calendarDeleteActionLabel(calendar: CalendarCollection): string {
return calendarIsExternal(calendar) ? "i18n:govoplan-calendar.remove.e963907d" : "i18n:govoplan-calendar.delete.f6fdbe48";
}
function calendarEventDeleteOptionLabel(calendar: CalendarCollection): string {
return calendarIsExternal(calendar) ? "i18n:govoplan-calendar.remove_local_events_with_this_calendar.fb8831e1" : "i18n:govoplan-calendar.delete_events_with_this_calendar.cc0e1287";
}
function calendarMoveTargetIsSupported(
source: CalendarSyncSource | null,
targetSource: CalendarSyncSource | null)
: boolean {
if (source) return targetSource === null;
return targetSource === null || calendarSyncSourceAcceptsCopies(targetSource);
}
function calendarSyncSourceAcceptsCopies(source: CalendarSyncSource): boolean {
return source.source_kind === "caldav" && source.sync_enabled && source.sync_direction === "two_way";
}
function calendarBulkMoveExternalAction(
source: CalendarSyncSource | null,
targetSource: CalendarSyncSource | null)
: CalendarBulkMoveExternalAction | undefined {
if (source && !targetSource) return "detach_keep_remote";
if (!source && targetSource && calendarSyncSourceAcceptsCopies(targetSource)) return "copy_to_remote";
return undefined;
}
function calendarBulkMoveOptionLabel(action: CalendarBulkMoveExternalAction | undefined): string {
if (action === "detach_keep_remote") return "i18n:govoplan-calendar.detach_local_events_and_keep_remote_events.c58ad4e7";
if (action === "copy_to_remote") return "i18n:govoplan-calendar.move_and_copy_events_to_the_external_calendar.23c3a004";
return "i18n:govoplan-calendar.move_events_to_another_calendar.830c7b09";
}
function calendarBulkMoveConsequence(action: CalendarBulkMoveExternalAction | undefined): string {
if (action === "detach_keep_remote") return "i18n:govoplan-calendar.govoplan_moves_the_local_event_copies_to_the_targe.4863e46b";
if (action === "copy_to_remote") return "i18n:govoplan-calendar.govoplan_moves_the_events_locally_and_queues_durab.da075b16";
return "i18n:govoplan-calendar.events_remain_in_govoplan_no_external_calendar_is_.62cb5937";
}
function calendarDeleteWarning(
calendar: CalendarCollection,
eventCount: number,
eventAction: CalendarDeleteEventAction,
targetCalendarId: string,
moveTargets: CalendarCollection[])
: string {
const action = calendarIsExternal(calendar) ? "i18n:govoplan-calendar.removing.b7d2c38b" : "i18n:govoplan-calendar.deleting.2cda36c9";
const eventWord = eventCount === 1 ? "i18n:govoplan-calendar.event" : "i18n:govoplan-calendar.events";
if (eventAction === "move") {
const target = moveTargets.find((item) => item.id === targetCalendarId);
return i18nMessage("i18n:govoplan-calendar.value_this_calendar_will_move_value_value_to_val.6c87e1c1", { value0: action, value1: eventCount, value2: eventWord, value3: target?.name || "i18n:govoplan-calendar.the_selected_calendar.67caa12f" });
}
if (calendarIsExternal(calendar)) {
return i18nMessage("i18n:govoplan-calendar.value_this_calendar_will_remove_value_local_valu.8f21a59e", { value0: action, value1: eventCount, value2: eventWord });
}
return i18nMessage("i18n:govoplan-calendar.value_this_calendar_will_delete_value_value.7869d1f1", { value0: action, value1: eventCount, value2: eventWord });
}
function calendarIsExternal(calendar: CalendarCollection): boolean {
const metadata = calendar.metadata;
if (!metadata || typeof metadata !== "object") return false;
const sourceKind = metadataText(metadata, "source_kind") || metadataText(metadata, "sourceKind") || metadataText(metadata, "connector_kind") || metadataText(metadata, "connectorKind");
if (sourceKind && sourceKind !== "local") return true;
const connectionKind = metadataText(metadata, "connection_kind") || metadataText(metadata, "connectionKind");
if (connectionKind && connectionKind !== "local") return true;
return Boolean(
metadataFlag(metadata, "external") ||
metadataFlag(metadata, "remote") ||
metadataFlag(metadata, "sync") ||
metadataFlag(metadata, "caldav") ||
metadataText(metadata, "connector_id") ||
metadataText(metadata, "connectorId") ||
metadataText(metadata, "sync_href") ||
metadataText(metadata, "syncHref")
);
}
function metadataText(metadata: Record<string, unknown>, key: string): string {
const value = metadata[key];
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function metadataFlag(metadata: Record<string, unknown>, key: string): boolean {
return metadata[key] === true;
}
@@ -0,0 +1,596 @@
import {
useMemo,
useState,
type FormEvent,
} from "react";
import { Trash2 } from "lucide-react";
import {
Button,
DateField,
Dialog,
SegmentedControl,
TimeField,
ToggleSwitch,
useUnsavedDraftGuard,
} from "@govoplan/core-webui";
import type {
CalendarCollection,
CalendarEvent,
CalendarEventCreatePayload,
} from "../../api/calendar";
import {
addDays,
addHours,
addMinutesToTime,
calendarDraftKey,
errorText,
localDateTime,
startOfDay,
toInputDate,
toInputTime,
} from "./calendarViewModel";
type CalendarEventEndMode = "end" | "duration";
type CalendarEventEditScope = "occurrence" | "series";
type CalendarEventAdvancedPayload = Pick<
CalendarEventCreatePayload,
| "organizer"
| "attendees"
| "categories"
| "rrule"
| "rdate"
| "exdate"
| "reminders"
| "attachments"
| "related_to"
| "icalendar"
| "metadata"
>;
export function CalendarEventDialog({
calendars,
defaultCalendarId,
event,
editScope,
canChooseSeries,
seriesLoaded,
focusDate,
saving,
canWrite,
canDelete,
onEditScopeChange,
onCancel,
onSave,
onDelete
}: {
calendars: CalendarCollection[];
defaultCalendarId: string;
event: CalendarEvent | null;
editScope: CalendarEventEditScope;
canChooseSeries: boolean;
seriesLoaded: boolean;
focusDate: Date;
saving: boolean;
canWrite: boolean;
canDelete: boolean;
onEditScopeChange: (scope: CalendarEventEditScope) => void;
onCancel: () => void;
onSave: (
payload: CalendarEventCreatePayload,
event: CalendarEvent | null,
editScope: CalendarEventEditScope
) => Promise<boolean>;
onDelete: (
event: CalendarEvent,
editScope: CalendarEventEditScope
) => Promise<void>;
}) {
const initialStart = event ? new Date(event.start_at) : focusDate;
const initialEnd = event?.end_at ? new Date(event.end_at) : addHours(initialStart, 1);
const initialAllDayEnd = event?.all_day && event.end_at ? addDays(startOfDay(initialEnd), -1) : initialEnd;
const initialDurationSeconds = event?.duration_seconds ?? Math.max(3600, Math.round((initialEnd.getTime() - initialStart.getTime()) / 1000));
const [summary, setSummary] = useState(event?.summary ?? "");
const [calendarId, setCalendarId] = useState(event?.calendar_id ?? defaultCalendarId);
const [description, setDescription] = useState(event?.description ?? "");
const [location, setLocation] = useState(event?.location ?? "");
const [startDate, setStartDate] = useState(toInputDate(initialStart));
const [endDate, setEndDate] = useState(toInputDate(initialAllDayEnd < initialStart ? initialStart : initialAllDayEnd));
const [startTime, setStartTime] = useState(toInputTime(initialStart));
const [endTime, setEndTime] = useState(toInputTime(initialEnd));
const [allDay, setAllDay] = useState(event?.all_day ?? false);
const [endMode, setEndMode] = useState<CalendarEventEndMode>(event && eventUsesDuration(event) ? "duration" : "end");
const [durationSeconds, setDurationSeconds] = useState(String(initialDurationSeconds));
const [uid, setUid] = useState(event?.uid ?? "");
const [recurrenceId, setRecurrenceId] = useState(event?.recurrence_id ?? "");
const [sequence, setSequence] = useState(String(event?.sequence ?? 0));
const [status, setStatus] = useState(event?.status ?? "CONFIRMED");
const [transparency, setTransparency] = useState(event?.transparency ?? "OPAQUE");
const [classification, setClassification] = useState(event?.classification ?? "PUBLIC");
const [timezone, setTimezone] = useState(event?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC");
const [categoriesText, setCategoriesText] = useState((event?.categories ?? []).join(", "));
const [organizerJson, setOrganizerJson] = useState(jsonTextareaValue(event?.organizer ?? null));
const [attendeesJson, setAttendeesJson] = useState(jsonTextareaValue(event?.attendees ?? []));
const [rruleText, setRruleText] = useState(event?.rrule ? serializeRRuleText(event.rrule) : "");
const [rdateJson, setRdateJson] = useState(jsonTextareaValue(event?.rdate ?? []));
const [exdateJson, setExdateJson] = useState(jsonTextareaValue(event?.exdate ?? []));
const [remindersJson, setRemindersJson] = useState(jsonTextareaValue(event?.reminders ?? []));
const [attachmentsJson, setAttachmentsJson] = useState(jsonTextareaValue(event?.attachments ?? []));
const [relatedToJson, setRelatedToJson] = useState(jsonTextareaValue(event?.related_to ?? []));
const [sourceKind, setSourceKind] = useState(event?.source_kind ?? "local");
const [sourceHref, setSourceHref] = useState(event?.source_href ?? "");
const [etag, setEtag] = useState(event?.etag ?? "");
const [icalendarJson, setICalendarJson] = useState(jsonTextareaValue(event?.icalendar ?? {}));
const [metadataJson, setMetadataJson] = useState(jsonTextareaValue(event?.metadata ?? {}));
const [formError, setFormError] = useState("");
const [confirmingDelete, setConfirmingDelete] = useState(false);
const formId = "calendar-event-form";
const eventDraft = {
summary,
calendarId,
description,
location,
startDate,
endDate,
startTime,
endTime,
allDay,
endMode,
durationSeconds,
uid,
recurrenceId,
sequence,
status,
transparency,
classification,
timezone,
categoriesText,
organizerJson,
attendeesJson,
rruleText,
rdateJson,
exdateJson,
remindersJson,
attachmentsJson,
relatedToJson,
sourceKind,
sourceHref,
etag,
icalendarJson,
metadataJson
};
const initialEventDraftKey = useMemo(() => calendarDraftKey(eventDraft), []);
const eventDirty = calendarDraftKey(eventDraft) !== initialEventDraftKey;
useUnsavedDraftGuard({
dirty: eventDirty,
onSave: saveCurrent,
onDiscard: onCancel
});
function handleAllDayChange(next: boolean) {
setAllDay(next);
if (next) {
setStartTime("00:00");
setEndTime("00:00");
} else if (startTime === "00:00" && endTime === "00:00") {
setStartTime("09:00");
setEndTime("10:00");
}
}
function handleStartDateChange(next: string) {
setStartDate(next);
if (endDate < next) setEndDate(next);
}
function handleStartTimeChange(next: string) {
setStartTime(next);
if (startDate === endDate && endTime <= next) setEndTime(addMinutesToTime(next, 60));
}
async function saveCurrent(): Promise<boolean> {
setFormError("");
const startAt = allDay ? localDateTime(startDate, "00:00") : localDateTime(startDate, startTime);
const parsedDurationSeconds = Math.max(0, Number(durationSeconds) || 0);
const usesDuration = !allDay && endMode === "duration";
const endAt = allDay ?
addDays(localDateTime(endDate, "00:00"), 1) :
usesDuration ?
new Date(startAt.getTime() + parsedDurationSeconds * 1000) :
localDateTime(endDate, endTime);
if (usesDuration && parsedDurationSeconds <= 0) {
setFormError("i18n:govoplan-calendar.duration_must_be_greater_than_zero.519292f7");
return false;
}
if (endAt <= startAt) {
setFormError(allDay ? "i18n:govoplan-calendar.end_date_must_be_on_or_after_start_date.a2518865" : "i18n:govoplan-calendar.end_date_and_time_must_be_after_start_date_and_t.daf31831");
return false;
}
let advanced: CalendarEventAdvancedPayload;
try {
advanced = {
organizer: parseJsonObjectOrNull(organizerJson, "i18n:govoplan-calendar.organizer.debd1720"),
attendees: parseJsonArray(attendeesJson, "i18n:govoplan-calendar.attendees.a45a0962"),
categories: commaSeparatedValues(categoriesText),
rrule: parseRRuleInput(rruleText),
rdate: parseJsonArray(rdateJson, "RDATE"),
exdate: parseJsonArray(exdateJson, "EXDATE"),
reminders: parseJsonArray(remindersJson, "i18n:govoplan-calendar.reminders.ae8c3939"),
attachments: parseJsonArray(attachmentsJson, "i18n:govoplan-calendar.attachments.6771ade6"),
related_to: parseJsonArray(relatedToJson, "i18n:govoplan-calendar.related_to.0e7989ff"),
icalendar: withDurationMode(parseJsonObject(icalendarJson, "i18n:govoplan-calendar.icalendar.f388476d"), usesDuration),
metadata: parseJsonObject(metadataJson, "i18n:govoplan-calendar.metadata.251edc0e")
};
} catch (err) {
setFormError(errorText(err));
return false;
}
const payload: CalendarEventCreatePayload = {
calendar_id: calendarId,
summary,
description: description || null,
location: location || null,
sequence: Math.max(0, Number(sequence) || 0),
status,
transparency,
classification,
start_at: startAt.toISOString(),
end_at: endAt.toISOString(),
duration_seconds: usesDuration ? parsedDurationSeconds : null,
all_day: allDay,
timezone: timezone.trim() || null,
source_kind: sourceKind.trim() || "local",
source_href: sourceHref.trim() || null,
etag: etag.trim() || null,
...advanced
};
if (!event) {
if (uid.trim()) payload.uid = uid.trim();
if (recurrenceId.trim()) payload.recurrence_id = recurrenceId.trim();
}
return onSave(payload, event, editScope);
}
function submit(formEvent: FormEvent<HTMLFormElement>) {
formEvent.preventDefault();
void saveCurrent();
}
function requestDelete() {
if (!event) return;
if (!confirmingDelete) {
setConfirmingDelete(true);
return;
}
void onDelete(event, editScope);
}
return (
<Dialog
open
title={event ? "i18n:govoplan-calendar.edit_event.a7028454" : "i18n:govoplan-calendar.new_event.2ef3795c"}
className="calendar-event-dialog calendar-vevent-dialog"
footerClassName="calendar-event-dialog-footer"
closeDisabled={saving}
onClose={onCancel}
footer={
<>
<div>
{event && canDelete &&
<Button type="button" variant="danger" onClick={requestDelete} disabled={saving}>
<Trash2 size={16} /> {confirmingDelete ? "i18n:govoplan-calendar.confirm_delete.c9f2829e" : "i18n:govoplan-calendar.delete.f6fdbe48"}
</Button>
}
</div>
<div className="calendar-dialog-actions">
<Button type="button" onClick={onCancel} disabled={saving}>i18n:govoplan-calendar.cancel.77dfd213</Button>
<Button type="submit" form={formId} variant="primary" disabled={saving || !canWrite}>{saving ? "i18n:govoplan-calendar.saving.ae7e8875" : "i18n:govoplan-calendar.save.efc007a3"}</Button>
</div>
</>
}>
<form id={formId} className="calendar-dialog-form" onSubmit={submit}>
{canChooseSeries && (
<SegmentedControl
ariaLabel="Recurring event edit scope"
value={editScope}
onChange={onEditScopeChange}
width="fill"
size="equal"
disabled={saving}
options={[
{ id: "occurrence", label: "This occurrence" },
{
id: "series",
label: seriesLoaded ? "Whole series" : "Loading series",
disabled: !seriesLoaded
}
]}
/>
)}
{formError && <p className="calendar-form-error">{formError}</p>}
<label>
<span>i18n:govoplan-calendar.title.768e0c1c</span>
<input value={summary} onChange={(item) => setSummary(item.target.value)} required maxLength={500} autoFocus disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.calendar.adab5090</span>
<select value={calendarId} onChange={(item) => setCalendarId(item.target.value)} required disabled={saving || !canWrite}>
{calendars.map((calendar) =>
<option key={calendar.id} value={calendar.id}>{calendar.name}</option>
)}
</select>
</label>
<label>
<span>i18n:govoplan-calendar.description.55f8ebc8</span>
<textarea value={description} onChange={(item) => setDescription(item.target.value)} rows={4} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.location.d219c681</span>
<input value={location} onChange={(item) => setLocation(item.target.value)} maxLength={500} disabled={saving || !canWrite} />
</label>
<ToggleSwitch label="i18n:govoplan-calendar.whole_day.951c82d1" checked={allDay} disabled={saving || !canWrite} onChange={handleAllDayChange} />
<div className="calendar-dialog-date-row">
<label>
<span>i18n:govoplan-calendar.start_date.ff99f5b5</span>
<DateField value={startDate} onChange={handleStartDateChange} required disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.start_time.88d8206d</span>
<TimeField value={startTime} onChange={handleStartTimeChange} disabled={saving || !canWrite || allDay} />
</label>
</div>
{!allDay &&
<div className="calendar-dialog-date-row">
<label>
<span>i18n:govoplan-calendar.end_mode.5a06de37</span>
<select value={endMode} onChange={(item) => setEndMode(item.target.value as CalendarEventEndMode)} disabled={saving || !canWrite}>
<option value="end">i18n:govoplan-calendar.end_time.cd7800da</option>
<option value="duration">i18n:govoplan-calendar.duration.1370004d</option>
</select>
</label>
{endMode === "duration" &&
<label>
<span>i18n:govoplan-calendar.duration_seconds.19d42eeb</span>
<input type="number" min={1} step={60} value={durationSeconds} onChange={(item) => setDurationSeconds(item.target.value)} required disabled={saving || !canWrite} />
</label>
}
</div>
}
{(allDay || endMode === "end") &&
<div className="calendar-dialog-date-row">
<label>
<span>i18n:govoplan-calendar.end_date.89d10cd6</span>
<DateField value={endDate} min={startDate} onChange={setEndDate} required disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.end_time.cd7800da</span>
<TimeField value={endTime} min={!allDay && startDate === endDate ? startTime : undefined} onChange={setEndTime} disabled={saving || !canWrite || allDay} />
</label>
</div>
}
<details className="calendar-advanced-settings calendar-vevent-details">
<summary>i18n:govoplan-calendar.vevent.9cf5be75</summary>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.identity.7e5a975b</h3>
<div className="calendar-dialog-grid-three">
<label>
<span>i18n:govoplan-calendar.uid.d946adf5</span>
<input value={uid} onChange={(item) => setUid(item.target.value)} maxLength={255} disabled={saving || !canWrite || Boolean(event)} />
</label>
<label>
<span>i18n:govoplan-calendar.recurrence_id.e0b780ba</span>
<input value={recurrenceId} onChange={(item) => setRecurrenceId(item.target.value)} maxLength={255} disabled={saving || !canWrite || Boolean(event)} />
</label>
<label>
<span>i18n:govoplan-calendar.sequence.5c8f4e0e</span>
<input type="number" min={0} step={1} value={sequence} onChange={(item) => setSequence(item.target.value)} disabled={saving || !canWrite} />
</label>
</div>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.state.a7250206</h3>
<div className="calendar-dialog-grid-three">
<label>
<span>i18n:govoplan-calendar.status.bae7d5be</span>
<select value={status} onChange={(item) => setStatus(item.target.value)} disabled={saving || !canWrite}>
<option value="CONFIRMED">i18n:govoplan-calendar.confirmed.0542404a</option>
<option value="TENTATIVE">i18n:govoplan-calendar.tentative.d19f9022</option>
<option value="CANCELLED">i18n:govoplan-calendar.cancelled.5587b0af</option>
</select>
</label>
<label>
<span>i18n:govoplan-calendar.transparency.7cb338a9</span>
<select value={transparency} onChange={(item) => setTransparency(item.target.value)} disabled={saving || !canWrite}>
<option value="OPAQUE">i18n:govoplan-calendar.opaque.3e1d0194</option>
<option value="TRANSPARENT">i18n:govoplan-calendar.transparent.ea4efcae</option>
</select>
</label>
<label>
<span>i18n:govoplan-calendar.class.41ff354b</span>
<select value={classification} onChange={(item) => setClassification(item.target.value)} disabled={saving || !canWrite}>
<option value="PUBLIC">i18n:govoplan-calendar.public.d1785ca2</option>
<option value="PRIVATE">i18n:govoplan-calendar.private.b0b7ba46</option>
<option value="CONFIDENTIAL">i18n:govoplan-calendar.confidential.84c9cc88</option>
</select>
</label>
<label>
<span>i18n:govoplan-calendar.timezone.d1f7dc89</span>
<input value={timezone} onChange={(item) => setTimezone(item.target.value)} maxLength={100} disabled={saving || !canWrite} />
</label>
<label className="calendar-dialog-wide">
<span>i18n:govoplan-calendar.categories.6ccb6007</span>
<input value={categoriesText} onChange={(item) => setCategoriesText(item.target.value)} disabled={saving || !canWrite} />
</label>
</div>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.participants.cd56e083</h3>
<div className="calendar-dialog-grid-two">
<label>
<span>i18n:govoplan-calendar.organizer_json.3add6f9f</span>
<textarea value={organizerJson} onChange={(item) => setOrganizerJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.attendees_json.aeb487bb</span>
<textarea value={attendeesJson} onChange={(item) => setAttendeesJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
</div>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.recurrence.f7ad40f5</h3>
<label>
<span>i18n:govoplan-calendar.rrule.c7b2f8a3</span>
<input value={rruleText} onChange={(item) => setRruleText(item.target.value)} disabled={saving || !canWrite} />
</label>
<div className="calendar-dialog-grid-two">
<label>
<span>i18n:govoplan-calendar.rdate_json.5b51fca4</span>
<textarea value={rdateJson} onChange={(item) => setRdateJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.exdate_json.7d0c538d</span>
<textarea value={exdateJson} onChange={(item) => setExdateJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
</div>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.related.917df91e</h3>
<div className="calendar-dialog-grid-three">
<label>
<span>i18n:govoplan-calendar.reminders_json.ca25e08f</span>
<textarea value={remindersJson} onChange={(item) => setRemindersJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.attachments_json.d91abf3c</span>
<textarea value={attachmentsJson} onChange={(item) => setAttachmentsJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.related_to_json.2d4e8f59</span>
<textarea value={relatedToJson} onChange={(item) => setRelatedToJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
</div>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.source.6da13add</h3>
<div className="calendar-dialog-grid-three">
<label>
<span>i18n:govoplan-calendar.source_kind.7eda9bc4</span>
<input value={sourceKind} onChange={(item) => setSourceKind(item.target.value)} maxLength={30} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.source_href.819fa147</span>
<input value={sourceHref} onChange={(item) => setSourceHref(item.target.value)} maxLength={1000} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.etag.11d00f6e</span>
<input value={etag} onChange={(item) => setEtag(item.target.value)} maxLength={255} disabled={saving || !canWrite} />
</label>
</div>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.raw.da433cd4</h3>
<div className="calendar-dialog-grid-two">
<label>
<span>i18n:govoplan-calendar.icalendar_json.fb6cc33e</span>
<textarea value={icalendarJson} onChange={(item) => setICalendarJson(item.target.value)} rows={8} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.metadata_json.b0e4c283</span>
<textarea value={metadataJson} onChange={(item) => setMetadataJson(item.target.value)} rows={8} disabled={saving || !canWrite} />
</label>
</div>
</section>
</details>
</form>
</Dialog>);
}
function jsonTextareaValue(value: unknown): string {
return JSON.stringify(value, null, 2);
}
function parseJsonObject(text: string, label: string): Record<string, unknown> {
const value = parseJsonText(text, label, {});
if (!isPlainObject(value)) throw new Error(`${label} must be a JSON object.`);
return value;
}
function parseJsonObjectOrNull(text: string, label: string): Record<string, unknown> | null {
if (!text.trim()) return null;
const value = parseJsonText(text, label, null);
if (value === null) return null;
if (!isPlainObject(value)) throw new Error(`${label} must be a JSON object or null.`);
return value;
}
function parseJsonArray(text: string, label: string): Record<string, unknown>[] {
const value = parseJsonText(text, label, []);
if (!Array.isArray(value)) throw new Error(`${label} must be a JSON array.`);
return value.map((item, index) => {
if (!isPlainObject(item)) throw new Error(`${label} item ${index + 1} must be a JSON object.`);
return item;
});
}
function parseJsonText(text: string, label: string, fallback: unknown): unknown {
const trimmed = text.trim();
if (!trimmed) return fallback;
try {
return JSON.parse(trimmed) as unknown;
} catch (err) {
throw new Error(`${label} contains invalid JSON: ${errorText(err)}`);
}
}
function parseRRuleInput(text: string): Record<string, unknown> | null {
const trimmed = text.trim();
if (!trimmed) return null;
if (trimmed.startsWith("{")) return parseJsonObject(trimmed, "RRULE");
const result: Record<string, unknown> = {};
for (const item of trimmed.split(";")) {
const [rawKey, ...rawValueParts] = item.split("=");
const key = rawKey.trim().toUpperCase();
const rawValue = rawValueParts.join("=").trim();
if (!key || !rawValue) continue;
const values = rawValue.split(",").map((value) => value.trim()).filter(Boolean);
result[key] = values.length > 1 ? values : values[0] ?? rawValue;
}
return Object.keys(result).length ? result : null;
}
function serializeRRuleText(value: Record<string, unknown>): string {
return Object.entries(value).
map(([key, item]) => `${key.toUpperCase()}=${Array.isArray(item) ? item.join(",") : String(item)}`).
join(";");
}
function commaSeparatedValues(text: string): string[] {
return text.split(",").map((item) => item.trim()).filter(Boolean);
}
function withDurationMode(value: Record<string, unknown>, usesDuration: boolean): Record<string, unknown> {
const next = { ...value };
const standard = isPlainObject(next.standard) ? { ...next.standard } : {};
if (usesDuration) {
standard.uses_duration = true;
} else {
delete standard.uses_duration;
}
if (Object.keys(standard).length > 0) {
next.standard = standard;
} else {
delete next.standard;
}
return next;
}
function eventUsesDuration(event: CalendarEvent): boolean {
const standard = isPlainObject(event.icalendar?.standard) ? event.icalendar.standard : null;
return standard?.uses_duration === true;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,241 @@
import { useEffect, useMemo, useState } from "react";
import { Save } from "lucide-react";
import {
Button,
Card,
DismissibleAlert,
FormField,
ToggleSwitch,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import {
getCalendarViewPreferences,
updateCalendarViewPreferences,
type CalendarViewPreferences
} from "../../api/calendar";
type CalendarPreferenceDraft = Pick<
CalendarViewPreferences,
| "dim_weekends"
| "dim_off_hours"
| "workday_start_hour"
| "workday_end_hour"
| "continuous_virtualization"
| "continuous_overscan_weeks"
| "alternate_continuous_months"
>;
const FALLBACK_DRAFT: CalendarPreferenceDraft = {
dim_weekends: true,
dim_off_hours: true,
workday_start_hour: 6,
workday_end_hour: 20,
continuous_virtualization: true,
continuous_overscan_weeks: 6,
alternate_continuous_months: true
};
export default function CalendarSettingsPanel({
settings,
auth
}: {
settings: ApiSettings;
auth: AuthInfo;
}) {
const [loaded, setLoaded] = useState<CalendarPreferenceDraft | null>(null);
const [draft, setDraft] = useState<CalendarPreferenceDraft>(FALLBACK_DRAFT);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState("");
const [tone, setTone] = useState<"success" | "warning">("success");
const dirty = useMemo(
() => loaded !== null && JSON.stringify(loaded) !== JSON.stringify(draft),
[draft, loaded]
);
useEffect(() => {
void loadPreferences();
}, [auth.user.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
async function loadPreferences() {
setLoading(true);
setMessage("");
try {
const response = await getCalendarViewPreferences(settings);
const next = preferenceDraft(response);
setLoaded(next);
setDraft(next);
} catch (error) {
setTone("warning");
setMessage(errorText(error));
} finally {
setLoading(false);
}
}
async function savePreferences() {
if (draft.workday_end_hour <= draft.workday_start_hour) {
setTone("warning");
setMessage("Workday end must be after workday start.");
return;
}
setSaving(true);
setMessage("");
try {
const response = await updateCalendarViewPreferences(settings, draft);
const next = preferenceDraft(response);
setLoaded(next);
setDraft(next);
setTone("success");
setMessage("Calendar preferences saved.");
window.dispatchEvent(
new CustomEvent("govoplan:calendar-preferences-changed")
);
} catch (error) {
setTone("warning");
setMessage(errorText(error));
} finally {
setSaving(false);
}
}
const disabled = loading || saving;
return (
<div className="dashboard-grid settings-dashboard-grid calendar-settings-panel">
<Card title="Calendar display">
<div className="form-grid">
<ToggleSwitch
label="Dim weekends"
help="Use a quieter background for Saturday and Sunday in week views."
checked={draft.dim_weekends}
disabled={disabled}
onChange={(dim_weekends) =>
setDraft((current) => ({ ...current, dim_weekends }))
}
/>
<ToggleSwitch
label="Dim hours outside the workday"
checked={draft.dim_off_hours}
disabled={disabled}
onChange={(dim_off_hours) =>
setDraft((current) => ({ ...current, dim_off_hours }))
}
/>
<div className="calendar-dialog-grid-two">
<FormField label="Workday starts">
<input
type="number"
min={0}
max={23}
value={draft.workday_start_hour}
disabled={disabled}
onChange={(event) =>
setDraft((current) => ({
...current,
workday_start_hour: Number(event.target.value)
}))
}
/>
</FormField>
<FormField label="Workday ends">
<input
type="number"
min={1}
max={24}
value={draft.workday_end_hour}
disabled={disabled}
onChange={(event) =>
setDraft((current) => ({
...current,
workday_end_hour: Number(event.target.value)
}))
}
/>
</FormField>
</div>
</div>
</Card>
<Card title="Continuous view">
<div className="form-grid">
<ToggleSwitch
label="Virtualize distant weeks"
help="Keep the continuous calendar responsive by rendering only nearby weeks."
checked={draft.continuous_virtualization}
disabled={disabled}
onChange={(continuous_virtualization) =>
setDraft((current) => ({
...current,
continuous_virtualization
}))
}
/>
<FormField
label="Overscan weeks"
help="Additional weeks rendered above and below the viewport."
>
<input
type="number"
min={2}
max={20}
value={draft.continuous_overscan_weeks}
disabled={disabled || !draft.continuous_virtualization}
onChange={(event) =>
setDraft((current) => ({
...current,
continuous_overscan_weeks: Number(event.target.value)
}))
}
/>
</FormField>
<ToggleSwitch
label="Alternate month backgrounds"
checked={draft.alternate_continuous_months}
disabled={disabled}
onChange={(alternate_continuous_months) =>
setDraft((current) => ({
...current,
alternate_continuous_months
}))
}
/>
<div className="button-row compact-actions">
<Button
type="button"
variant="primary"
disabled={disabled || !dirty}
onClick={() => void savePreferences()}
>
<Save size={16} /> {saving ? "Saving" : "Save preferences"}
</Button>
</div>
{message && (
<DismissibleAlert tone={tone} resetKey={message} floating>
{message}
</DismissibleAlert>
)}
</div>
</Card>
</div>
);
}
function preferenceDraft(
preferences: CalendarViewPreferences
): CalendarPreferenceDraft {
return {
dim_weekends: preferences.dim_weekends,
dim_off_hours: preferences.dim_off_hours,
workday_start_hour: preferences.workday_start_hour,
workday_end_hour: preferences.workday_end_hour,
continuous_virtualization: preferences.continuous_virtualization,
continuous_overscan_weeks: preferences.continuous_overscan_weeks,
alternate_continuous_months: preferences.alternate_continuous_months
};
}
function errorText(error: unknown): string {
return error instanceof Error
? error.message
: "Calendar preferences could not be loaded.";
}
@@ -0,0 +1,667 @@
import {
useEffect,
useMemo,
useRef,
type DragEvent as ReactDragEvent,
} from "react";
import { i18nMessage } from "@govoplan/core-webui";
import type { CalendarEvent } from "../../api/calendar";
import {
HOUR_ROW_HEIGHT,
INITIAL_SCROLL_HOUR,
calendarEventColorStyle,
calendarEventInstanceId,
chunk,
continuousVirtualWindow,
dayKey,
dayMonthLabel,
dropSlotMinuteOfDay,
eventTimeLabel,
isWeekend,
layoutTimedEventsForDay,
minuteOfDayLabel,
monthYearLabel,
sameDay,
sameMonth,
timedEventStyle,
timedOverflowStyle,
timeGridStyle,
weekdayLong,
type CalendarDropTarget,
type CalendarMode,
type CalendarResizeEdge,
type CalendarTimeDropTarget,
type CalendarViewPreferences,
type ContinuousViewport,
} from "./calendarViewModel";
type CalendarViewCallbacks = {
onEventSelect: (event: CalendarEvent) => void;
onEventHover: (eventId: string) => void;
onEventDragStart: (
dragEvent: ReactDragEvent<HTMLElement>,
event: CalendarEvent,
) => void;
onEventDragEnd: () => void;
onEventDragOverDay: (
dragEvent: ReactDragEvent<HTMLElement>,
day: Date,
) => void;
onDropTargetLeave: (
dragEvent: ReactDragEvent<HTMLElement>,
) => void;
onEventDropOnDay: (
dropEvent: ReactDragEvent<HTMLElement>,
day: Date,
) => void;
};
type CalendarWeekRowsProps = CalendarViewCallbacks & {
days: Date[];
eventsByDay: Map<string, CalendarEvent[]>;
calendarColorById: Map<string, string>;
focusDate: Date;
variant: "month" | "continuous";
preferences: CalendarViewPreferences;
canWrite: boolean;
draggingEventId: string;
hoveredEventId: string;
dropTarget: CalendarDropTarget;
viewport?: ContinuousViewport;
};
export function CalendarWeekRows({
days,
eventsByDay,
calendarColorById,
focusDate,
variant,
preferences,
canWrite,
draggingEventId,
hoveredEventId,
dropTarget,
viewport,
onEventSelect,
onEventHover,
onEventDragStart,
onEventDragEnd,
onEventDragOverDay,
onDropTargetLeave,
onEventDropOnDay,
}: CalendarWeekRowsProps) {
const weeks = chunk(days, 7);
const virtualWindow =
variant === "continuous" && preferences.continuousVirtualization
? continuousVirtualWindow(
weeks.length,
viewport ?? { scrollTop: 0, height: 0 },
preferences.continuousOverscanWeeks,
)
: {
start: 0,
end: weeks.length,
topSpacerHeight: 0,
bottomSpacerHeight: 0,
};
const visibleWeeks = weeks.slice(
virtualWindow.start,
virtualWindow.end,
);
const eventLimit = variant === "month" ? 5 : 3;
return (
<div className={`calendar-week-rows is-${variant}`}>
<div className="calendar-weekday-header">
{[
"i18n:govoplan-calendar.mon.24b2a099",
"i18n:govoplan-calendar.tue.529541bb",
"i18n:govoplan-calendar.wed.23408b19",
"i18n:govoplan-calendar.thu.3593ccd9",
"i18n:govoplan-calendar.fri.bbd6e32e",
"i18n:govoplan-calendar.sat.6b782d41",
"i18n:govoplan-calendar.sun.48c98cab",
].map((label) => (
<span key={label}>{label}</span>
))}
</div>
{virtualWindow.topSpacerHeight > 0 && (
<div
className="calendar-week-spacer"
style={{ height: virtualWindow.topSpacerHeight }}
aria-hidden="true"
/>
)}
{visibleWeeks.map((week) => (
<div className="calendar-week-row" key={dayKey(week[0])}>
{week.map((day) => {
const key = dayKey(day);
const dayEvents = eventsByDay.get(key) ?? [];
const outsideFocusMonth =
variant === "month" && !sameMonth(day, focusDate);
return (
<section
key={key}
className={[
"calendar-day-cell",
outsideFocusMonth ? "is-muted" : "",
sameDay(day, new Date()) ? "is-today" : "",
dropTarget?.kind === "day" &&
dropTarget.key === key
? "is-drop-target"
: "",
variant === "continuous" &&
preferences.alternateContinuousMonths
? day.getMonth() % 2 === 0
? "is-even-month"
: "is-odd-month"
: "",
]
.filter(Boolean)
.join(" ")}
onDragOver={
canWrite
? (event) => onEventDragOverDay(event, day)
: undefined
}
onDragLeave={
canWrite ? onDropTargetLeave : undefined
}
onDrop={
canWrite
? (event) => onEventDropOnDay(event, day)
: undefined
}
>
<header>
<span className="calendar-day-number">
{day.getDate()}
</span>
{day.getDate() === 1 && (
<small>{monthYearLabel(day)}</small>
)}
</header>
<div className="calendar-event-stack">
{dayEvents.slice(0, eventLimit).map((event) => (
<CalendarEventChip
key={calendarEventInstanceId(event)}
event={event}
color={calendarColorById.get(event.calendar_id)}
canDrag={canWrite}
dragging={draggingEventId === calendarEventInstanceId(event)}
linkedHover={hoveredEventId === calendarEventInstanceId(event)}
onSelect={onEventSelect}
onHover={onEventHover}
onDragStart={onEventDragStart}
onDragEnd={onEventDragEnd}
/>
))}
{dayEvents.length > eventLimit && (
<span className="calendar-more-events">
+{dayEvents.length - eventLimit}
</span>
)}
</div>
</section>
);
})}
</div>
))}
{virtualWindow.bottomSpacerHeight > 0 && (
<div
className="calendar-week-spacer"
style={{ height: virtualWindow.bottomSpacerHeight }}
aria-hidden="true"
/>
)}
</div>
);
}
type CalendarTimeGridProps = CalendarViewCallbacks & {
days: Date[];
eventsByDay: Map<string, CalendarEvent[]>;
calendarColorById: Map<string, string>;
mode: CalendarMode;
preferences: CalendarViewPreferences;
canWrite: boolean;
draggingEventId: string;
hoveredEventId: string;
dropTarget: CalendarDropTarget;
onEventResizeDragStart: (
dragEvent: ReactDragEvent<HTMLElement>,
event: CalendarEvent,
edge: CalendarResizeEdge,
) => void;
onEventDragOverTime: (
dragEvent: ReactDragEvent<HTMLElement>,
day: Date,
) => void;
onEventDropOnTime: (
dropEvent: ReactDragEvent<HTMLElement>,
day: Date,
minuteOfDay: number,
) => void;
};
export function CalendarTimeGrid({
days,
eventsByDay,
calendarColorById,
mode,
preferences,
canWrite,
draggingEventId,
hoveredEventId,
dropTarget,
onEventSelect,
onEventHover,
onEventDragStart,
onEventResizeDragStart,
onEventDragEnd,
onEventDragOverDay,
onEventDragOverTime,
onDropTargetLeave,
onEventDropOnDay,
onEventDropOnTime,
}: CalendarTimeGridProps) {
const scrollRef = useRef<HTMLDivElement | null>(null);
const hours = Array.from({ length: 24 }, (_item, index) => index);
const columns = `72px repeat(${days.length}, minmax(132px, 1fr))`;
const dimWeekends = preferences.dimWeekends && mode === "week";
const gridStyle = timeGridStyle(columns, preferences);
const allDayEventsByDay = days.map((day) => ({
key: dayKey(day),
date: day,
events: (eventsByDay.get(dayKey(day)) ?? []).filter(
(event) => event.all_day,
),
}));
const hasAllDayEvents = allDayEventsByDay.some(
(day) => day.events.length > 0,
);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop =
INITIAL_SCROLL_HOUR * HOUR_ROW_HEIGHT;
}
}, [days.length, days[0]?.getTime()]);
return (
<div className="calendar-time-view">
<div
className="calendar-time-header"
style={{ gridTemplateColumns: columns }}
>
<div className="calendar-time-corner" />
{days.map((day) => (
<header
key={dayKey(day)}
className={[
sameDay(day, new Date()) ? "is-today" : "",
dimWeekends && isWeekend(day) ? "is-weekend" : "",
]
.filter(Boolean)
.join(" ")}
>
<strong>{weekdayLong(day)}</strong>
<span>{dayMonthLabel(day)}</span>
</header>
))}
</div>
{hasAllDayEvents && (
<div
className="calendar-all-day-strip"
style={{ gridTemplateColumns: columns }}
>
<div className="calendar-all-day-label">
i18n:govoplan-calendar.all_day.11457433
</div>
{allDayEventsByDay.map((day) => (
<div
key={day.key}
className={[
"calendar-all-day-cell",
dimWeekends && isWeekend(day.date)
? "is-weekend"
: "",
dropTarget?.kind === "day" &&
dropTarget.key === day.key
? "is-drop-target"
: "",
]
.filter(Boolean)
.join(" ")}
onDragOver={
canWrite
? (event) => onEventDragOverDay(event, day.date)
: undefined
}
onDragLeave={
canWrite ? onDropTargetLeave : undefined
}
onDrop={
canWrite
? (event) => onEventDropOnDay(event, day.date)
: undefined
}
>
{day.events.map((event) => (
<CalendarEventChip
key={calendarEventInstanceId(event)}
event={event}
color={calendarColorById.get(event.calendar_id)}
compact
canDrag={canWrite}
dragging={draggingEventId === calendarEventInstanceId(event)}
linkedHover={hoveredEventId === calendarEventInstanceId(event)}
onSelect={onEventSelect}
onHover={onEventHover}
onDragStart={onEventDragStart}
onDragEnd={onEventDragEnd}
/>
))}
</div>
))}
</div>
)}
<div className="calendar-time-scroll" ref={scrollRef}>
<div className="calendar-time-grid" style={gridStyle}>
<div className="calendar-hour-label-column">
{hours.map((hour) => (
<div className="calendar-hour-label" key={hour}>
{String(hour).padStart(2, "0")}:00
</div>
))}
</div>
{days.map((day) => (
<TimedDayColumn
key={dayKey(day)}
day={day}
events={eventsByDay.get(dayKey(day)) ?? []}
calendarColorById={calendarColorById}
dimWeekend={dimWeekends && isWeekend(day)}
canWrite={canWrite}
draggingEventId={draggingEventId}
hoveredEventId={hoveredEventId}
dropTarget={
dropTarget?.kind === "time" &&
dropTarget.key === dayKey(day)
? dropTarget
: null
}
onEventSelect={onEventSelect}
onEventHover={onEventHover}
onEventDragStart={onEventDragStart}
onEventResizeDragStart={onEventResizeDragStart}
onEventDragEnd={onEventDragEnd}
onEventDragOverTime={onEventDragOverTime}
onDropTargetLeave={onDropTargetLeave}
onEventDropOnTime={onEventDropOnTime}
/>
))}
</div>
</div>
</div>
);
}
type TimedDayColumnProps = {
day: Date;
events: CalendarEvent[];
calendarColorById: Map<string, string>;
dimWeekend: boolean;
canWrite: boolean;
draggingEventId: string;
hoveredEventId: string;
dropTarget: CalendarTimeDropTarget | null;
onEventSelect: (event: CalendarEvent) => void;
onEventHover: (eventId: string) => void;
onEventDragStart: (
dragEvent: ReactDragEvent<HTMLElement>,
event: CalendarEvent,
) => void;
onEventResizeDragStart: (
dragEvent: ReactDragEvent<HTMLElement>,
event: CalendarEvent,
edge: CalendarResizeEdge,
) => void;
onEventDragEnd: () => void;
onEventDragOverTime: (
dragEvent: ReactDragEvent<HTMLElement>,
day: Date,
) => void;
onDropTargetLeave: (
dragEvent: ReactDragEvent<HTMLElement>,
) => void;
onEventDropOnTime: (
dropEvent: ReactDragEvent<HTMLElement>,
day: Date,
minuteOfDay: number,
) => void;
};
function TimedDayColumn({
day,
events,
calendarColorById,
dimWeekend,
canWrite,
draggingEventId,
hoveredEventId,
dropTarget,
onEventSelect,
onEventHover,
onEventDragStart,
onEventResizeDragStart,
onEventDragEnd,
onEventDragOverTime,
onDropTargetLeave,
onEventDropOnTime,
}: TimedDayColumnProps) {
const layout = useMemo(
() => layoutTimedEventsForDay(events, day),
[day, events],
);
return (
<div
className={[
"calendar-day-time-column",
dimWeekend ? "is-weekend" : "",
dropTarget ? "is-drop-target" : "",
]
.filter(Boolean)
.join(" ")}
onDragOver={
canWrite
? (event) => onEventDragOverTime(event, day)
: undefined
}
onDragLeave={canWrite ? onDropTargetLeave : undefined}
onDrop={
canWrite
? (event) =>
onEventDropOnTime(event, day, dropSlotMinuteOfDay(event))
: undefined
}
>
{dropTarget && (
<div
className="calendar-time-drop-marker"
style={{
top: `${
(dropTarget.minuteOfDay / 60) * HOUR_ROW_HEIGHT
}px`,
}}
aria-hidden="true"
>
<span>{minuteOfDayLabel(dropTarget.minuteOfDay)}</span>
</div>
)}
{layout.items.map((item) => (
<div
key={calendarEventInstanceId(item.event)}
className={[
"calendar-timed-event",
draggingEventId === calendarEventInstanceId(item.event) ? "is-dragging" : "",
hoveredEventId === calendarEventInstanceId(item.event)
? "is-linked-hover"
: "",
]
.filter(Boolean)
.join(" ")}
style={timedEventStyle(
item,
calendarColorById.get(item.event.calendar_id),
)}
draggable={canWrite}
onMouseEnter={() => onEventHover(calendarEventInstanceId(item.event))}
onMouseLeave={() => onEventHover("")}
onDragStart={
canWrite
? (event) => onEventDragStart(event, item.event)
: undefined
}
onDragEnd={canWrite ? onEventDragEnd : undefined}
title={i18nMessage(
"i18n:govoplan-calendar.edit_value.fad75899",
{ value0: item.event.summary },
)}
>
{canWrite && (
<span
className="calendar-event-resize-handle is-start"
draggable
title="i18n:govoplan-calendar.change_start_time.06eea3d3"
onDragStart={(event) =>
onEventResizeDragStart(event, item.event, "start")
}
onDragEnd={onEventDragEnd}
/>
)}
<button
type="button"
className="calendar-timed-event-content"
onClick={() => onEventSelect(item.event)}
onFocus={() => onEventHover(calendarEventInstanceId(item.event))}
onBlur={() => onEventHover("")}
title={i18nMessage(
"i18n:govoplan-calendar.edit_value.fad75899",
{ value0: item.event.summary },
)}
>
<EventInlineLabel event={item.event} />
</button>
{canWrite && (
<span
className="calendar-event-resize-handle is-end"
draggable
title="i18n:govoplan-calendar.change_end_time.db66ef4b"
onDragStart={(event) =>
onEventResizeDragStart(event, item.event, "end")
}
onDragEnd={onEventDragEnd}
/>
)}
</div>
))}
{layout.overflows.map((overflow) => (
<button
key={overflow.key}
type="button"
className="calendar-timed-overflow"
style={timedOverflowStyle(overflow)}
title={overflow.events
.map((event) => event.summary)
.join(", ")}
onClick={() => onEventSelect(overflow.events[0])}
>
+{overflow.events.length}
</button>
))}
</div>
);
}
type CalendarEventChipProps = {
event: CalendarEvent;
color?: string | null;
compact?: boolean;
canDrag?: boolean;
dragging?: boolean;
linkedHover?: boolean;
onSelect: (event: CalendarEvent) => void;
onHover?: (eventId: string) => void;
onDragStart?: (
dragEvent: ReactDragEvent<HTMLElement>,
event: CalendarEvent,
) => void;
onDragEnd?: () => void;
};
function CalendarEventChip({
event,
color,
compact = false,
canDrag = false,
dragging = false,
linkedHover = false,
onSelect,
onHover,
onDragStart,
onDragEnd,
}: CalendarEventChipProps) {
return (
<button
type="button"
className={[
compact
? "calendar-event-chip is-compact"
: "calendar-event-chip",
dragging ? "is-dragging" : "",
linkedHover ? "is-linked-hover" : "",
]
.filter(Boolean)
.join(" ")}
style={calendarEventColorStyle(color)}
draggable={canDrag}
onClick={() => onSelect(event)}
onMouseEnter={onHover ? () => onHover(calendarEventInstanceId(event)) : undefined}
onMouseLeave={onHover ? () => onHover("") : undefined}
onFocus={onHover ? () => onHover(calendarEventInstanceId(event)) : undefined}
onBlur={onHover ? () => onHover("") : undefined}
onDragStart={
canDrag && onDragStart
? (dragEvent) => onDragStart(dragEvent, event)
: undefined
}
onDragEnd={canDrag ? onDragEnd : undefined}
title={i18nMessage(
"i18n:govoplan-calendar.edit_value.fad75899",
{ value0: event.summary },
)}
>
<EventInlineLabel event={event} />
</button>
);
}
export function EventInlineLabel({
event,
}: {
event: CalendarEvent;
}) {
return (
<span className="calendar-event-line">
<span className="calendar-event-time">
{eventTimeLabel(event)}
</span>
<span className="calendar-event-separator">-</span>
<strong>{event.summary}</strong>
</span>
);
}
@@ -0,0 +1,111 @@
import { useCallback } from "react";
import { CalendarDays, MapPin } from "lucide-react";
import { Link } from "react-router";
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(),
expand_recurring: true
});
return [...response.events]
.filter((event) => event.status.toUpperCase() !== "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.instance_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;
}
@@ -0,0 +1,932 @@
import type {
CSSProperties,
DragEvent as ReactDragEvent,
} from "react";
import type {
CalendarEvent,
CalendarEventDeltaResponse,
} from "../../api/calendar";
export type CalendarMode =
| "continuous"
| "month"
| "week"
| "workweek"
| "day";
export type Range = { start: Date; end: Date };
export type AgendaGroup = {
key: string;
date: Date;
events: CalendarEvent[];
};
export type ContinuousViewport = { scrollTop: number; height: number };
export type CalendarDayDropTarget = { kind: "day"; key: string };
export type CalendarTimeDropTarget = {
kind: "time";
key: string;
minuteOfDay: number;
};
export type CalendarDropTarget =
| CalendarDayDropTarget
| CalendarTimeDropTarget
| null;
export type CalendarDragAction =
| { kind: "move"; event: CalendarEvent }
| { kind: "resize-start"; event: CalendarEvent }
| { kind: "resize-end"; event: CalendarEvent };
export type CalendarResizeEdge = "start" | "end";
export type CalendarViewPreferences = {
dimWeekends: boolean;
dimOffHours: boolean;
workdayStartHour: number;
workdayEndHour: number;
continuousVirtualization: boolean;
continuousOverscanWeeks: number;
alternateContinuousMonths: boolean;
};
export type TimedEventLayoutItem = TimedEventSegment & {
column: number;
columns: number;
};
export type TimedOverflowLayoutItem = {
key: string;
top: number;
column: number;
columns: number;
events: CalendarEvent[];
};
type TimedEventSegment = {
event: CalendarEvent;
start: Date;
end: Date;
top: number;
height: number;
};
export const HOUR_ROW_HEIGHT = 64;
export const INITIAL_SCROLL_HOUR = 7;
export const CONTINUOUS_WEEK_ROW_HEIGHT = 92;
export const DEFAULT_CALENDAR_COLOR = "#5aa99b";
const MAX_TIMED_EVENT_COLUMNS = 3;
const CALENDAR_MODE_STORAGE_KEY = "govoplan.calendar.mode";
const CALENDAR_VIEW_PREFERENCES_STORAGE_KEY =
"govoplan.calendar.viewPreferences";
export const DEFAULT_CALENDAR_VIEW_PREFERENCES: CalendarViewPreferences = {
dimWeekends: true,
dimOffHours: true,
workdayStartHour: 6,
workdayEndHour: 20,
continuousVirtualization: true,
continuousOverscanWeeks: 6,
alternateContinuousMonths: true,
};
export function calendarEventInstanceId(event: CalendarEvent): string {
return event.instance_id || event.id;
}
export function rangeForMode(
mode: CalendarMode,
focusDate: Date,
continuousWeeks: { before: number; after: number },
): Range {
if (mode === "month") {
const start = startOfWeek(startOfMonth(focusDate));
return { start, end: addDays(start, 42) };
}
if (mode === "day") {
return {
start: startOfDay(focusDate),
end: addDays(startOfDay(focusDate), 1),
};
}
if (mode === "continuous") {
const start = addDays(
startOfWeek(focusDate),
-continuousWeeks.before * 7,
);
const end = addDays(
startOfWeek(focusDate),
continuousWeeks.after * 7,
);
return { start, end };
}
const start = startOfWeek(focusDate);
return { start, end: addDays(start, mode === "workweek" ? 5 : 7) };
}
export function daysForMode(
mode: CalendarMode,
focusDate: Date,
continuousWeeks: { before: number; after: number },
): Date[] {
const range = rangeForMode(mode, focusDate, continuousWeeks);
return daysBetween(range.start, range.end);
}
export function continuousEventWindow(
days: Date[],
viewport: ContinuousViewport,
options: {overscanWeeks?: number;minimumWeeks?: number;stepWeeks?: number;} = {},
): Range {
if (days.length === 0) {
const now = startOfDay(new Date());
return { start: now, end: addDays(now, 7) };
}
const totalWeeks = Math.max(1, Math.ceil(days.length / 7));
const overscanWeeks = Math.max(0, Math.floor(options.overscanWeeks ?? 2));
const minimumWeeks = Math.max(1, Math.floor(options.minimumWeeks ?? 10));
const stepWeeks = Math.max(1, Math.floor(options.stepWeeks ?? 4));
const firstVisibleWeek = Math.max(
0,
Math.min(totalWeeks - 1, Math.floor(viewport.scrollTop / CONTINUOUS_WEEK_ROW_HEIGHT)),
);
const visibleWeeks = Math.max(
1,
Math.ceil(Math.max(viewport.height, CONTINUOUS_WEEK_ROW_HEIGHT) / CONTINUOUS_WEEK_ROW_HEIGHT),
);
const desiredWeeks = Math.min(
totalWeeks,
Math.max(minimumWeeks, visibleWeeks + overscanWeeks * 2),
);
let startWeek = Math.max(
0,
Math.floor(Math.max(0, firstVisibleWeek - overscanWeeks) / stepWeeks) * stepWeeks,
);
if (startWeek + desiredWeeks > totalWeeks) {
startWeek = Math.max(0, totalWeeks - desiredWeeks);
}
const endWeek = Math.min(totalWeeks, startWeek + desiredWeeks);
return {
start: days[startWeek * 7],
end: addDays(days[Math.min(days.length - 1, endWeek * 7 - 1)], 1),
};
}
export function groupEventsByDay(
events: CalendarEvent[],
): Map<string, CalendarEvent[]> {
const grouped = new Map<string, CalendarEvent[]>();
for (const event of events) {
const start = new Date(event.start_at);
const end = event.end_at ? new Date(event.end_at) : start;
let cursor = startOfDay(start);
let last =
event.all_day && event.end_at
? addDays(startOfDay(end), -1)
: startOfDay(end);
if (last < cursor) last = cursor;
while (cursor <= last) {
const key = dayKey(cursor);
grouped.set(key, [...(grouped.get(key) ?? []), event]);
cursor = addDays(cursor, 1);
}
}
for (const [key, items] of grouped.entries()) {
grouped.set(
key,
items.sort(
(left, right) =>
left.start_at.localeCompare(right.start_at) ||
left.summary.localeCompare(right.summary),
),
);
}
return grouped;
}
export function agendaGroupsForDays(
days: Date[],
eventsByDay: Map<string, CalendarEvent[]>,
): AgendaGroup[] {
const groups: AgendaGroup[] = [];
for (const day of days) {
const key = dayKey(day);
const events = (eventsByDay.get(key) ?? [])
.slice()
.sort(compareAgendaEvents);
if (events.length > 0) groups.push({ key, date: day, events });
}
return groups;
}
export function eventTimeLabel(event: CalendarEvent): string {
if (event.all_day) return "i18n:govoplan-calendar.all_day.11457433";
const start = new Date(event.start_at);
const end = event.end_at ? new Date(event.end_at) : null;
return end ? `${timeLabel(start)}-${timeLabel(end)}` : timeLabel(start);
}
export function dateTimeLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(value);
}
export function headingForMode(
mode: CalendarMode,
focusDate: Date,
range: Range,
): string {
if (mode === "day") return dayMonthYearLabel(focusDate);
if (mode === "month") return monthYearLabel(focusDate);
return `${dayMonthYearLabel(range.start)} - ${dayMonthYearLabel(
addDays(range.end, -1),
)}`;
}
export function chunk<T>(items: T[], size: number): T[][] {
const rows: T[][] = [];
for (let index = 0; index < items.length; index += size) {
rows.push(items.slice(index, index + size));
}
return rows;
}
export function startOfDay(value: Date): Date {
return new Date(value.getFullYear(), value.getMonth(), value.getDate());
}
export function startOfWeek(value: Date): Date {
const day = value.getDay() || 7;
return addDays(startOfDay(value), 1 - day);
}
export function startOfMonth(value: Date): Date {
return new Date(value.getFullYear(), value.getMonth(), 1);
}
export function addDays(value: Date, days: number): Date {
const next = new Date(value);
next.setDate(next.getDate() + days);
return next;
}
export function addMonths(value: Date, months: number): Date {
const next = new Date(value);
next.setMonth(next.getMonth() + months);
return next;
}
export function addHours(value: Date, hours: number): Date {
const next = new Date(value);
next.setHours(next.getHours() + hours);
return next;
}
export function addMinutes(value: Date, minutes: number): Date {
const next = new Date(value);
next.setMinutes(next.getMinutes() + minutes);
return next;
}
export function daysBetween(start: Date, end: Date): Date[] {
const days: Date[] = [];
for (
let cursor = startOfDay(start);
cursor < end;
cursor = addDays(cursor, 1)
) {
days.push(cursor);
}
return days;
}
export function dayKey(value: Date): string {
return toInputDate(value);
}
export function sameDay(left: Date, right: Date): boolean {
return (
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate()
);
}
export function sameMonth(left: Date, right: Date): boolean {
return (
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth()
);
}
export function toInputDate(value: Date): string {
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(
2,
"0",
)}-${String(value.getDate()).padStart(2, "0")}`;
}
export function toInputTime(value: Date): string {
return `${String(value.getHours()).padStart(2, "0")}:${String(
value.getMinutes(),
).padStart(2, "0")}`;
}
export function localDateTime(date: string, time: string): Date {
return new Date(`${date}T${time || "00:00"}:00`);
}
export function loadCalendarViewPreferences(): CalendarViewPreferences {
if (typeof window === "undefined") return DEFAULT_CALENDAR_VIEW_PREFERENCES;
try {
const raw = window.localStorage.getItem(
CALENDAR_VIEW_PREFERENCES_STORAGE_KEY,
);
if (!raw) return DEFAULT_CALENDAR_VIEW_PREFERENCES;
const parsed = JSON.parse(raw) as Partial<CalendarViewPreferences>;
let workdayStartHour = clampNumber(
parsed.workdayStartHour,
0,
23,
DEFAULT_CALENDAR_VIEW_PREFERENCES.workdayStartHour,
);
let workdayEndHour = clampNumber(
parsed.workdayEndHour,
1,
24,
DEFAULT_CALENDAR_VIEW_PREFERENCES.workdayEndHour,
);
if (workdayEndHour <= workdayStartHour) {
workdayStartHour =
DEFAULT_CALENDAR_VIEW_PREFERENCES.workdayStartHour;
workdayEndHour = DEFAULT_CALENDAR_VIEW_PREFERENCES.workdayEndHour;
}
return {
dimWeekends:
typeof parsed.dimWeekends === "boolean"
? parsed.dimWeekends
: DEFAULT_CALENDAR_VIEW_PREFERENCES.dimWeekends,
dimOffHours:
typeof parsed.dimOffHours === "boolean"
? parsed.dimOffHours
: DEFAULT_CALENDAR_VIEW_PREFERENCES.dimOffHours,
workdayStartHour,
workdayEndHour,
continuousVirtualization:
typeof parsed.continuousVirtualization === "boolean"
? parsed.continuousVirtualization
: DEFAULT_CALENDAR_VIEW_PREFERENCES.continuousVirtualization,
continuousOverscanWeeks: clampNumber(
parsed.continuousOverscanWeeks,
2,
20,
DEFAULT_CALENDAR_VIEW_PREFERENCES.continuousOverscanWeeks,
),
alternateContinuousMonths:
typeof parsed.alternateContinuousMonths === "boolean"
? parsed.alternateContinuousMonths
: DEFAULT_CALENDAR_VIEW_PREFERENCES.alternateContinuousMonths,
};
} catch {
return DEFAULT_CALENDAR_VIEW_PREFERENCES;
}
}
export function loadCalendarMode(): CalendarMode {
if (typeof window === "undefined") return "continuous";
try {
const storedMode = window.localStorage.getItem(
CALENDAR_MODE_STORAGE_KEY,
);
return isCalendarMode(storedMode) ? storedMode : "continuous";
} catch {
return "continuous";
}
}
export function saveCalendarMode(mode: CalendarMode): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(CALENDAR_MODE_STORAGE_KEY, mode);
} catch {
// Storage can be unavailable in locked-down browsers.
}
}
export function normalizeHexColor(
value: string | null | undefined,
): string | null {
if (!value) return null;
const trimmed = value.trim();
if (/^#[0-9a-fA-F]{8}$/.test(trimmed)) {
return trimmed.slice(0, 7).toLowerCase();
}
return /^#[0-9a-fA-F]{6}$/.test(trimmed)
? trimmed.toLowerCase()
: null;
}
export function calendarEventColorStyle(
color: string | null | undefined,
): CSSProperties {
const normalizedColor =
normalizeHexColor(color) || DEFAULT_CALENDAR_COLOR;
return {
"--calendar-event-color": normalizedColor,
"--calendar-event-bg": mixHexWithWhite(normalizedColor, 0.88),
"--calendar-event-border": mixHexWithWhite(normalizedColor, 0.58),
} as CSSProperties;
}
export function continuousVirtualWindow(
weekCount: number,
viewport: ContinuousViewport,
overscanWeeks: number,
): {
start: number;
end: number;
topSpacerHeight: number;
bottomSpacerHeight: number;
} {
if (weekCount === 0) {
return {
start: 0,
end: 0,
topSpacerHeight: 0,
bottomSpacerHeight: 0,
};
}
const visibleStart = Math.floor(
viewport.scrollTop / CONTINUOUS_WEEK_ROW_HEIGHT,
);
const visibleCount = Math.max(
1,
Math.ceil(
(viewport.height || CONTINUOUS_WEEK_ROW_HEIGHT * 8) /
CONTINUOUS_WEEK_ROW_HEIGHT,
),
);
const start = Math.max(0, visibleStart - overscanWeeks);
const end = Math.min(
weekCount,
visibleStart + visibleCount + overscanWeeks,
);
return {
start,
end,
topSpacerHeight: start * CONTINUOUS_WEEK_ROW_HEIGHT,
bottomSpacerHeight: Math.max(
0,
(weekCount - end) * CONTINUOUS_WEEK_ROW_HEIGHT,
),
};
}
export function timeGridStyle(
columns: string,
preferences: CalendarViewPreferences,
): CSSProperties {
return {
gridTemplateColumns: columns,
"--calendar-work-start-y": `${
preferences.workdayStartHour * HOUR_ROW_HEIGHT
}px`,
"--calendar-work-end-y": `${
preferences.workdayEndHour * HOUR_ROW_HEIGHT
}px`,
"--calendar-off-hours-opacity": preferences.dimOffHours ? "1" : "0",
} as CSSProperties;
}
export function moveEventToDay(
event: CalendarEvent,
day: Date,
): { startAt: Date; endAt: Date | null; allDay: boolean } {
const start = new Date(event.start_at);
const end = event.end_at ? new Date(event.end_at) : null;
if (event.all_day) {
const durationDays = Math.max(
1,
Math.round(
daysBetweenCount(
startOfDay(start),
end ? startOfDay(end) : addDays(startOfDay(start), 1),
),
),
);
const startAt = startOfDay(day);
return {
startAt,
endAt: addDays(startAt, durationDays),
allDay: true,
};
}
const startAt = new Date(
day.getFullYear(),
day.getMonth(),
day.getDate(),
start.getHours(),
start.getMinutes(),
);
const durationMs = eventDurationMs(event, 60 * 60 * 1000);
return {
startAt,
endAt: new Date(startAt.getTime() + durationMs),
allDay: false,
};
}
export function moveEventToTime(
event: CalendarEvent,
day: Date,
minuteOfDay: number,
): { startAt: Date; endAt: Date | null; allDay: boolean } {
const snappedMinute = Math.min(
23 * 60 + 45,
Math.max(0, snapMinute(minuteOfDay)),
);
const startAt = dateAtMinuteOfDay(day, snappedMinute);
const durationMs = event.all_day
? 60 * 60 * 1000
: eventDurationMs(event, 60 * 60 * 1000);
return {
startAt,
endAt: new Date(startAt.getTime() + durationMs),
allDay: false,
};
}
export function resizeEventToTime(
event: CalendarEvent,
edge: CalendarResizeEdge,
day: Date,
minuteOfDay: number,
): { startAt: Date; endAt: Date | null; allDay: boolean } {
const target = dateAtMinuteOfDay(
day,
Math.min(23 * 60 + 45, Math.max(0, snapMinute(minuteOfDay))),
);
const currentStart = new Date(event.start_at);
const fallbackEnd = addMinutes(currentStart, 30);
const currentEnd =
event.end_at && new Date(event.end_at) > currentStart
? new Date(event.end_at)
: fallbackEnd;
const minimumDurationMs = 15 * 60_000;
if (edge === "start") {
const latestStart = new Date(
currentEnd.getTime() - minimumDurationMs,
);
return {
startAt: target < latestStart ? target : latestStart,
endAt: currentEnd,
allDay: false,
};
}
const earliestEnd = new Date(
currentStart.getTime() + minimumDurationMs,
);
return {
startAt: currentStart,
endAt: target > earliestEnd ? target : earliestEnd,
allDay: false,
};
}
export function dropMinuteOfDay(
event: ReactDragEvent<HTMLElement>,
): number {
const rect = event.currentTarget.getBoundingClientRect();
const offset = Math.min(
Math.max(event.clientY - rect.top, 0),
HOUR_ROW_HEIGHT * 24,
);
return Math.floor((offset / HOUR_ROW_HEIGHT) * 60);
}
export function dropSlotMinuteOfDay(
event: ReactDragEvent<HTMLElement>,
): number {
return Math.min(
23 * 60 + 45,
Math.max(0, snapMinute(dropMinuteOfDay(event))),
);
}
export function minuteOfDayLabel(value: number): string {
const minute = Math.min(23 * 60 + 45, Math.max(0, value));
return `${String(Math.floor(minute / 60)).padStart(2, "0")}:${String(
minute % 60,
).padStart(2, "0")}`;
}
export function isWeekend(value: Date): boolean {
const day = value.getDay();
return day === 0 || day === 6;
}
export function daysBetweenCount(start: Date, end: Date): number {
return Math.round(
(startOfDay(end).getTime() - startOfDay(start).getTime()) / 86_400_000,
);
}
export function addMinutesToTime(
time: string,
minutes: number,
): string {
const [hourPart, minutePart] = time.split(":");
const total = Math.min(
23 * 60 + 59,
Math.max(
0,
Number(hourPart || 0) * 60 + Number(minutePart || 0) + minutes,
),
);
return `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(
total % 60,
).padStart(2, "0")}`;
}
export function layoutTimedEventsForDay(
events: CalendarEvent[],
day: Date,
): {
items: TimedEventLayoutItem[];
overflows: TimedOverflowLayoutItem[];
} {
const segments = timedSegmentsForDay(events, day);
const clusters = clusterTimedSegments(segments);
const items: TimedEventLayoutItem[] = [];
const overflows: TimedOverflowLayoutItem[] = [];
for (const cluster of clusters) {
const columnEnds: Date[] = [];
const assigned = cluster.map((segment) => {
let column = columnEnds.findIndex((end) => end <= segment.start);
if (column === -1) column = columnEnds.length;
columnEnds[column] = segment.end;
return { ...segment, column };
});
const totalColumns = Math.max(1, columnEnds.length);
const visibleColumns =
totalColumns > MAX_TIMED_EVENT_COLUMNS
? MAX_TIMED_EVENT_COLUMNS
: totalColumns;
const overflowColumn = MAX_TIMED_EVENT_COLUMNS - 1;
const hidden =
totalColumns > MAX_TIMED_EVENT_COLUMNS
? assigned.filter((item) => item.column >= overflowColumn)
: [];
for (const item of assigned) {
if (hidden.includes(item)) continue;
items.push({ ...item, columns: visibleColumns });
}
if (hidden.length > 0) {
overflows.push({
key: `${dayKey(day)}-${calendarEventInstanceId(hidden[0].event)}-overflow`,
top: Math.min(...hidden.map((item) => item.top)),
column: overflowColumn,
columns: MAX_TIMED_EVENT_COLUMNS,
events: hidden.map((item) => item.event),
});
}
}
return { items, overflows };
}
export function timedEventStyle(
item: TimedEventLayoutItem,
color?: string | null,
): CSSProperties {
return {
top: `${item.top}px`,
height: `${item.height}px`,
left: `calc(${(item.column * 100) / item.columns}% + 4px)`,
width: `calc(${100 / item.columns}% - 8px)`,
...calendarEventColorStyle(color),
};
}
export function timedOverflowStyle(
item: TimedOverflowLayoutItem,
): CSSProperties {
return {
top: `${item.top}px`,
left: `calc(${(item.column * 100) / item.columns}% + 4px)`,
width: `calc(${100 / item.columns}% - 8px)`,
};
}
export function monthYearLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, {
month: "long",
year: "numeric",
}).format(value);
}
export function dayMonthLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, {
day: "2-digit",
month: "short",
}).format(value);
}
export function dayMonthYearLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, {
day: "2-digit",
month: "short",
year: "numeric",
}).format(value);
}
export function agendaDateLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, {
weekday: "short",
day: "2-digit",
month: "short",
year: "numeric",
}).format(value);
}
export function weekdayLong(value: Date): string {
return new Intl.DateTimeFormat(undefined, {
weekday: "short",
}).format(value);
}
export function calendarDraftKey(value: unknown): string {
return JSON.stringify(value);
}
export function mergeCalendarEventDelta(
current: CalendarEvent[],
response: CalendarEventDeltaResponse,
): CalendarEvent[] {
if (response.full) {
return response.events.slice().sort(compareCalendarEvents);
}
const rows = new Map(current.map((event) => [event.id, event]));
for (const deleted of response.deleted) {
if (
!deleted.resource_type ||
deleted.resource_type === "calendar_event"
) {
rows.delete(deleted.id);
}
}
for (const event of response.events) rows.set(event.id, event);
return Array.from(rows.values()).sort(compareCalendarEvents);
}
export function errorText(error: unknown): string {
if (error instanceof Error) return error.message;
return "i18n:govoplan-calendar.calendar_request_failed.ae8a54f4";
}
function isCalendarMode(value: unknown): value is CalendarMode {
return (
value === "continuous" ||
value === "month" ||
value === "week" ||
value === "workweek" ||
value === "day"
);
}
function mixHexWithWhite(hex: string, whiteRatio: number): string {
const normalized = normalizeHexColor(hex) || DEFAULT_CALENDAR_COLOR;
const ratio = Math.min(1, Math.max(0, whiteRatio));
const red = Number.parseInt(normalized.slice(1, 3), 16);
const green = Number.parseInt(normalized.slice(3, 5), 16);
const blue = Number.parseInt(normalized.slice(5, 7), 16);
return `rgb(${Math.round(red * (1 - ratio) + 255 * ratio)}, ${Math.round(
green * (1 - ratio) + 255 * ratio,
)}, ${Math.round(blue * (1 - ratio) + 255 * ratio)})`;
}
function eventDurationMs(event: CalendarEvent, fallback: number): number {
if (!event.end_at) return fallback;
const start = new Date(event.start_at);
const end = new Date(event.end_at);
return Math.max(30 * 60 * 1000, end.getTime() - start.getTime());
}
function dateAtMinuteOfDay(day: Date, minuteOfDay: number): Date {
return new Date(
day.getFullYear(),
day.getMonth(),
day.getDate(),
Math.floor(minuteOfDay / 60),
minuteOfDay % 60,
);
}
function snapMinute(value: number): number {
return Math.round(value / 15) * 15;
}
function clampNumber(
value: unknown,
min: number,
max: number,
fallback: number,
): number {
if (typeof value !== "number" || Number.isNaN(value)) return fallback;
return Math.min(max, Math.max(min, Math.round(value)));
}
function timedSegmentsForDay(
events: CalendarEvent[],
day: Date,
): TimedEventSegment[] {
const dayStart = startOfDay(day);
const dayEnd = addDays(dayStart, 1);
return events
.filter((event) => !event.all_day)
.map((event): TimedEventSegment | null => {
const eventStart = new Date(event.start_at);
const rawEventEnd = event.end_at
? new Date(event.end_at)
: addMinutes(eventStart, 30);
const eventEnd =
rawEventEnd > eventStart ? rawEventEnd : addMinutes(eventStart, 30);
const start = eventStart < dayStart ? dayStart : eventStart;
const end = eventEnd > dayEnd ? dayEnd : eventEnd;
if (end <= start) return null;
const startMinutes = minutesBetween(dayStart, start);
const durationMinutes = minutesBetween(start, end);
return {
event,
start,
end,
top: Math.max(0, (startMinutes / 60) * HOUR_ROW_HEIGHT),
height: Math.max(
26,
(durationMinutes / 60) * HOUR_ROW_HEIGHT,
),
};
})
.filter(
(segment): segment is TimedEventSegment => segment !== null,
)
.sort(
(left, right) =>
left.start.getTime() - right.start.getTime() ||
right.end.getTime() - left.end.getTime(),
);
}
function clusterTimedSegments(
segments: TimedEventSegment[],
): TimedEventSegment[][] {
const clusters: TimedEventSegment[][] = [];
let current: TimedEventSegment[] = [];
let currentEnd: Date | null = null;
for (const segment of segments) {
if (!current.length || (currentEnd && segment.start < currentEnd)) {
current.push(segment);
if (!currentEnd || segment.end.getTime() > currentEnd.getTime()) {
currentEnd = segment.end;
}
continue;
}
clusters.push(current);
current = [segment];
currentEnd = segment.end;
}
if (current.length) clusters.push(current);
return clusters;
}
function minutesBetween(start: Date, end: Date): number {
return Math.max(0, (end.getTime() - start.getTime()) / 60_000);
}
function compareAgendaEvents(
left: CalendarEvent,
right: CalendarEvent,
): number {
if (left.all_day !== right.all_day) return left.all_day ? -1 : 1;
return (
left.start_at.localeCompare(right.start_at) ||
left.summary.localeCompare(right.summary)
);
}
function timeLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, {
hour: "2-digit",
minute: "2-digit",
}).format(value);
}
function compareCalendarEvents(
left: CalendarEvent,
right: CalendarEvent,
): number {
return (
new Date(left.start_at).getTime() -
new Date(right.start_at).getTime() ||
left.summary.localeCompare(right.summary) ||
left.id.localeCompare(right.id)
);
}
+2 -4
View File
@@ -39,7 +39,6 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED",
"i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy",
"i18n:govoplan-calendar.continuous.04f2ccda": "Continuous",
"i18n:govoplan-calendar.credential_reference.a7e92de5": "Credential reference",
"i18n:govoplan-calendar.credential.8bede3ea": "Credential",
"i18n:govoplan-calendar.dav_url.4205e180": "DAV URL",
"i18n:govoplan-calendar.day.987b9ced": "Day",
@@ -63,7 +62,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.end_date.89d10cd6": "End date",
"i18n:govoplan-calendar.end_mode.5a06de37": "End mode",
"i18n:govoplan-calendar.end_time.cd7800da": "End time",
"i18n:govoplan-calendar.enter_a_secret_now_or_use_an_environment_referen.6db0e6ce": "Enter a secret now or use an environment reference such as",
"i18n:govoplan-calendar.enter_a_password_or_token_for_this_source.74c09a54": "Enter a password or token for this source.",
"i18n:govoplan-calendar.etag.11d00f6e": "ETag",
"i18n:govoplan-calendar.event": "event",
"i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055": "Event count could not be loaded. The backend will still apply the selected delete action.",
@@ -228,7 +227,6 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED",
"i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy",
"i18n:govoplan-calendar.continuous.04f2ccda": "Continuous",
"i18n:govoplan-calendar.credential_reference.a7e92de5": "Credential reference",
"i18n:govoplan-calendar.credential.8bede3ea": "Credential",
"i18n:govoplan-calendar.dav_url.4205e180": "DAV URL",
"i18n:govoplan-calendar.day.987b9ced": "Tag",
@@ -252,7 +250,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.end_date.89d10cd6": "End date",
"i18n:govoplan-calendar.end_mode.5a06de37": "End mode",
"i18n:govoplan-calendar.end_time.cd7800da": "End time",
"i18n:govoplan-calendar.enter_a_secret_now_or_use_an_environment_referen.6db0e6ce": "Enter a secret now or use an environment reference such as",
"i18n:govoplan-calendar.enter_a_password_or_token_for_this_source.74c09a54": "Geben Sie ein Passwort oder Token für diese Quelle ein.",
"i18n:govoplan-calendar.etag.11d00f6e": "ETag",
"i18n:govoplan-calendar.event": "event",
"i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055": "Event count could not be loaded. The backend will still apply the selected delete action.",
+97 -2
View File
@@ -1,10 +1,19 @@
import { createElement, lazy } from "react";
import type { CalendarPickerUiCapability, PlatformWebModule } from "@govoplan/core-webui";
import type {
CalendarPickerUiCapability,
DashboardWidgetsUiCapability,
PlatformWebModule,
SettingsSectionsUiCapability
} 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"));
const CalendarSettingsPanel = lazy(
() => import("./features/calendar/CalendarSettingsPanel")
);
const eventRead = ["calendar:event:read"];
const translations = {
@@ -13,6 +22,74 @@ const translations = {
};
const calendarPicker: CalendarPickerUiCapability = { CalendarPicker };
const calendarSettingsSections: SettingsSectionsUiCapability = {
sections: [
{
id: "calendar",
surfaceId: "calendar.settings.preferences",
label: "Calendar",
group: "ui",
order: 45,
anyOf: eventRead,
render: ({ settings, auth }) =>
createElement(CalendarSettingsPanel, { settings, auth })
}
]
};
const calendarDashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
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 +98,29 @@ 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
},
{
id: "calendar.settings.preferences",
moduleId: "calendar",
kind: "section",
label: "Calendar preferences",
order: 45
}
],
navItems: [{ to: "/calendar", label: "i18n:govoplan-calendar.calendar.adab5090", iconName: "calendar", anyOf: eventRead, order: 55 }],
routes: [
{ path: "/calendar", anyOf: eventRead, order: 55, render: ({ settings, auth }) => createElement(CalendarPage, { settings, auth }) }],
uiCapabilities: {
"calendar.picker": calendarPicker
"calendar.picker": calendarPicker,
"dashboard.widgets": calendarDashboardWidgets,
"settings.sections": calendarSettingsSections
}
};
+1 -49
View File
@@ -89,21 +89,6 @@
gap: 6px;
}
.calendar-icon-button.btn {
width: 36px;
min-width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.calendar-icon-button.btn:disabled {
opacity: .42;
cursor: default;
}
.calendar-mode-switch {
flex: 0 1 520px;
grid-template-columns: repeat(5, minmax(0, 1fr));
@@ -250,40 +235,7 @@
}
.calendar-list-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 2px;
min-width: 28px;
}
.calendar-row-icon-button.btn {
width: 28px;
min-width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border-color: transparent;
background: transparent;
opacity: .72;
}
.calendar-row-icon-button.btn:hover,
.calendar-row-icon-button.btn:focus-visible {
border-color: var(--line-dark);
background: var(--surface);
opacity: 1;
}
.calendar-row-icon-button.btn.is-syncing,
.calendar-row-icon-button.btn.is-syncing:disabled {
border-color: var(--green);
background: var(--surface);
color: var(--green);
opacity: 1;
cursor: progress;
width: auto;
}
.calendar-sync-spin {
@@ -0,0 +1,78 @@
import fs from "node:fs";
function assert(condition, message) {
if (!condition) throw new Error(message);
}
const page = fs.readFileSync(
new URL(
"../src/features/calendar/CalendarPage.tsx",
import.meta.url,
),
"utf8",
);
const views = fs.readFileSync(
new URL(
"../src/features/calendar/CalendarViews.tsx",
import.meta.url,
),
"utf8",
);
const model = fs.readFileSync(
new URL(
"../src/features/calendar/calendarViewModel.ts",
import.meta.url,
),
"utf8",
);
const collectionDialogs = fs.readFileSync(
new URL(
"../src/features/calendar/CalendarCollectionDialogs.tsx",
import.meta.url,
),
"utf8",
);
const eventDialog = fs.readFileSync(
new URL(
"../src/features/calendar/CalendarEventDialog.tsx",
import.meta.url,
),
"utf8",
);
assert(
page.includes('from "./CalendarViews"'),
"CalendarPage must compose the focused calendar view components",
);
assert(
!page.includes("function CalendarWeekRows("),
"CalendarPage must not own month/continuous rendering",
);
assert(
!page.includes("function CalendarTimeGrid("),
"CalendarPage must not own time-grid rendering",
);
assert(
views.includes("export function CalendarWeekRows(") &&
views.includes("export function CalendarTimeGrid("),
"CalendarViews must own both view families",
);
assert(
model.includes("export function layoutTimedEventsForDay(") &&
model.includes("export function continuousVirtualWindow("),
"calendar layout and virtualization must remain independently testable",
);
assert(
!page.includes("function CalendarCollectionDialog(") &&
!page.includes("function CalendarEventDialog("),
"CalendarPage must not own source or event form dialogs",
);
assert(
collectionDialogs.includes(
"export function CalendarCollectionDialog(",
) &&
eventDialog.includes("export function CalendarEventDialog("),
"calendar dialogs must remain focused components",
);
console.log("Calendar page decomposition checks passed.");
+115
View File
@@ -0,0 +1,115 @@
import {
continuousEventWindow,
continuousVirtualWindow,
daysForMode,
groupEventsByDay,
layoutTimedEventsForDay,
moveEventToDay,
rangeForMode,
resizeEventToTime,
} from "../src/features/calendar/calendarViewModel.ts";
function assert(condition: unknown, message: string): void {
if (!condition) throw new Error(message);
}
function event(
id: string,
start: Date,
end: Date,
allDay = false,
) {
return {
id,
calendar_id: "calendar-1",
summary: id,
start_at: start.toISOString(),
end_at: end.toISOString(),
all_day: allDay,
} as never;
}
const focus = new Date(2026, 6, 15, 12);
const month = rangeForMode("month", focus, {
before: 8,
after: 12,
});
assert(
(month.end.getTime() - month.start.getTime()) / 86_400_000 === 42,
"month view should retain its six-week window",
);
const virtual = continuousVirtualWindow(
100,
{ scrollTop: 20 * 92, height: 8 * 92 },
3,
);
assert(virtual.start === 17, "virtualization should retain overscan above");
assert(virtual.end === 31, "virtualization should retain overscan below");
assert(
virtual.topSpacerHeight + virtual.bottomSpacerHeight > 0,
"virtualization should preserve offscreen geometry",
);
const continuousDays = daysForMode("continuous", focus, {
before: 50,
after: 50,
});
const eventWindow = continuousEventWindow(
continuousDays,
{ scrollTop: 40 * 92, height: 6 * 92 },
);
assert(
(eventWindow.end.getTime() - eventWindow.start.getTime()) / 86_400_000 === 10 * 7,
"continuous event loading should stay bounded to a ten-week window",
);
assert(
eventWindow.start > continuousDays[0],
"continuous event loading should follow the virtualized viewport",
);
const day = new Date(2026, 6, 7);
const parallel = [0, 1, 2, 3].map((index) =>
event(
`event-${index}`,
new Date(2026, 6, 7, 9),
new Date(2026, 6, 7, 10),
),
);
const layout = layoutTimedEventsForDay(parallel, day);
assert(
layout.items.length === 2 && layout.overflows.length === 1,
"parallel events should use bounded columns plus one overflow control",
);
assert(
layout.overflows[0].events.length === 2,
"overflow should retain every hidden event",
);
const allDay = event(
"all-day",
new Date(2026, 6, 7),
new Date(2026, 6, 9),
true,
);
const grouped = groupEventsByDay([allDay]);
assert(grouped.size === 2, "all-day end dates should remain exclusive");
const moved = moveEventToDay(allDay, new Date(2026, 6, 20));
assert(
moved.endAt?.getDate() === 22,
"moving an all-day event should preserve its duration",
);
const timed = event(
"timed",
new Date(2026, 6, 7, 10),
new Date(2026, 6, 7, 11),
);
const resized = resizeEventToTime(timed, "end", day, 9 * 60);
assert(
resized.endAt !== null &&
resized.endAt.getTime() > resized.startAt.getTime(),
"resizing must not invert an event",
);
console.log("Calendar view model interaction checks passed.");
+44
View File
@@ -0,0 +1,44 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@govoplan/core-webui": [
"../../govoplan-core/webui/src/index.ts"
],
"lucide-react": [
"../../govoplan-core/webui/node_modules/lucide-react/dist/lucide-react.d.ts"
],
"react": [
"../../govoplan-core/webui/node_modules/@types/react/index.d.ts"
],
"react/jsx-runtime": [
"../../govoplan-core/webui/node_modules/@types/react/jsx-runtime.d.ts"
],
"react-router": [
"../../govoplan-core/webui/node_modules/react-router/dist/development/index.d.ts"
]
}
},
"include": [
"../../govoplan-core/webui/src/vite-env.d.ts",
"src/module.ts",
"src/api/calendar.ts",
"src/features/calendar/CalendarPage.tsx",
"src/features/calendar/CalendarSettingsPanel.tsx",
"src/features/calendar/UpcomingEventsWidget.tsx",
"src/features/calendar/CalendarViews.tsx",
"src/features/calendar/CalendarCollectionDialogs.tsx",
"src/features/calendar/CalendarEventDialog.tsx",
"src/features/calendar/calendarViewModel.ts"
]
}