# Calendar panel design A calendar panel that drops from the island's clock pill: a month grid beside the selected day's agenda, and an editor that creates, edits and deletes events in the vdirsyncer calendars. This is sub-project 3 of the roadmap in `2026-10-07-island-core-design.md`. The model is Modesty's calendar menu, read for ideas only (GPLv3, this repo is GPLv2-only). ## Scope In v1: - Month grid with event dots, month navigation, today and selection marked. - Agenda of the selected day. - Create, edit and delete events: title, calendar, date or date range, start and end time or all day, simple recurrence, one alarm, location, notes. - Simple recurrence: none, daily, weekly, monthly, yearly, with an optional until-date. Edit and delete act on the whole series. - Live refresh when the calendar files change, whoever changed them. - A sync after every write. Not in v1: per-occurrence edits (`RECURRENCE-ID`, `EXDATE`, "this and following"); rich recurrence rules beyond a plain `FREQ` and `UNTIL`; more than one alarm per event from the form; a keybind; an upcoming-event hint on the clock pill. Measured on the live calendars: no event uses `RECURRENCE-ID` or `EXDATE`, so the cut loses nothing in use today. ## Where it lives desktop/island/calendar/ calctl Python: list, new, edit, delete; JSON in and out test_calctl.py plain asserts against temporary vdirs Cal.js pure date logic for the QML, node-tested test_cal.js node test for Cal.js Calendar.qml singleton: calctl list, the file watch, writes CalendarPanel.qml month grid left, agenda or editor right EventEditor.qml the form The island is the only consumer, so the backend sits beside its QML. ## Backend: calctl `/usr/bin/python3` with `icalendar` and `dateutil`, both already installed for cal-notif; nothing else outside the standard library. Calendars come from the khal config's `[calendars]` section (name, path, colour), so there is no second list to keep in step. khal and cal-notif keep working untouched: all three read the same vdir. khal was the roadmap's first idea for reading. It is not used: 0.11 has no JSON output, and its sqlite cache is a second view of the same files. Commands: calctl calendars {default, calendars: [{name, color, path}]} calctl list occurrences in [from, to), local time calctl get the series itself, for the editor calctl new {uid} calctl edit {uid} calctl delete {} `list` expands recurrence with `dateutil.rrule` and returns one object per occurrence: uid, calendar, title, start, end, allDay, location, notes, repeat, until, alarm. `repeat` is `none`, `daily`, `weekly`, `monthly`, `yearly`, or `custom` for any rule richer than a plain `FREQ` with optional `UNTIL`. `alarm` is `none`, `10m`, `1h`, `1d`, or `custom` for anything else (several alarms, another offset, an absolute trigger, an alarm after the start). Writes: - `new` writes `.ics` into the chosen calendar's directory. Timed events carry a `TZID` for the local zone with its VTIMEZONE; all-day events are `DATE` values with an exclusive end, per RFC 5545. - `edit` loads the file and changes only the properties the form owns. Other properties, VTIMEZONEs, a `custom` rule and `custom` alarms survive. Picking a preset alarm replaces every VALARM; picking a preset repeat replaces the RRULE. A calendar change moves the file to the other directory. - `delete` removes the file. - Every write goes to a temporary file in the same directory and is renamed into place, so a reader never sees half a file. - After a write, `vdirsyncer sync` is started detached, its output appended to the existing sync log. A failed sync is not an error for calctl: the cron run every 30 minutes retries. Every command prints one JSON object on stdout: `{"ok": true, "data": ...}` or `{"ok": false, "error": "..."}` with exit 1, the error also on stderr. The shell reads stdout only, so it never has to order an exit code against a stream. The event JSON is an argument rather than stdin, which keeps the QML side to a plain `Process` command. `get` exists because the editor edits the series: an occurrence's start is not the series' DTSTART. Event JSON, in and out: `calendar`, `title`, `allDay`, `start`, `end` (`YYYY-MM-DDTHH:MM` local, or `YYYY-MM-DD` all day with an inclusive last day), `repeat`, `until` (`YYYY-MM-DD` or ""), `alarm`, `location`, `notes`; out also `uid`, `color` and `override`. Recurrence is expanded in naive local time, which keeps the wall-clock time of a TZID event across DST, as RFC 5545 asks. ponytail: a `Z` UNTIL is read as local time, so an until-date can be off by the UTC offset at its very end. ## Shell side `Calendar.qml` is a singleton. It runs `calctl list` for the six weeks the month grid shows, and again on month navigation. It watches both calendar directories with `inotifywait` for `close_write,moved_to,delete`, debounced, the same shape as the mail count's xapian watch, so an edit from khal, the phone via cron sync, or the panel itself all repaint the grid. Writes go through it: `create`, `update` and `remove` run calctl and report the stderr line on failure. The clock pill gains a click that sets the island's `panel` to `"calendar"`. The pill opens to about 620px; its flick and VM dots are unchanged at rest. **Left: the month.** Seven columns, weeks from Monday (khal's `firstweekday = 0`). Days outside the month dimmed, today in the accent's text colour, the selected day outlined. A day with events carries a dot per calendar that has events that day, in that calendar's khal colour mapped to a Theme colour. Header with the month name and previous/next arrows. **Right: the agenda.** The selected day's events in start order, all-day first, each with a calendar-coloured bar, its time range and title. A click opens it in the editor. "+ New event" at the bottom opens an empty editor for that day. **The editor** replaces the agenda. While it is open the month grid is its date picker: a click sets the date, a shift-click a second day sets an end date for a multi-day event. Fields: - title; - calendar, one chip per calendar; - start and end time, two `HH:MM` text fields, scroll steps 15 minutes; an all-day chip hides them; - repeat: none, day, week, month, year, plus `custom` shown when the event has a richer rule and not selectable; an optional until-date set by clicking the grid after choosing "until"; - alarm: none, 10m, 1h, 1d, plus `custom` shown when not a preset and not selectable; when cal-notif's `overrides.toml` has the event's uid, a note says cal-notif's own alarm wins; - location, notes; - Delete (a second click confirms), Cancel, Save. A new event defaults to the next whole hour, one hour long, the default calendar from the khal config, alarm 1h (the most common alarm in the live calendars, 84 of 97 events with one). Escape closes the editor first and the panel second. ## Errors A failed calctl keeps the editor open with its stderr line under the buttons, so nothing typed is lost. A list that fails keeps the last good month and shows the line in the agenda. Sync conflicts follow the vdirsyncer pair's `conflict_resolution = "b wins"`: an event edited here and on the server before the next sync keeps the server's version. That is the existing behaviour, documented, not handled. ## Testing - `test_calctl.py`: list across a month boundary, all-day and multi-day, yearly expansion of an event created years ago, `custom` detection for a rich rule and a non-preset alarm, edit preserving unknown properties and a custom alarm, a calendar move, delete, atomic write leaving no temp file. Run against temporary vdirs with a temporary khal config; never against the live calendars. - A smoke check of the live shell log after wiring. - The user checks on screen: grid, agenda, create, edit, move between calendars, repeat, alarm, delete, and an edit made in khal repainting the panel.