diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-24 15:23:44 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-24 15:23:44 +0200 |
| commit | d3a369d8105a77869900a241b57855e7876ff1ed (patch) | |
| tree | 266bee0f41ff7ccc5717ad479e203f6692948682 | |
| parent | 7454096cae63d87c75a676751bb07f5650280cd0 (diff) | |
| download | qtmaildir-d3a369d8105a77869900a241b57855e7876ff1ed.tar.gz qtmaildir-d3a369d8105a77869900a241b57855e7876ff1ed.zip | |
docs: specify the calendar window (item 206)
Month and agenda views over the vdirsyncer vdir, editing in the side
pane, atomic stale-checked writes with a debounced sync and a post-sync
comparison, and undo for create, edit and delete. Names and colours come
from the vdir's own metadata files; qtmaildir depends on neither khal
nor its config.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | docs/superpowers/specs/2026-09-24-calendar-design.md | 372 |
1 files changed, 372 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-09-24-calendar-design.md b/docs/superpowers/specs/2026-09-24-calendar-design.md new file mode 100644 index 0000000..e33767e --- /dev/null +++ b/docs/superpowers/specs/2026-09-24-calendar-design.md @@ -0,0 +1,372 @@ +# A calendar window: view, add, edit and delete events + +Date: 2026-09-24 +Status: approved, not yet implemented +Backlog: item 206 (blocks 207; answers 205's write-during-sync question) + +## Problem + +qtmaildir has no calendar. The user's words (2026-09-18): "I'd prefer to be +able to work on my calendar from inside qtmaildir. I'm talking view, add, +remove, edit events, accept/refuse invitations, send invites to events, etc." +Invitations are item 207 and out of scope here; this spec is everything else. + +khal is another consumer of the same calendar, exactly as neomutt is another +consumer of the Maildir. qtmaildir must not depend on khal, its config or its +presence: this feature replaces what khal was used for. + +## Settled before this brainstorm + +Not to be reopened: the calendar is its **own top-level window**, like +`ComposeWindow`, and iCalendar work uses **libical**, never a hand parse. + +## Verified context + +Measured on 2026-09-24 against the live data, read-only. + +- **The data is a vdir.** 312 `.ics` files, one `VEVENT` each, in three + collections under `~/.local/share/calendars/`, the local half of a + vdirsyncer `calendars` pair against a CalDAV server built on Open-Xchange. + vdirsyncer runs from cron every 30 minutes with `conflict_resolution = "b + wins"`: the server wins a conflict and a local edit loses silently. +- **Names and colours are on the server and in the vdir.** The pair had no + `metadata` key, so no collection carried a `displayname` or `color` file. + With `metadata = ["color", "displayname"]` added and `vdirsyncer metasync` + run, the server supplied all three display names. It supplied no colour; + a hand-written local `color` file was PUSHED by metasync and a fresh pull + from the server returned it, so the server stores colours it is given. Both + are now in place on this machine. The config change is the user's, outside + this repo. +- **Scheduling is mail-only in practice.** 305 events are a one-time OX + migration import, the other 7 were written by clients (DAVx5, Thunderbird, + khal), and none carries `SCHEDULE-AGENT`/`SCHEDULE-STATUS`. Nothing suggests + the server writes events on its own; an organiser's update arrives as mail + and changes nothing until a client applies it (207). +- **308 of 312 carry `ATTENDEE`.** Almost everything is a meeting. +- **Time forms are mixed**, and every one must work: UTC, `TZID=Europe/Rome` + and `Europe/Berlin`, `VALUE=DATE` (59 all-day), and possibly a few floating + times. **120 files name a `TZID` without embedding the `VTIMEZONE`** the RFC + requires. +- **Recurrence is simple in the events** (37 files carry `RRULE`, many of them + only inside `VTIMEZONE` DST rules): yearly for birthdays, one monthly + `BYMONTHDAY` with `COUNT`, and `WKST` present on several. No file carries a + `RECURRENCE-ID` override today, but other clients may write one at any time. +- **95 events carry a `VALARM`.** +- **libical 3.0.20** ships `libical.pc`, so it is `pkg_check_modules`, unlike + notmuch. Its vCard support is 4.0 and irrelevant here. +- **Performance needs no worker thread.** A standalone C benchmark parsing all + 312 files with libical and expanding every `RRULE` over three months takes + **about 5 ms**, with 0 unparsable files. + +## Decisions + +Taken with the user on 2026-09-24. + +1. **One spec, one branch**, view and edit together. +2. **Views: Month (default) and Agenda.** Week is backlogged; every view reads + the same list of expanded occurrences, so Week later is a new painter and + not a rework. +3. **Writes are atomic, stale-checked, synced soon after and verified** + (§ Writing). +4. **Events organised by someone else are read-only.** Replying to them is + 207. Events with no `ORGANIZER`, or organised by one of the user's own + account addresses, are editable. Delete follows the same rule. +5. **Recurring edits offer "This occurrence" and "All".** "This and following" + is backlogged. +6. **Editing a meeting the user organised is allowed**, bumps `SEQUENCE`, and + says the attendees have not been notified. The attendee list is read-only + until 207. +7. **Alarms are preserved and shown, never fired, never edited.** +8. **Layout:** a toolbar, the view, and a right-hand side pane for the + selected event. +9. **Collections are single-select** with "All calendars", like the account + combo, and the combo carries each collection's colour as a legend. +10. **Names and colours come from the vdir**, not from qtmaildir's config and + not from khal's. +11. **Editing happens in the side pane**, which becomes the form. +12. **Mid-edit, navigation stays free and selection is locked**; closing with + unsaved changes asks Save / Discard / Cancel. +13. **New and double-click on an empty day** open the form for that day in the + default calendar. +14. **Create, edit and delete are undoable**, with no confirmation on delete. +15. **Architecture A:** everything on the UI thread, pure units, libical + confined to one file. + +## Architecture + +``` +CalendarWindow (QMainWindow, single instance, opened from MainWindow) + ├ toolbar: collection combo · ‹ Today › · month combo · year spin · Month|Agenda · + New + ├ QStackedWidget: MonthView | AgendaView ← read QList<Occurrence> only + └ EventPane: read-only details ⇄ edit form + +CalendarStore namespace; with icalraii.h, the only code including libical +CalendarWriter namespace; atomic write, stale check, post-sync comparison +CalendarSync QObject owning the QProcess for calendar_sync_command +MonthLayout pure geometry for the month grid, no painter +RepeatRule struct + RRULE <-> control conversion +``` + +**The libical rule is notmuch's rule.** C handles are owned by RAII aliases in +`src/icalraii.h`, following `nmraii.h`, and no libical pointer leaves +`calendarstore.cpp`. Everything the rest of the code sees is a plain value +struct in `src/caltypes.h`. + +### Value structs + +- `CalCollection { QString dir; QString displayName; QColor color; bool readOnly; }`. + `displayName` from the vdir's `displayname` file, falling back to the + directory name; `color` from its `color` file (`#RRGGBB`), falling back to a + colour hashed from the directory name, so a collection is never invisible. + `readOnly` when the directory is not writable. +- `CalEvent { QString filePath; QString uid; QString collectionDir; QString + summary, location, description; QDateTime start, end; bool allDay; + RepeatRule repeat; bool hasAlarm; CalPerson organizer; QList<CalPerson> + attendees; int sequence; QByteArray rawText; QList<CalOverride> overrides; + QList<QDateTime> exdates; }`. `rawText` is the file exactly as read: the + stale check compares against it and undo writes it back. +- `CalPerson { QString name; QString address; QString partstat; }`. +- `Occurrence { int eventIndex; QDateTime start, end; bool isOverride; }`. + This is the whole of what a view sees. + +### `CalendarStore` + +- `load(dir) -> LoadResult { collections, events, unparsable, unknownZones }`. + A file that does not parse is skipped and counted; a `TZID` that cannot be + resolved is treated as local time and counted. The window reports both + counts in its status bar. Bad data never takes the window down. +- `occurrences(events, from, to) -> QList<Occurrence>`, sorted by start. +- `applyEdit(rawText, EventEdit, Scope, occurrenceStart) -> QByteArray`. + Edits the PARSED file, never rebuilds it: every property the form does not + own (`VALARM`, `ATTENDEE`, `X-*`, `WKST`, a custom `RRULE`, unknown + components) is written back unchanged. This is the discipline `TagRule::unknown` + applies to the rules file, for the same reason. +- `newEvent(EventEdit, zone) -> QByteArray`: a fresh `UID` (`QUuid`), + `DTSTAMP`, `SEQUENCE:0`, `PRODID:-//qtmaildir//EN`, and the zone's + `VTIMEZONE` embedded from libical's built-in database. +- `isEditable(event, collection, ownAddresses) -> bool`: the collection is not + read-only AND (`ORGANIZER` is absent OR its address, `mailto:` stripped and + compared case-insensitively, is one of the configured account addresses). + +## Time and recurrence + +- **Resolving a `TZID`**, in order: the file's embedded `VTIMEZONE`; then the + name as an IANA zone through `QTimeZone`. Anything else, such as a + Windows-style name, is local time and counted. +- **UTC** converts directly. **Floating** is local wall-clock time. +- **Expansion runs in the event's own zone, and converts to local after.** A + weekly 10:00 `Europe/Rome` event stays 10:00 across a DST change; expanding + in UTC would move it by an hour for half the year. +- **All-day `DTEND;VALUE=DATE` is exclusive.** A one-day event on the 24th + ends on the 25th. +- **Recurrence** uses `icalrecur_iterator` started at the visible window, so an + infinite series yields only what is on screen. `EXDATE`s are removed. A + `RECURRENCE-ID` override replaces its original slot and is placed by its OWN + `DTSTART`, which may be inside or outside the window independently of the + slot it replaces. +- **New events** are written in the system zone with the `VTIMEZONE` embedded. + An edited event keeps the zone it had. + +### The repeat control + +`RepeatRule` round-trips this set and nothing more: + +| Mode | Control | `RRULE` | +|---|---|---| +| None | | no `RRULE` | +| Daily | every N days | `FREQ=DAILY;INTERVAL=N` | +| Weekly | every N weeks on weekday checkboxes | `FREQ=WEEKLY;INTERVAL=N;BYDAY=MO,WE,FR` | +| Monthly | every N months on day D | `FREQ=MONTHLY;INTERVAL=N;BYMONTHDAY=D` | +| Monthly | every N months on the first / second / third / fourth / last weekday | `FREQ=MONTHLY;INTERVAL=N;BYDAY=-1FR` | +| Yearly | every N years on the start date | `FREQ=YEARLY;INTERVAL=N` | +| Yearly | every N years on the Nth weekday of a month | `FREQ=YEARLY;INTERVAL=N;BYMONTH=9;BYDAY=-1FR` | +| Ends | never / on a date / after N times | none / `UNTIL` / `COUNT` | + +`UNTIL` follows `DTSTART`'s type: a `DATE` for all-day events, UTC when +`DTSTART` carries a `TZID` (RFC 5545). `WKST` is the one part outside the +table that is carried through untouched on a rewrite, since several existing +rules carry it; any OTHER part makes the rule custom. A rule using anything +outside the table (`BYSETPOS`, `BYWEEKNO`, +`BYHOUR`, several months, `FREQ=HOURLY` and finer) is **"Custom rule, kept as +is"**, shown in words where possible, and an edit never rewrites its `RRULE` +line. Changing the start date re-proposes the mode's default (day 24, or "the +fourth Thursday"), as Thunderbird does. + +## Writing + +**Save** runs the form through `applyEdit`, then +`CalendarWriter::write(path, expected = rawText, newText)`: + +1. **Stale check.** Re-read the file; if it is not `expected`, refuse. The + event is reloaded, the form stays open holding the user's values, and the + status bar says the event changed on disk and needs checking before saving + again. Nothing is overwritten blind. +2. **Atomic write.** Write `.<name>.tmp` in the same directory and `rename()` + it into place. vdirsyncer lists only `*.ics`, so it never sees the + temporary. +3. Every edit sets `DTSTAMP` and `LAST-MODIFIED` and increments `SEQUENCE`. + +A **new** event is `<uuid>.ics` in `default_calendar` or the collection chosen +in the form. **Delete** runs the same stale check and then unlinks. + +### Recurring scopes + +- **All:** edit the master `VEVENT`. +- **This occurrence:** add, or replace, an override `VEVENT` carrying + `RECURRENCE-ID`: a copy of the master without `RRULE`/`EXDATE`, with the + edit applied. Same file. +- **Delete this occurrence:** add an `EXDATE` to the master and remove any + override for that slot. + +The scope is asked when Edit or Delete is pressed on a recurring event, never +after the edit. + +### Undo + +The calendar window owns a `QUndoStack`, alive as long as the window. Each +command holds `{path, textBefore | none, textAfter | none}`; undo writes +`textBefore` back (or deletes the file when there was none) through the same +writer, **expecting `textAfter` on disk**. If a sync has changed the file +since, undo refuses and says so. Undoing a delete restores the file with its +original `UID`, which the next sync pushes back. This is item 176's rule: an +undo reverses what the write changed and nothing else. + +### Sync + +- `calendar_sync_command` defaults to + `flock -w 60 /tmp/vdirsyncer.lock vdirsyncer sync calendars`. `CalendarSync` + runs it `calendar_sync_delay_ms` (default 2000) after the last write, so a + burst of saves costs one run. An empty command disables syncing. +- The window's status bar reports "Syncing calendars...", then the outcome; a + non-zero exit shows the command's output, as `MailSync` does. +- **Post-sync comparison.** After a run, every file written since the previous + run is re-read and compared on MEANING: `SUMMARY`, start and end, the repeat + rule, and whether the file still exists. Bytes are not compared, because + the server may normalise the text on the round trip. A difference reads "the + server kept a different version of *Title*". +- **The cron line is the user's to change**: prefixing it with the same + `flock -w 60 /tmp/vdirsyncer.lock` makes the two runs exclude each other. + Appending `&& vdirsyncer metasync` there keeps names and colours current; + `sync` does not run metasync. + +### Reloading + +A `QFileSystemWatcher` on each collection directory triggers a reload debounced +by 500 ms. The selection survives a reload by `UID` plus occurrence start. An +event changing under an open form is caught by the stale check at Save. + +## The window + +**Opening.** A `calendar` action in `MainWindow`: View menu and a toolbar +button, icon `x-office-calendar`, no default shortcut. That is AGENTS.md's +five places. A single instance: a second trigger raises it. Geometry, the +Month/Agenda choice and the selected collection persist in `uistate.conf`. + +**Toolbar**, left to right: the collection combo (single-select, "All +calendars" first, each entry with its colour swatch), `‹`, **Today**, `›`, a +month combo, a year spin box, a stretch, the Month | Agenda toggle, **+ New**. + +**Month view.** `MonthLayout` computes every rect, as `CardLayout` does for a +card, and `MonthView` only paints it: + +- a 6×7 grid starting on the locale's first weekday; days outside the month + dimmed; today's number on a pill; +- all-day events as a bar filled with the collection colour; timed events as + a colour dot and `10:00 Title`; a multi-day event gets a chip on every day + it covers; +- a day with more chips than fit ends in "+N more", which switches to Agenda + at that day; +- clicking a chip selects it; double-clicking an empty day opens New for that + day. + +**Agenda view.** A list grouped by day over the same range the month combo and +year spin select, scrolled to the selected day or today. + +**Side pane, read-only state.** Title; the time, with the repeat rule in words +("Monthly, on the last Friday"); the collection swatch and name; location; +notes; the bell when an alarm exists; the organiser; attendees with their +reply. Notices for the two read-only reasons ("Organised by *X*: read-only", +"Read-only calendar"). Edit and Delete are ABSENT, not disabled, when not +allowed. + +**Side pane, editing state.** Title, calendar, all-day, starts, ends, repeat, +location, notes; the reminder and attendees shown read-only; "Attendees have +not been notified" when the event has attendees. Save and Cancel. While the +form is open the views still navigate, but selecting another event is refused +with a status-bar hint. Leaving the window with unsaved changes asks Save / +Discard / Cancel on EVERY route out: the close button, `close()` and a menu +action. AGENTS.md's `QDialog` trap is the reason each route is asserted. + +**Untrusted text.** `SUMMARY`, `DESCRIPTION`, `LOCATION` and attendee names +come from strangers once invitations arrive. Every one is shown in a label set +to `Qt::PlainText`, `MessageDetailsDialog`'s rule. `URL` and `ATTACH` are not +rendered. + +**Menus and keys**, the composer's pattern (item 161): a menu bar with File, +Edit and View holding every action; `Ctrl+N` New, `Ctrl+E` Edit, `Delete`, +`Ctrl+Z` / `Ctrl+Shift+Z`, `PgUp` / `PgDn` for the month, `Home` for today, +`Esc` to cancel an edit. Window shortcuts, not `KeyMap` entries. + +**i18n.** Every string in `tr()`; the Italian `.ts` refreshed, `lrelease` +reporting 0 unfinished. + +## Config + +All in `[general]`, read without the `general/` prefix per AGENTS.md: + +| Key | Default | Meaning | +|---|---|---| +| `calendars_dir` | `~/.local/share/calendars/` | The vdir root, tilde-expanded like `contacts_dir`. Empty turns the feature off: no action, no error. | +| `default_calendar` | first collection by name | Directory name of the collection new events go to. | +| `calendar_sync_command` | `flock -w 60 /tmp/vdirsyncer.lock vdirsyncer sync calendars` | Run after writes. Empty disables. | +| `calendar_sync_delay_ms` | `2000` | Debounce before the sync runs. | + +Names and colours are not configured: they come from the vdir. + +## Testing + +Only what has a right answer. Fixtures are written by hand with example.org +addresses, never copied from the live vdir. + +- **`test_calendarstore`.** Parsing: UTC, `TZID` with and without an embedded + `VTIMEZONE`, floating, `DATE`; an unknown `TZID` counted; a broken file + skipped and counted. Expansion: a weekly 10:00 `Europe/Rome` event across + 2026-10-25 stays at 10:00 local; an all-day event's end is exclusive; + `EXDATE`; an override moving into and out of the window; an infinite series + yields only the window; `COUNT` and `UNTIL`; monthly `BYDAY=-1FR`; yearly + `BYMONTH`+`BYDAY`. `RepeatRule` round-trips every row of the table, detects + a custom rule, keeps `WKST`. `applyEdit` keeps `VALARM`, `ATTENDEE` and + `X-*` byte for byte, increments `SEQUENCE`, creates an override, adds an + `EXDATE`. `newEvent` embeds its `VTIMEZONE`. `isEditable` for an organiser + who is the user, someone else, nobody, and for a read-only collection. +- **`test_calendarwriter`**, in a temporary directory: the atomic write; the + stale refusal; delete; undo refusing a changed file; the post-sync + comparison passing normalised text and flagging a moved start. +- **`test_monthlayout`.** The 6×7 geometry, the first weekday, "+N more". +- **`test_calendarwindow`**, minimal: the edit lock refuses a selection change; + every route out with unsaved changes asks. +- **A read-only run against the live vdir** before hand-off: load all 312, + report unparsable and unresolved-zone counts. No test ever writes to the + live directory. + +Everything visual is the user's hand test. + +## Out of scope, and where it goes + +- **Invitations** (accept, decline, send, apply an organiser's update): 207. +- **Week view**: new backlog item. The occurrence list is the seam. +- **"This and following"** for recurring edits: new backlog item. It splits a + series into two files, divides `EXDATE`s and overrides between them, shifts + moved `RECURRENCE-ID`s when the time changes, and converts `COUNT`. +- **Alarm notifications**: declined by the user; not planned. +- **`VTODO`**: none exist in the data. +- **Editing the attendee list**: 207, since adding someone is inviting them. + +## Delivery + +Branch `calendar`, merged to master after the user's hand test. CMake gains +`pkg_check_modules(LIBICAL REQUIRED IMPORTED_TARGET libical)`. AGENTS.md gains +the calendar's traps: zone-local expansion, the exclusive all-day end, `TZID` +without `VTIMEZONE`, and the libical RAII rule. The backlog gains the two new +items, 206's row points here, and 205's row records that its write-during-sync +question is answered by § Writing. |
