fix(calendar): preserve floating/TZID semantics across CalDAV roundtrip (#782)

The CalDAV REPORT in `_search_events_by_date` unconditionally requested
server-side `<C:expand>`. Per RFC 4791 §9.6.5 the server then normalizes
every expanded DTSTART/DTEND to UTC `Z`, which destroyed two pieces of
information on the read path:

- RFC 5545 floating local times came back as fake-UTC (a `+00:00` suffix
  that did not match the stored value), so a 2:30 PM floating event was
  indistinguishable from a 14:30 UTC event in the MCP response.
- TZID-bound events lost their IANA TZID context — a "10am America/New_York"
  event came back as `14:00:00+00:00`, making it impossible for callers to
  reconstruct DST-aware recurrence semantics.

Replace `<C:expand>` with client-side recurrence expansion via the
`recurring-ical-events` library (promoted from transitive to direct dep),
so the wire response retains its original DTSTART format. Surface the
TZID parameter as new `start_tz`/`end_tz` fields on `CalendarEventSummary`.

Add an optional `timezone` (IANA name) parameter to `nc_calendar_create_event`
and `nc_calendar_update_event` so callers can pin a TZID for naive input;
the helper attaches `ZoneInfo(...)` and emits a paired `VTIMEZONE`
component. Naive input without `timezone` continues to store as RFC 5545
floating local time (with a warning logged). Offset-aware input continues
to store as UTC `Z`.

Drive-by: switch the update path's DTSTART/DTEND assignment from raw
`datetime` to `vDDDTypes(dt)` wrappers — the previous code produced invalid
iCal like `DTSTART:2026-05-14 10:00:00+00:00` for any TZ-aware update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-11 02:41:10 +02:00
co-authored by Claude Opus 4.7
parent 728a9ae252
commit 9d5ac01f24
6 changed files with 469 additions and 75 deletions
+24 -2
View File
@@ -25,8 +25,30 @@ class CalendarEventSummary(BaseModel):
uid: str = Field(description="Event UID")
summary: str = Field(description="Event summary/title")
start: str = Field(description="Event start datetime (ISO format)")
end: Optional[str] = Field(None, description="Event end datetime (ISO format)")
start: str = Field(
description=(
"Event start datetime (ISO format). No suffix = RFC 5545 floating "
"local time; ``+00:00`` = UTC; an explicit offset (e.g. ``-04:00``) "
"= TZID-bound at that instant. The IANA TZID name is exposed "
"separately as ``start_tz``."
)
)
end: Optional[str] = Field(
None,
description=(
"Event end datetime (ISO format). Same encoding rules as ``start``."
),
)
start_tz: Optional[str] = Field(
None,
description=(
"IANA timezone name when DTSTART had a TZID parameter (e.g. "
"``America/New_York``). ``None`` for floating local or UTC."
),
)
end_tz: Optional[str] = Field(
None, description="IANA timezone name when DTEND had a TZID parameter."
)
all_day: bool = Field(default=False, description="Whether event is all-day")
location: Optional[str] = Field(None, description="Event location")
description: Optional[str] = Field(None, description="Event description")