aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-09-24-calendar-design.md
blob: f58f3727c09ea93db6253e93839eb6421c23ea94 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
# A calendar window: view, add, edit and delete events

Date: 2026-09-24
Status: approved, implemented on branch calendar
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.** `QSaveFile` writes a temporary in the same directory and
   `rename()`s it into place on `commit()`, so vdirsyncer, which lists only
   `*.ics`, never sees a partial file.
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.

**Those that are not safe as window shortcuts are scoped to the views with
`Qt::WidgetWithChildrenShortcut`.** A bare `Delete`, `Ctrl+Z`, `PgUp`/`PgDn` or
`Home` would otherwise fire while the cursor is in the edit form's Title field,
deleting the event or moving the month instead of editing text. `Delete`,
`PgUp`/`PgDn` and `Home` sit on the stacked views; Undo and Redo sit there too;
Escape is a window shortcut: it closes the details pane (the pane is hidden
while nothing is selected) and cancels an edit only when focus is inside
`EventPane`. `Ctrl+N`, `Ctrl+E` and `Ctrl+W` stay window
shortcuts, since no text field wants them.

**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` | unset (off) | The vdir root, tilde-expanded like `contacts_dir`. Empty turns the feature off; the menu and toolbar entry remains and shows a hint naming the key (no window opens). |
| `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-*` value for value, 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.