### Description
Takes over #2807 (the "calendar" external integration type — sp…ec and milestone 1 implementation, written this summer) in a fresh PR: the branch is brought up to date with `master`, and the spec and every modified file were reviewed again against what `master` has become since. #2807 can be closed in favor of this one.
A calendar provider (Nextcloud/CalDAV servers, iCloud, token-based APIs, public ICS feeds) becomes an ordinary external integration that pushes calendars and events into the core calendar store, feeding the same calendar view, chips box, scene trigger/actions and MCP tool as the internal CalDAV service. Push model: the integration syncs, the core stores. Per-user OAuth2 (Google, Outlook) stays design-only (milestone 2).
**What the original PR brings** (unchanged in substance, see #2807 for the details):
- `lib/calendar`: transactional `upsertCalendars` / `upsertEvents` (window prune by overlap, 10 000 events per calendar, event moves, rows of another owner never stolen), ownership checks and user-editable field whitelists on the user calendar routes, `CALENDAR_TYPES`, the duplicated `calendar.associate` fixed;
- external-integration supervisor: `type: "calendar"`, `account_schema` (per-user accounts, enable/disable as the consent gesture), user-scoped `ext:<selector>:<user_selector>:` ids, host API `GET/POST/DELETE /api/integration/v1/calendar`, `GET .../calendar/account`, `POST .../calendar/event`, management routes `.../:selector/calendar/account` and `PATCH .../:selector/calendar/:calendar_selector`, `calendar.updated` / `external-integration.calendar.account-updated` pushes, write rate limit, explicit uninstall cleanup;
- front: "My calendars" block, live refresh of the calendar view, calendar integrations routed to the configuration screen.
**Merge with master**
- `provider` type (#3109), dashboard widgets and scene declarations (#3110): `calendar` added next to `provider` in `MANIFEST_TYPES`, the vendored `manifest.schema.json` (enum + `account_schema` rule, merged with the `provider` rule) and the shared front list `TYPES_WITHOUT_DEVICE_SCREENS` (tabs, catalog URL, discover/device guards, and now the post-install redirect).
- `ConfigSchemaForm` moved to `components/integration/` on master: the `CalendarAccountCard` import was broken by the merge (front build failure), fixed.
- Spanish translation (#3119): the new keys added to `es.json`.
- Second merge (energy contracts #3130 and the October fixes): `energyPrice` → `energyContract` in the supervisor test wiring, the CalDAV formatter keeps both `CALENDAR_TYPES` and the new timezone helpers, `management-api.md` keeps the action `default` wording and the calendar cleanup on uninstall.
**Review fixes**
- Spec: the file no longer allocates the "B.19" identifier (the spec README forbids new identifiers and says "there is no B.19"); every reference now cites `capabilities/calendar-type.md`, and the README/AGENTS.md edits that listed capabilities are reverted (the README never lists them). The "current state" section is now a dated "state before this workstream", and a new paragraph records why calendar is a type and not a capability field, against the bar set by `provider-type.md` (which, with `integration-catalog-categories.md` §2.2, now counts the calendar type).
- Privacy: a publication batch mixing shared and private calendars, or an event moving between a shared and a private calendar, broadcast the private calendars' selectors (their slugified names) to every connected user. All `calendar.updated` pushes now go through one helper, `notifyCalendarsUpdated`, that sends each calendar to its own audience.
- Isolation: the user creation routes (`POST /api/v1/calendar`, `POST /api/v1/calendar/:selector/event`) accepted any `external_id`. A household member could squat the user-scoped id another member's integration would push, and that member's sync then failed with 409. The `ext:` namespace is now reserved to the integrations (`400`).
- Events cap: the window prune now runs before the upsert, in the same transaction and with the same final state. The 10 000-event cap is then measured on the resulting calendar, so a window republished at the cap is no longer refused because of the rows it replaces.
- Install screen: the calendar information line the spec promised was missing, now added (en/fr/de/es).
- Calendar view: an event without `end` (optional in the contract) rendered in 1970; it now renders at its start.
- `account_schema` validation reuses `ACCOUNT_FIELD_TYPES`; small comment and constant cleanups.
**Second review pass** (independent review of the whole calendar diff, plus CodeRabbit):
- Selectors: an event's selector now derives from its `external_id`, which is unique by construction (CalDAV derives it from the iCal UID the same way). Name-derived selectors probed `name`, `name-2`… inside the write transaction for every occurrence of a recurrence: 500 same-named events took ~6 s against ~1.4 s. A calendar whose name slugifies to nothing (non-Latin script, emoji) now gets `calendar` instead of an empty selector, which collided and could not be addressed in a URL.
- Full-day events: the code now honors the spec's "interpreted by calendar date". The core reads the date as written and stores the local midnights of the instance (the `TIMEZONE` setting, else the scene engine default). The end is exclusive and defaults to start + 1 day. This is what CalDAV stores, what the view renders as one day and what scenes evaluate. Before, a UTC-midnight full-day event showed as two days in Paris and fired scenes at 02:00.
- Window overlap: an event starting exactly at `from` now belongs to the window. Before, a zero-duration event on a boundary was refused by every window.
- Spec: one window = one request. Above 500 events, the integration splits the *window* into disjoint sub-windows, never the list under one window (that would prune the other chunks).
- Races: `sync` is read again inside the upsert transaction, and events pushed to a calendar whose account is no longer enabled answer `404`.
- The `ext:` guard of `createEvent` also covers the CalDAV/webcal sync path, whose UIDs the feed controls; such an event is skipped and logged.
- The CalDAV service's `PATCH /api/v1/service/caldav/enable|disable` routes, open to every user, now check that the requester owns the CalDAV calendar.
- CodeRabbit: the generic `PATCH /api/v1/calendar/:selector` leaves `sync`/`shared` of an integration calendar to the integration route (its side effects live there), and the config page drops a calendar-account response that arrives after the user opened another integration.
**Third review pass** (maintainer review, end-to-end test on a real instance, CodeRabbit), after the merge with `master`:
- CalDAV sharing page: it now lists only the user's own calendars. `GET /api/v1/calendar` also returns the calendars other members share, and saving them failed the whole save. New Cypress spec for the two-user case.
- Events without `end`: a timed event pushed without `end` is stored with `end` = `start`, a zero-duration event (RFC 5545). Before, the column was NULL: the scene trigger received `Invalid Date` and `is-event-running` never matched the event.
- Timezone: an unknown `TIMEZONE` setting falls back to the default timezone with a warning, instead of failing every full-day push with a 500. `DEFAULT_TIMEZONE` is now one constant in `server/utils/constants.js`, shared by the scene engine and the calendar integrations.
- **Behavior change on the user calendar routes:** a write on a calendar another member **shares** now answers `403`. Writes are still owner-only; the calendar is just visible to the requester. A private calendar of another user still answers `404`. This applies to calendar PATCH/DELETE and to event POST/PATCH/DELETE, through one helper (`assertCalendarWritable`).
- Integration calendar toggles: the generic `PATCH /api/v1/calendar/:selector` now answers `400` naming the integration route when the request would change `sync`/`shared` of an integration calendar. Before, they were dropped silently. Unchanged values sent back with the row still pass.
- Calendars cap: the 50-calendars-per-user cap is counted inside the `upsertCalendars` transaction, so two concurrent publishes cannot both pass it.
- `null` description: a `null` calendar `description` is now treated as absent, the same convention as the event fields.
- Performance: `upsertEvents` reads the batch's rows in three queries instead of one lookup per event inside the write transaction.
- `destroyEvent`: it joins its calendar with `required: true`, like `updateEvent`.
- "My calendars" block:
- the form starts from the `account_schema` defaults;
- the calendar list refreshes on `calendar.updated`;
- a failed toggle is reloaded from the server;
- an enabled account with nothing to fill in shows no Save button.
- Uninstall: a calendar integration gets its own warning (en/fr/de/es): uninstalling deletes every user's calendars.
No DB migration. No new device feature category.
**Cross-repo follow-ups** (listed in the spec): the canonical manifest schema of `GladysAssistant/integration-store` must accept `type: "calendar"` + `account_schema` before a calendar integration can be published to the store, and the SDK methods land in `integration-sdk-js`.
### Related request
Forum: https://community.gladysassistant.com/t/10432
Supersedes #2807.
### Checklist
- [x] If a forum topic or GitHub issue exists, the description links it (`Forum: https://community.gladysassistant.com/t/...` or `Closes #...`)
- [ ] Tests pass: `cd server && npm run coverage` (Codecov requires 100% coverage on changed lines) and Cypress (`npm run cypress:run`) if the UI changed — locally: every calendar / external-integration / scene / CalDAV test passes and the changed server files are at 100% line and branch coverage; the only failures of the full run are environmental (Gladys Plus gateway network calls, no `sqlite3` CLI, no Docker) and identical on `master`. Cypress: the two new specs (`caldav/CaldavShare.cy.js`, `external-integration/ExternalIntegrationCalendarAccount.cy.js`) pass locally; the full suite is left to CI.
- [x] Linter and prettier pass on both front and server (`npm run eslint`, `npm run prettier`)
- [x] No undocumented breaking change
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01TaHFVjnhhWusXLXL9wCpTu
https://claude.ai/code/session_019EyH5GXHme5vzMe2f3gC8Z
## Summary by CodeRabbit
* **New Features**
* Added calendar-provider integrations that sync calendars and events with Gladys.
* Added per-user settings to configure calendar accounts, enable or disable calendars, manage synchronization, and control household sharing.
* Calendar updates from integrations now appear in the calendar view without reloading.
* Non-admin users can view installed calendar integrations and manage their own calendar accounts.
* **Bug Fixes**
* Improved event display when end times or calendar color details are unavailable.
* Calendar editing now respects ownership, preventing users from modifying another user’s calendars or events.
* Uninstalling a calendar integration removes its calendars and events for all household members.