aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-09-24-calendar.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/plans/2026-09-24-calendar.md')
-rw-r--r--docs/superpowers/plans/2026-09-24-calendar.md5468
1 files changed, 5468 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-09-24-calendar.md b/docs/superpowers/plans/2026-09-24-calendar.md
new file mode 100644
index 0000000..2182e30
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-24-calendar.md
@@ -0,0 +1,5468 @@
+# Calendar Window Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A top-level calendar window over the user's vdirsyncer vdir: Month and Agenda views, a side pane that shows and edits the selected event, atomic stale-checked writes with a debounced sync, and undo.
+
+**Architecture:** Everything runs on the UI thread (the whole vdir parses and expands in about 5 ms). libical is confined to `calendarstore.cpp` behind unique_ptr RAII aliases; every other unit sees plain value structs. `RepeatRule`, `CalendarWriter` and `MonthLayout` are pure and libical-free, so the parts with a right answer are tested without a widget.
+
+**Tech Stack:** C++17, Qt 6.11 Widgets, libical 3.0.20 (via pkg-config), Qt Test.
+
+**Spec:** `docs/superpowers/specs/2026-09-24-calendar-design.md`. Read it first; this plan does not repeat its reasoning.
+
+---
+
+## Before starting
+
+- Work on a branch: `git switch -c calendar`. Merge to master only after the user's hand test (AGENTS.md: substantial UI work is branched so master stays shippable).
+- Build and test per AGENTS.md: `cmake --build build && ctest --test-dir build --output-on-failure`. A single binary is ALWAYS run as `QT_QPA_PLATFORM=offscreen ./build/tests/test_<name>`. Never launch `./build/src/qtmaildir`.
+- Commits are GPG-signed (`git commit -S`, which `commit.gpgsign=true` already does). Every new source file carries the GPLv2 header used by `src/contactstore.h` lines 1-17.
+- **Every code block here is a draft.** AGENTS.md records many defects found in plan code written confidently. Verify each libical call against `/usr/include/libical/*.h` before relying on it, and prefer a failing test to a belief.
+- Pre-existing failures, not yours: `test_mainwindow::undoMovesTheMessageBack` fails deterministically on this machine; `aSuccessfulCronSyncDrainsTheEditedAccounts` and `undoingAMarkReadRestoresOnlyWhatWasUnread` are order-dependent flakes that pass alone.
+
+## Rulings against the spec
+
+Taken while planning; the spec is amended in Task 16.
+
+1. **`calendars_dir` absent means OFF**, not a default path. `contacts_dir` (`config.cpp`) set that precedent: an unset key is an ordinary machine, not a misconfiguration. The README documents the usual value.
+2. **"Byte for byte" becomes "value for value".** libical re-serialises the whole component on write, so line folding and property order may change. Tests assert that `VALARM`, `ATTENDEE` and `X-*` properties survive with the same VALUES, which is the property that matters, rather than identical bytes.
+3. **Views read `CalendarItem`**, an `Occurrence` plus the title and colour to draw. Still no libical and no `CalEvent` in a view.
+4. **Expansion does not use `icalrecur_iterator_set_start`.** It is unsupported with `COUNT` rules, and the whole vdir expands from `DTSTART` in milliseconds. A cap of 10 000 iterations per series guards a pathological rule.
+
+## File structure
+
+| File | Responsibility |
+|---|---|
+| `src/icalraii.h` | unique_ptr aliases for libical handles. Included only by `calendarstore.cpp`. |
+| `src/caltypes.h` | Plain value structs: `CalCollection`, `CalPerson`, `CalOverride`, `CalEvent`, `Occurrence`, `CalendarItem`, `EventEdit`, `LoadResult`. |
+| `src/repeatrule.h/.cpp` | `RepeatRule`: the repeat control's model, `RRULE` value text in and out, and a sentence describing it. No libical. |
+| `src/calendarstore.h/.cpp` | The only libical user: load a vdir, parse one file, expand occurrences, apply an edit, build a new event, add an `EXDATE`, compare two texts by meaning, decide editability. |
+| `src/calendarwriter.h/.cpp` | One function: replace a file's content (create, edit, delete) atomically, refusing when the file is not what the caller expects. |
+| `src/calendarsync.h/.cpp` | QObject owning the debounced `QProcess` for `calendar_sync_command`. |
+| `src/monthlayout.h/.cpp` | Pure geometry of the month grid. |
+| `src/monthview.h/.cpp` | Paints a `MonthLayout` with `CalendarItem`s; emits clicks. |
+| `src/agendaview.h/.cpp` | A `QListWidget` grouped by day. |
+| `src/eventpane.h/.cpp` | The side pane: read-only details page and the edit form, including the repeat control. |
+| `src/calendarwindow.h/.cpp` | The top-level window: toolbar, views, pane, reload, undo, sync, the edit lock. |
+| `src/config.h/.cpp` | Four `[general]` keys. |
+| `src/keymap.cpp`, `src/mainwindow.h/.cpp` | The `calendar` action in the five places AGENTS.md lists; single instance. |
+| `tests/test_repeatrule.cpp`, `test_calendarstore.cpp`, `test_calendarwriter.cpp`, `test_calendarsync.cpp`, `test_monthlayout.cpp`, `test_calendarwindow.cpp` | One suite per unit. Fixtures are inline strings with example.org addresses. |
+
+---
+
+### Task 1: Build wiring, RAII aliases, value structs, and parsing one event
+
+**Files:**
+- Modify: `CMakeLists.txt` (after the GMime `pkg_check_modules` line)
+- Modify: `src/CMakeLists.txt`
+- Modify: `tests/CMakeLists.txt`
+- Create: `src/icalraii.h`, `src/caltypes.h`, `src/repeatrule.h`, `src/repeatrule.cpp` (stub for now), `src/calendarstore.h`, `src/calendarstore.cpp`
+- Test: `tests/test_calendarstore.cpp`
+
+- [ ] **Step 1: Wire libical into CMake**
+
+In `CMakeLists.txt`, directly under `pkg_check_modules(GMIME REQUIRED IMPORTED_TARGET gmime-3.0)`:
+
+```cmake
+# libical parses and writes the calendar's iCalendar files (item 206). Unlike
+# notmuch it ships a .pc, so pkg-config finds it; unlike GMime it pulls in no
+# glib, so its headers may sit anywhere in an include list.
+pkg_check_modules(LIBICAL REQUIRED IMPORTED_TARGET libical)
+```
+
+In `src/CMakeLists.txt`, add to the `qtmaildir_lib` source list after `signatures.cpp`:
+
+```cmake
+ repeatrule.cpp
+ calendarstore.cpp
+```
+
+and add `PkgConfig::LIBICAL` to `target_link_libraries(qtmaildir_lib PUBLIC ...)` after `PkgConfig::GMIME`.
+
+In `tests/CMakeLists.txt`, after the `contactstore` block:
+
+```cmake
+add_qtmaildir_test(repeatrule)
+add_qtmaildir_test(calendarstore)
+```
+
+- [ ] **Step 2: Write `src/icalraii.h`**
+
+```cpp
+/* GPLv2 header, as src/contactstore.h lines 1-17 */
+
+#pragma once
+
+#include <libical/ical.h>
+
+#include <cstdlib>
+#include <memory>
+
+/// Owners for libical handles, the calendar's counterpart to nmraii.h.
+///
+/// The same rule as notmuch's: no libical pointer leaves calendarstore.cpp,
+/// the one file that includes this header. unique_ptr rather than NmHandle's
+/// template because every libical handle here has exactly one destroy
+/// function and no aliasing, which is what unique_ptr already expresses.
+struct IcalComponentFree {
+ void operator()(icalcomponent *c) const { icalcomponent_free(c); }
+};
+using IcalComponent = std::unique_ptr<icalcomponent, IcalComponentFree>;
+
+struct IcalRecurFree {
+ void operator()(icalrecur_iterator *i) const { icalrecur_iterator_free(i); }
+};
+using IcalRecurIterator = std::unique_ptr<icalrecur_iterator, IcalRecurFree>;
+
+/// For strings returned by the `*_r` functions, which the caller must free().
+/// The plain variants return a ring buffer libical reuses, so a pointer kept
+/// from one is overwritten by a later call.
+struct IcalStringFree {
+ void operator()(char *s) const { std::free(s); }
+};
+using IcalString = std::unique_ptr<char, IcalStringFree>;
+```
+
+- [ ] **Step 3: Write `src/repeatrule.h` and a stub `src/repeatrule.cpp`**
+
+The full implementation is Task 3; the type must exist now because `CalEvent` holds one.
+
+```cpp
+/* GPLv2 header */
+
+#pragma once
+
+#include <QDate>
+#include <QList>
+#include <QString>
+
+/// The repeat control's model, and its RRULE value text in both directions.
+///
+/// No libical: an RRULE value is `KEY=VALUE;KEY=VALUE`, which is simpler to
+/// read and write as text than through icalrecurrencetype, and keeping it pure
+/// is what lets every row of the spec's repeat table be tested as a string.
+struct RepeatRule
+{
+ enum class Freq { None, Daily, Weekly, Monthly, Yearly };
+ /// Monthly and Yearly only: on a day of the month, or on the Nth weekday.
+ enum class By { MonthDay, Weekday };
+ enum class End { Never, Until, Count };
+
+ Freq freq = Freq::None;
+ int interval = 1;
+ /// Weekly: Qt::DayOfWeek values, 1 (Monday) to 7 (Sunday), sorted.
+ QList<int> weekdays;
+ By by = By::MonthDay;
+ int monthDay = 0; ///< Monthly, By::MonthDay: 1 to 31.
+ int ordinal = 0; ///< By::Weekday: 1 to 4, or -1 for "last".
+ int weekday = 0; ///< By::Weekday: Qt::DayOfWeek.
+ int month = 0; ///< Yearly, By::Weekday: 1 to 12.
+ End end = End::Never;
+ QDate until; ///< End::Until: the last day an occurrence may fall on.
+ int count = 0; ///< End::Count.
+ /// Carried through a rewrite untouched, per the spec: several existing
+ /// rules carry it and it changes nothing for the modes above.
+ QString wkst;
+
+ /// A rule outside the control's table. `customText` is the value exactly
+ /// as read, and an edit never rewrites it.
+ bool custom = false;
+ QString customText;
+
+ /// Parses an RRULE value (no "RRULE:" prefix). `start` is the event's
+ /// first day, which fills in what an RRULE leaves implicit (a weekly rule
+ /// with no BYDAY repeats on the start's weekday).
+ static RepeatRule fromRRule(const QString &value, const QDate &start);
+
+ /// The RRULE value, or an empty string for Freq::None. `untilValue` is
+ /// the whole UNTIL value, `20261231` for an all-day event or
+ /// `20261231T225959Z` for a timed one, computed by the caller because
+ /// only it knows the event's zone. Ignored unless end is End::Until.
+ QString toRRule(const QString &untilValue = QString()) const;
+
+ /// "Monthly, on the last Friday", translated. Custom rules say so.
+ QString describe() const;
+
+ bool operator==(const RepeatRule &other) const;
+};
+```
+
+`src/repeatrule.cpp`, stub:
+
+```cpp
+/* GPLv2 header */
+
+#include "repeatrule.h"
+
+RepeatRule RepeatRule::fromRRule(const QString &, const QDate &) { return {}; }
+QString RepeatRule::toRRule(const QString &) const { return {}; }
+QString RepeatRule::describe() const { return {}; }
+bool RepeatRule::operator==(const RepeatRule &) const { return false; }
+```
+
+Also create an empty `tests/test_repeatrule.cpp` so CMake configures (Task 3 fills it):
+
+```cpp
+/* GPLv2 header */
+
+#include <QtTest>
+
+#include "repeatrule.h"
+
+class TestRepeatRule : public QObject
+{
+ Q_OBJECT
+};
+
+QTEST_MAIN(TestRepeatRule)
+#include "test_repeatrule.moc"
+```
+
+- [ ] **Step 4: Write `src/caltypes.h`**
+
+```cpp
+/* GPLv2 header */
+
+#pragma once
+
+#include "repeatrule.h"
+
+#include <QByteArray>
+#include <QColor>
+#include <QDateTime>
+#include <QList>
+#include <QString>
+
+/// One vdir collection: a subdirectory of calendars_dir.
+struct CalCollection
+{
+ QString dir; ///< The directory's own name, e.g. "52".
+ QString path; ///< Absolute path.
+ /// From the vdir's `displayname` file, which vdirsyncer's metasync fills
+ /// from the server; the directory name when absent.
+ QString displayName;
+ /// From the vdir's `color` file (`#RRGGBB`); a colour hashed from the
+ /// directory name when absent, so no collection is ever invisible.
+ QColor color;
+ bool readOnly = false; ///< The directory is not writable.
+};
+
+struct CalPerson
+{
+ QString name; ///< CN, possibly empty.
+ QString address; ///< The address with "mailto:" removed.
+ QString partstat; ///< PARTSTAT as written, e.g. "ACCEPTED"; empty if absent.
+};
+
+/// A RECURRENCE-ID override: one occurrence of a series moved or changed.
+struct CalOverride
+{
+ QDateTime recurrenceId; ///< The original slot it replaces.
+ QDateTime start;
+ QDateTime end;
+ bool allDay = false;
+ QString summary;
+ QString location;
+ QString description;
+ bool cancelled = false; ///< STATUS:CANCELLED: the slot is removed.
+};
+
+/// One .ics file's event, as the rest of the application sees it.
+struct CalEvent
+{
+ QString filePath;
+ QString collectionDir;
+ QString uid;
+ QString summary;
+ QString location;
+ QString description;
+ QDateTime start;
+ QDateTime end; ///< Exclusive. For an all-day event, midnight after the last day.
+ bool allDay = false;
+ RepeatRule repeat; ///< Freq::None when the event does not repeat.
+ QList<QDateTime> exdates;
+ QList<CalOverride> overrides;
+ bool hasAlarm = false;
+ CalPerson organizer; ///< Empty address: no ORGANIZER.
+ QList<CalPerson> attendees;
+ int sequence = 0;
+ QByteArray rawText; ///< The file exactly as read: stale checks and undo.
+};
+
+/// One expanded occurrence. What a view is built from.
+struct Occurrence
+{
+ int eventIndex = -1; ///< Into LoadResult::events.
+ QDateTime start;
+ QDateTime end;
+ bool allDay = false;
+ /// The slot this occurrence fills in its series, which is what a
+ /// RECURRENCE-ID or an EXDATE names. Invalid for a non-repeating event.
+ QDateTime recurrenceId;
+ bool isOverride = false;
+ int overrideIndex = -1; ///< Into CalEvent::overrides when isOverride.
+};
+
+/// An occurrence plus what a view draws. Views see nothing else.
+struct CalendarItem
+{
+ Occurrence occurrence;
+ QString title;
+ QColor color;
+};
+
+/// What the edit form produces.
+struct EventEdit
+{
+ QString summary;
+ QString location;
+ QString description;
+ QDateTime start;
+ QDateTime end;
+ bool allDay = false;
+ RepeatRule repeat;
+ QString collectionDir;
+};
+
+struct LoadResult
+{
+ QList<CalCollection> collections;
+ QList<CalEvent> events;
+ int unparsable = 0; ///< Files skipped because they did not parse.
+ int unknownZones = 0; ///< Events whose TZID resolved to nothing, shown in local time.
+};
+```
+
+- [ ] **Step 5: Write the failing test**
+
+`tests/test_calendarstore.cpp`:
+
+```cpp
+/* GPLv2 header */
+
+#include <QtTest>
+#include <QDir>
+#include <QFile>
+#include <QTemporaryDir>
+#include <QTimeZone>
+
+#include "calendarstore.h"
+
+namespace {
+
+/// Wraps VEVENT lines (and optional extra components) into a VCALENDAR.
+QByteArray ics(const QString &body)
+{
+ return QStringLiteral("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n"
+ "PRODID:-//example//test//EN\r\n%1END:VCALENDAR\r\n")
+ .arg(body).toUtf8();
+}
+
+QString vevent(const QString &lines)
+{
+ return QStringLiteral("BEGIN:VEVENT\r\n%1END:VEVENT\r\n").arg(lines);
+}
+
+} // namespace
+
+class TestCalendarStore : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void parsesAUtcEvent();
+};
+
+void TestCalendarStore::parsesAUtcEvent()
+{
+ bool unknownZone = true;
+ const CalEvent event = CalendarStore::parseEvent(
+ ics(vevent(QStringLiteral(
+ "UID:utc-1@example.org\r\nDTSTAMP:20260901T000000Z\r\n"
+ "SUMMARY:Standup\r\nLOCATION:Room 2\r\n"
+ "DTSTART:20260922T080000Z\r\nDTEND:20260922T081500Z\r\n"
+ "SEQUENCE:3\r\n"))),
+ QStringLiteral("/tmp/x/utc-1.ics"), QStringLiteral("x"), &unknownZone);
+
+ QCOMPARE(event.uid, QStringLiteral("utc-1@example.org"));
+ QCOMPARE(event.summary, QStringLiteral("Standup"));
+ QCOMPARE(event.location, QStringLiteral("Room 2"));
+ QCOMPARE(event.start, QDateTime(QDate(2026, 9, 22), QTime(8, 0), QTimeZone::utc()));
+ QCOMPARE(event.end, QDateTime(QDate(2026, 9, 22), QTime(8, 15), QTimeZone::utc()));
+ QVERIFY(!event.allDay);
+ QCOMPARE(event.sequence, 3);
+ QCOMPARE(event.filePath, QStringLiteral("/tmp/x/utc-1.ics"));
+ QVERIFY(!unknownZone);
+ QVERIFY(!event.rawText.isEmpty());
+}
+
+QTEST_MAIN(TestCalendarStore)
+#include "test_calendarstore.moc"
+```
+
+- [ ] **Step 6: Write `src/calendarstore.h`**
+
+```cpp
+/* GPLv2 header */
+
+#pragma once
+
+#include "caltypes.h"
+
+#include <QByteArray>
+#include <QDateTime>
+#include <QList>
+#include <QString>
+#include <QStringList>
+
+/// The calendar's iCalendar work, and the only code that includes libical.
+///
+/// A namespace of free functions over values, like ContactStore and
+/// MimeParser, so every rule with a right answer is testable without a
+/// widget. See the spec, docs/superpowers/specs/2026-09-24-calendar-design.md.
+namespace CalendarStore {
+
+enum class Scope { All, ThisOccurrence };
+
+/// Parses one file's text. An empty uid in the result means the text held no
+/// usable VEVENT. `unknownZone` is set when a TZID resolved to nothing and the
+/// times were read as local.
+CalEvent parseEvent(const QByteArray &text, const QString &filePath,
+ const QString &collectionDir, bool *unknownZone);
+
+/// Reads every collection under `dir` and every *.ics in each.
+LoadResult load(const QString &dir);
+
+/// Every occurrence of every event overlapping [from, to), sorted by start.
+QList<Occurrence> occurrences(const QList<CalEvent> &events,
+ const QDateTime &from, const QDateTime &to);
+
+/// Whether the user may edit or delete `event`: its collection is writable,
+/// and it has no ORGANIZER or the organiser is one of `ownAddresses`.
+bool isEditable(const CalEvent &event, const CalCollection &collection,
+ const QStringList &ownAddresses);
+
+/// Applies `edit` to the file text and returns the new text; empty when the
+/// text does not parse. Scope::ThisOccurrence writes or replaces the override
+/// for `recurrenceId`. Properties the form does not own survive.
+QByteArray applyEdit(const QByteArray &text, const EventEdit &edit,
+ Scope scope, const QDateTime &recurrenceId);
+
+/// A complete new file for `edit`, timed events in the IANA zone `zoneId`.
+QByteArray newEvent(const EventEdit &edit, const QByteArray &zoneId);
+
+/// Removes one occurrence: adds an EXDATE to the master and drops any override
+/// for that slot. Empty on a parse failure.
+QByteArray deleteOccurrence(const QByteArray &text, const QDateTime &recurrenceId);
+
+/// Whether two texts describe the same event: summary, times, repeat rule,
+/// exceptions. Formatting is ignored, since the server may normalise it.
+bool sameMeaning(const QByteArray &a, const QByteArray &b);
+
+} // namespace CalendarStore
+```
+
+- [ ] **Step 7: Run the test to verify it fails**
+
+Run: `cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug && cmake --build build 2>&1 | tail -5`
+Expected: link failure, undefined reference to `CalendarStore::parseEvent`.
+
+- [ ] **Step 8: Write the parsing half of `src/calendarstore.cpp`**
+
+Only `parseEvent` and the time helper for now, with the remaining functions as stubs so the library links. Later tasks replace each stub.
+
+```cpp
+/* GPLv2 header */
+
+#include "calendarstore.h"
+#include "icalraii.h"
+
+#include <QTimeZone>
+
+namespace {
+
+QString str(const char *s) { return s ? QString::fromUtf8(s) : QString(); }
+
+QByteArray tzidOf(icalproperty *prop)
+{
+ if (!prop)
+ return {};
+ icalparameter *param = icalproperty_get_first_parameter(prop, ICAL_TZID_PARAMETER);
+ return param ? QByteArray(icalparameter_get_tzid(param)) : QByteArray();
+}
+
+/// One icaltimetype to a QDateTime, in this order (spec, "Time and
+/// recurrence"): a date is local midnight; UTC is UTC; a zone libical resolved
+/// (an embedded VTIMEZONE) converts through libical; a TZID libical could not
+/// resolve is tried as an IANA name through QTimeZone; anything else is local
+/// wall-clock time, and an unresolvable TZID sets *unknownZone.
+QDateTime toDateTime(icaltimetype t, const QByteArray &tzid, bool *unknownZone)
+{
+ if (icaltime_is_null_time(t))
+ return {};
+ const QDate date(t.year, t.month, t.day);
+ if (t.is_date)
+ return QDateTime(date, QTime(0, 0));
+ const QTime time(t.hour, t.minute, t.second);
+ if (icaltime_is_utc(t))
+ return QDateTime(date, time, QTimeZone::utc());
+ if (t.zone) {
+ const icaltimetype utc =
+ icaltime_convert_to_zone(t, icaltimezone_get_utc_timezone());
+ return QDateTime(QDate(utc.year, utc.month, utc.day),
+ QTime(utc.hour, utc.minute, utc.second), QTimeZone::utc());
+ }
+ if (!tzid.isEmpty()) {
+ const QTimeZone zone(tzid);
+ if (zone.isValid())
+ return QDateTime(date, time, zone);
+ if (unknownZone)
+ *unknownZone = true;
+ }
+ return QDateTime(date, time);
+}
+
+CalPerson personOf(icalproperty *prop)
+{
+ CalPerson person;
+ QString value = str(icalproperty_get_value_as_string(prop));
+ if (value.startsWith(QLatin1String("mailto:"), Qt::CaseInsensitive))
+ value = value.mid(7);
+ person.address = value;
+ person.name = str(icalproperty_get_parameter_as_string(prop, "CN"));
+ person.partstat = str(icalproperty_get_parameter_as_string(prop, "PARTSTAT"));
+ return person;
+}
+
+/// The VEVENT without a RECURRENCE-ID, or the first VEVENT if every one has.
+icalcomponent *masterOf(icalcomponent *root)
+{
+ icalcomponent *first = nullptr;
+ for (icalcomponent *c = icalcomponent_get_first_component(root, ICAL_VEVENT_COMPONENT);
+ c; c = icalcomponent_get_next_component(root, ICAL_VEVENT_COMPONENT)) {
+ if (!first)
+ first = c;
+ if (!icalcomponent_get_first_property(c, ICAL_RECURRENCEID_PROPERTY))
+ return c;
+ }
+ return first;
+}
+
+IcalComponent parseRoot(const QByteArray &text)
+{
+ IcalComponent root(icalparser_parse_string(text.constData()));
+ if (root && icalcomponent_isa(root.get()) == ICAL_VEVENT_COMPONENT) {
+ // A bare VEVENT with no VCALENDAR around it: wrap it, so every
+ // caller walks the same shape.
+ IcalComponent cal(icalcomponent_new(ICAL_VCALENDAR_COMPONENT));
+ icalcomponent_add_component(cal.get(), root.release());
+ return cal;
+ }
+ return root;
+}
+
+/// The end of a component: DTEND, else DTSTART + DURATION (libical computes
+/// both through get_dtend), else one day for a date and the start otherwise.
+QDateTime endOf(icalcomponent *c, const QDateTime &start, bool allDay, bool *unknownZone)
+{
+ icalproperty *endProp = icalcomponent_get_first_property(c, ICAL_DTEND_PROPERTY);
+ const icaltimetype end = icalcomponent_get_dtend(c);
+ if (!icaltime_is_null_time(end))
+ return toDateTime(end, tzidOf(endProp ? endProp
+ : icalcomponent_get_first_property(c, ICAL_DTSTART_PROPERTY)),
+ unknownZone);
+ return allDay ? start.addDays(1) : start;
+}
+
+} // namespace
+
+namespace CalendarStore {
+
+CalEvent parseEvent(const QByteArray &text, const QString &filePath,
+ const QString &collectionDir, bool *unknownZone)
+{
+ CalEvent event;
+ event.filePath = filePath;
+ event.collectionDir = collectionDir;
+ event.rawText = text;
+ if (unknownZone)
+ *unknownZone = false;
+
+ IcalComponent root = parseRoot(text);
+ if (!root)
+ return event;
+ icalcomponent *master = masterOf(root.get());
+ if (!master)
+ return event;
+
+ event.uid = str(icalcomponent_get_uid(master));
+ event.summary = str(icalcomponent_get_summary(master));
+ event.location = str(icalcomponent_get_location(master));
+ event.description = str(icalcomponent_get_description(master));
+ event.sequence = icalcomponent_get_sequence(master);
+
+ icalproperty *startProp = icalcomponent_get_first_property(master, ICAL_DTSTART_PROPERTY);
+ const icaltimetype start = icalcomponent_get_dtstart(master);
+ event.allDay = start.is_date;
+ event.start = toDateTime(start, tzidOf(startProp), unknownZone);
+ event.end = endOf(master, event.start, event.allDay, unknownZone);
+
+ event.hasAlarm = icalcomponent_get_first_component(master, ICAL_VALARM_COMPONENT);
+ if (icalproperty *org = icalcomponent_get_first_property(master, ICAL_ORGANIZER_PROPERTY))
+ event.organizer = personOf(org);
+ for (icalproperty *a = icalcomponent_get_first_property(master, ICAL_ATTENDEE_PROPERTY);
+ a; a = icalcomponent_get_next_property(master, ICAL_ATTENDEE_PROPERTY))
+ event.attendees.append(personOf(a));
+
+ // Recurrence, exceptions and overrides: Task 4.
+ return event;
+}
+
+LoadResult load(const QString &) { return {}; }
+QList<Occurrence> occurrences(const QList<CalEvent> &, const QDateTime &, const QDateTime &) { return {}; }
+bool isEditable(const CalEvent &, const CalCollection &, const QStringList &) { return false; }
+QByteArray applyEdit(const QByteArray &, const EventEdit &, Scope, const QDateTime &) { return {}; }
+QByteArray newEvent(const EventEdit &, const QByteArray &) { return {}; }
+QByteArray deleteOccurrence(const QByteArray &, const QDateTime &) { return {}; }
+bool sameMeaning(const QByteArray &, const QByteArray &) { return false; }
+
+} // namespace CalendarStore
+```
+
+- [ ] **Step 9: Run the test to verify it passes**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_calendarstore`
+Expected: `Totals: 3 passed, 0 failed` (initTestCase, parsesAUtcEvent, cleanupTestCase).
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add CMakeLists.txt src/CMakeLists.txt tests/CMakeLists.txt src/icalraii.h src/caltypes.h \
+ src/repeatrule.h src/repeatrule.cpp src/calendarstore.h src/calendarstore.cpp \
+ tests/test_calendarstore.cpp tests/test_repeatrule.cpp
+git commit -m "feat: parse one calendar event with libical (item 206)"
+```
+
+---
+
+### Task 2: Every time form, and loading a vdir
+
+**Files:**
+- Modify: `src/calendarstore.cpp` (`load`)
+- Test: `tests/test_calendarstore.cpp`
+
+- [ ] **Step 1: Write the failing tests**
+
+Add to the `private slots:` list and the file:
+
+```cpp
+ void readsATzidWithItsVtimezone();
+ void readsATzidWithoutAVtimezoneAsIana();
+ void readsAnUnknownTzidAsLocalAndSaysSo();
+ void readsFloatingTimeAsLocal();
+ void readsAnAllDayEventWithAnExclusiveEnd();
+ void loadReadsCollectionsAndFallsBack();
+ void loadSkipsAndCountsABrokenFile();
+```
+
+```cpp
+namespace {
+
+const char *kRomeVtimezone =
+ "BEGIN:VTIMEZONE\r\nTZID:Europe/Rome\r\n"
+ "BEGIN:DAYLIGHT\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200\r\nTZNAME:CEST\r\n"
+ "DTSTART:19700329T020000\r\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nEND:DAYLIGHT\r\n"
+ "BEGIN:STANDARD\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nTZNAME:CET\r\n"
+ "DTSTART:19701025T030000\r\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\r\nEND:STANDARD\r\n"
+ "END:VTIMEZONE\r\n";
+
+CalEvent parse(const QString &body, bool *unknown = nullptr)
+{
+ bool ignored = false;
+ return CalendarStore::parseEvent(ics(body), QStringLiteral("/x/e.ics"),
+ QStringLiteral("x"), unknown ? unknown : &ignored);
+}
+
+void writeFile(const QString &path, const QByteArray &content)
+{
+ QDir().mkpath(QFileInfo(path).absolutePath());
+ QFile file(path);
+ QVERIFY2(file.open(QIODevice::WriteOnly | QIODevice::Truncate),
+ qPrintable(file.errorString()));
+ file.write(content);
+}
+
+} // namespace
+
+void TestCalendarStore::readsATzidWithItsVtimezone()
+{
+ const CalEvent e = parse(QString::fromLatin1(kRomeVtimezone) + vevent(QStringLiteral(
+ "UID:a@example.org\r\nDTSTART;TZID=Europe/Rome:20260922T100000\r\n"
+ "DTEND;TZID=Europe/Rome:20260922T110000\r\n")));
+ // 10:00 in Rome in September is 08:00 UTC (CEST, +2).
+ QCOMPARE(e.start.toUTC(), QDateTime(QDate(2026, 9, 22), QTime(8, 0), QTimeZone::utc()));
+ QCOMPARE(e.end.toUTC(), QDateTime(QDate(2026, 9, 22), QTime(9, 0), QTimeZone::utc()));
+}
+
+void TestCalendarStore::readsATzidWithoutAVtimezoneAsIana()
+{
+ // 120 of the user's 312 files are shaped like this: RFC 5545 requires the
+ // VTIMEZONE, and real servers omit it.
+ bool unknown = true;
+ const CalEvent e = parse(vevent(QStringLiteral(
+ "UID:b@example.org\r\nDTSTART;TZID=Europe/Rome:20260922T100000\r\n"
+ "DTEND;TZID=Europe/Rome:20260922T110000\r\n")), &unknown);
+ QCOMPARE(e.start.toUTC(), QDateTime(QDate(2026, 9, 22), QTime(8, 0), QTimeZone::utc()));
+ QVERIFY(!unknown);
+}
+
+void TestCalendarStore::readsAnUnknownTzidAsLocalAndSaysSo()
+{
+ bool unknown = false;
+ const CalEvent e = parse(vevent(QStringLiteral(
+ "UID:c@example.org\r\nDTSTART;TZID=Not A Zone:20260922T100000\r\n"
+ "DTEND;TZID=Not A Zone:20260922T110000\r\n")), &unknown);
+ QVERIFY(unknown);
+ QCOMPARE(e.start, QDateTime(QDate(2026, 9, 22), QTime(10, 0)));
+}
+
+void TestCalendarStore::readsFloatingTimeAsLocal()
+{
+ const CalEvent e = parse(vevent(QStringLiteral(
+ "UID:d@example.org\r\nDTSTART:20260922T100000\r\nDTEND:20260922T110000\r\n")));
+ QCOMPARE(e.start, QDateTime(QDate(2026, 9, 22), QTime(10, 0)));
+ QCOMPARE(e.start.timeSpec(), Qt::LocalTime);
+}
+
+void TestCalendarStore::readsAnAllDayEventWithAnExclusiveEnd()
+{
+ const CalEvent e = parse(vevent(QStringLiteral(
+ "UID:e@example.org\r\nDTSTART;VALUE=DATE:20260924\r\nDTEND;VALUE=DATE:20260925\r\n")));
+ QVERIFY(e.allDay);
+ QCOMPARE(e.start.date(), QDate(2026, 9, 24));
+ // Exclusive: a one-day event on the 24th ends at the START of the 25th.
+ QCOMPARE(e.end.date(), QDate(2026, 9, 25));
+ QCOMPARE(e.end.time(), QTime(0, 0));
+}
+
+void TestCalendarStore::loadReadsCollectionsAndFallsBack()
+{
+ QTemporaryDir dir;
+ const QString root = dir.path();
+ writeFile(root + QStringLiteral("/52/displayname"), "Work");
+ writeFile(root + QStringLiteral("/52/color"), "#60a5fa\n");
+ writeFile(root + QStringLiteral("/52/one.ics"), ics(vevent(QStringLiteral(
+ "UID:one@example.org\r\nDTSTART:20260922T080000Z\r\n"))));
+ writeFile(root + QStringLiteral("/31/two.ics"), ics(vevent(QStringLiteral(
+ "UID:two@example.org\r\nDTSTART:20260923T080000Z\r\n"))));
+ writeFile(root + QStringLiteral("/31/notes.txt"), "not a calendar file");
+
+ const LoadResult r = CalendarStore::load(root);
+ QCOMPARE(r.collections.size(), 2);
+ QCOMPARE(r.events.size(), 2);
+ QCOMPARE(r.unparsable, 0);
+
+ // Sorted by display name, so "31" (no displayname) sorts before "Work".
+ QCOMPARE(r.collections[0].dir, QStringLiteral("31"));
+ QCOMPARE(r.collections[0].displayName, QStringLiteral("31"));
+ QVERIFY(r.collections[0].color.isValid());
+ QCOMPARE(r.collections[1].displayName, QStringLiteral("Work"));
+ QCOMPARE(r.collections[1].color, QColor(QStringLiteral("#60a5fa")));
+ QVERIFY(!r.collections[1].readOnly);
+
+ // The hashed fallback is stable across loads, or colours would shuffle.
+ QCOMPARE(CalendarStore::load(root).collections[0].color, r.collections[0].color);
+}
+
+void TestCalendarStore::loadSkipsAndCountsABrokenFile()
+{
+ QTemporaryDir dir;
+ writeFile(dir.path() + QStringLiteral("/c/good.ics"), ics(vevent(QStringLiteral(
+ "UID:good@example.org\r\nDTSTART:20260922T080000Z\r\n"))));
+ writeFile(dir.path() + QStringLiteral("/c/bad.ics"), "this is not iCalendar");
+ const LoadResult r = CalendarStore::load(dir.path());
+ QCOMPARE(r.events.size(), 1);
+ QCOMPARE(r.unparsable, 1);
+}
+```
+
+Add `#include <QFileInfo>` to the test's includes.
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_calendarstore`
+Expected: the five parse tests PASS already (Task 1's `toDateTime` covers them; that is fine, they pin the behaviour), and the two `load*` tests FAIL (`load` is a stub returning nothing). If any parse test fails, fix `toDateTime` before going on.
+
+- [ ] **Step 3: Implement `load`**
+
+Replace the `load` stub. Add includes `<QCryptographicHash>`, `<QDir>`, `<QFile>`, `<QFileInfo>` and `<algorithm>` at the top of `calendarstore.cpp`.
+
+```cpp
+namespace {
+
+QString readTrimmed(const QString &path)
+{
+ QFile file(path);
+ if (!file.open(QIODevice::ReadOnly))
+ return {};
+ return QString::fromUtf8(file.readAll()).trimmed();
+}
+
+/// A colour for a collection with no `color` file, derived from its directory
+/// name so it is the same on every load and on every machine.
+QColor hashedColour(const QString &dir)
+{
+ const QByteArray hash = QCryptographicHash::hash(dir.toUtf8(), QCryptographicHash::Md5);
+ const int hue = (static_cast<unsigned char>(hash[0]) * 256
+ + static_cast<unsigned char>(hash[1])) % 360;
+ return QColor::fromHsl(hue, 150, 140);
+}
+
+} // namespace
+```
+
+```cpp
+LoadResult load(const QString &dir)
+{
+ LoadResult result;
+ const QFileInfoList subdirs =
+ QDir(dir).entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name);
+ for (const QFileInfo &sub : subdirs) {
+ CalCollection collection;
+ collection.dir = sub.fileName();
+ collection.path = sub.absoluteFilePath();
+ collection.displayName = readTrimmed(collection.path + QStringLiteral("/displayname"));
+ if (collection.displayName.isEmpty())
+ collection.displayName = collection.dir;
+ collection.color = QColor(readTrimmed(collection.path + QStringLiteral("/color")));
+ if (!collection.color.isValid())
+ collection.color = hashedColour(collection.dir);
+ collection.readOnly = !sub.isWritable();
+ result.collections.append(collection);
+
+ const QFileInfoList files =
+ QDir(collection.path).entryInfoList({ QStringLiteral("*.ics") }, QDir::Files, QDir::Name);
+ for (const QFileInfo &file : files) {
+ QFile f(file.absoluteFilePath());
+ if (!f.open(QIODevice::ReadOnly)) {
+ ++result.unparsable;
+ continue;
+ }
+ bool unknown = false;
+ CalEvent event = parseEvent(f.readAll(), file.absoluteFilePath(),
+ collection.dir, &unknown);
+ if (event.uid.isEmpty() || !event.start.isValid()) {
+ ++result.unparsable;
+ continue;
+ }
+ if (unknown)
+ ++result.unknownZones;
+ result.events.append(event);
+ }
+ }
+ std::sort(result.collections.begin(), result.collections.end(),
+ [](const CalCollection &a, const CalCollection &b) {
+ return a.displayName.localeAwareCompare(b.displayName) < 0;
+ });
+ return result;
+}
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_calendarstore`
+Expected: all PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/calendarstore.cpp tests/test_calendarstore.cpp
+git commit -m "feat: read every time form and load a calendar vdir"
+```
+
+---
+
+### Task 3: `RepeatRule`
+
+**Files:**
+- Modify: `src/repeatrule.cpp`
+- Test: `tests/test_repeatrule.cpp`
+
+- [ ] **Step 1: Write the failing tests**
+
+Replace `tests/test_repeatrule.cpp`:
+
+```cpp
+/* GPLv2 header */
+
+#include <QtTest>
+
+#include "repeatrule.h"
+
+class TestRepeatRule : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void roundTripsEveryRowOfTheTable_data();
+ void roundTripsEveryRowOfTheTable();
+ void fillsWhatAnRruleLeavesImplicit();
+ void recognisesACustomRule_data();
+ void recognisesACustomRule();
+ void keepsWkst();
+ void readsUntilInEveryForm();
+ void describesInWords();
+};
+
+void TestRepeatRule::roundTripsEveryRowOfTheTable_data()
+{
+ QTest::addColumn<QString>("rrule");
+ // Each row of the spec's repeat table, written the way toRRule writes it.
+ QTest::newRow("daily") << QStringLiteral("FREQ=DAILY");
+ QTest::newRow("every 3 days") << QStringLiteral("FREQ=DAILY;INTERVAL=3");
+ QTest::newRow("weekly on days") << QStringLiteral("FREQ=WEEKLY;BYDAY=MO,WE,FR");
+ QTest::newRow("monthly on day") << QStringLiteral("FREQ=MONTHLY;BYMONTHDAY=18");
+ QTest::newRow("monthly last friday") << QStringLiteral("FREQ=MONTHLY;BYDAY=-1FR");
+ QTest::newRow("monthly 2nd tuesday") << QStringLiteral("FREQ=MONTHLY;INTERVAL=2;BYDAY=2TU");
+ QTest::newRow("yearly") << QStringLiteral("FREQ=YEARLY");
+ QTest::newRow("yearly last fri of sep") << QStringLiteral("FREQ=YEARLY;BYMONTH=9;BYDAY=-1FR");
+ QTest::newRow("count") << QStringLiteral("FREQ=MONTHLY;BYMONTHDAY=18;COUNT=12");
+ QTest::newRow("until date") << QStringLiteral("FREQ=YEARLY;UNTIL=20301231");
+}
+
+void TestRepeatRule::roundTripsEveryRowOfTheTable()
+{
+ QFETCH(QString, rrule);
+ const QDate start(2026, 9, 18);
+ const RepeatRule rule = RepeatRule::fromRRule(rrule, start);
+ QVERIFY2(!rule.custom, qPrintable(rrule));
+ // UNTIL in the "until date" row is a DATE, so the all-day form (no suffix).
+ QCOMPARE(rule.toRRule(rule.end == RepeatRule::End::Until
+ ? rule.until.toString(QStringLiteral("yyyyMMdd"))
+ : QString()),
+ rrule);
+ QCOMPARE(RepeatRule::fromRRule(rule.toRRule(rule.until.toString(QStringLiteral("yyyyMMdd"))),
+ start), rule);
+}
+
+void TestRepeatRule::fillsWhatAnRruleLeavesImplicit()
+{
+ // 2026-09-18 is a Friday.
+ const RepeatRule weekly = RepeatRule::fromRRule(QStringLiteral("FREQ=WEEKLY"), QDate(2026, 9, 18));
+ QCOMPARE(weekly.weekdays, QList<int>{ Qt::Friday });
+ const RepeatRule monthly = RepeatRule::fromRRule(QStringLiteral("FREQ=MONTHLY"), QDate(2026, 9, 18));
+ QCOMPARE(monthly.by, RepeatRule::By::MonthDay);
+ QCOMPARE(monthly.monthDay, 18);
+}
+
+void TestRepeatRule::recognisesACustomRule_data()
+{
+ QTest::addColumn<QString>("rrule");
+ QTest::newRow("bysetpos") << QStringLiteral("FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1");
+ QTest::newRow("byweekno") << QStringLiteral("FREQ=YEARLY;BYWEEKNO=20");
+ QTest::newRow("hourly") << QStringLiteral("FREQ=HOURLY");
+ QTest::newRow("two months") << QStringLiteral("FREQ=YEARLY;BYMONTH=3,9;BYDAY=-1FR");
+ QTest::newRow("two monthdays") << QStringLiteral("FREQ=MONTHLY;BYMONTHDAY=1,15");
+ QTest::newRow("weekly ordinal") << QStringLiteral("FREQ=WEEKLY;BYDAY=1MO");
+ QTest::newRow("fifth monday") << QStringLiteral("FREQ=MONTHLY;BYDAY=5MO");
+}
+
+void TestRepeatRule::recognisesACustomRule()
+{
+ QFETCH(QString, rrule);
+ const RepeatRule rule = RepeatRule::fromRRule(rrule, QDate(2026, 9, 18));
+ QVERIFY(rule.custom);
+ // An edit never rewrites a custom rule: it comes back exactly.
+ QCOMPARE(rule.toRRule(), rrule);
+}
+
+void TestRepeatRule::keepsWkst()
+{
+ const RepeatRule rule = RepeatRule::fromRRule(QStringLiteral("FREQ=YEARLY;WKST=TU"), QDate(2026, 9, 18));
+ QVERIFY(!rule.custom);
+ QCOMPARE(rule.toRRule(), QStringLiteral("FREQ=YEARLY;WKST=TU"));
+}
+
+void TestRepeatRule::readsUntilInEveryForm()
+{
+ const QDate start(2026, 9, 18);
+ QCOMPARE(RepeatRule::fromRRule(QStringLiteral("FREQ=DAILY;UNTIL=20261231"), start).until,
+ QDate(2026, 12, 31));
+ QCOMPARE(RepeatRule::fromRRule(QStringLiteral("FREQ=DAILY;UNTIL=20261231T120000"), start).until,
+ QDate(2026, 12, 31));
+ // A UTC UNTIL keeps its UTC date: the caller writes 23:59:59 local as UTC,
+ // which in a zone east of UTC is still the same date.
+ QCOMPARE(RepeatRule::fromRRule(QStringLiteral("FREQ=DAILY;UNTIL=20261231T215959Z"), start).until,
+ QDate(2026, 12, 31));
+ QCOMPARE(RepeatRule::fromRRule(QStringLiteral("FREQ=DAILY;UNTIL=20261231"), start).end,
+ RepeatRule::End::Until);
+}
+
+void TestRepeatRule::describesInWords()
+{
+ const QDate start(2026, 9, 18);
+ QCOMPARE(RepeatRule::fromRRule(QStringLiteral("FREQ=DAILY"), start).describe(),
+ QStringLiteral("Daily"));
+ QCOMPARE(RepeatRule::fromRRule(QStringLiteral("FREQ=MONTHLY;BYDAY=-1FR"), start).describe(),
+ QStringLiteral("Monthly, on the last Friday"));
+ QCOMPARE(RepeatRule::fromRRule(QStringLiteral("FREQ=MONTHLY;BYMONTHDAY=18;COUNT=12"), start).describe(),
+ QStringLiteral("Monthly, on day 18, 12 times"));
+ QCOMPARE(RepeatRule::fromRRule(QStringLiteral("FREQ=YEARLY;BYMONTH=9;BYDAY=-1FR"), start).describe(),
+ QStringLiteral("Yearly, on the last Friday of September"));
+ QCOMPARE(RepeatRule::fromRRule(QStringLiteral("FREQ=YEARLY;BYWEEKNO=20"), start).describe(),
+ QStringLiteral("Custom rule"));
+}
+
+QTEST_MAIN(TestRepeatRule)
+#include "test_repeatrule.moc"
+```
+
+`describesInWords` compares English: the test process installs no translator, and QLocale day and month names come from the C locale under `QTEST_MAIN`'s default. If the machine locale leaks through (`LANG=it_IT` gives "venerdì"), add `QLocale::setDefault(QLocale::c());` at the top of that test; check before assuming.
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_repeatrule`
+Expected: FAIL (the stubs return empty rules).
+
+- [ ] **Step 3: Implement `src/repeatrule.cpp`**
+
+```cpp
+/* GPLv2 header */
+
+#include "repeatrule.h"
+
+#include <QCoreApplication>
+#include <QLocale>
+#include <QMap>
+
+#include <algorithm>
+
+namespace {
+
+// iCalendar weekday codes, indexed by Qt::DayOfWeek - 1.
+const char *const kDays[] = { "MO", "TU", "WE", "TH", "FR", "SA", "SU" };
+
+int dayFromCode(const QString &code)
+{
+ for (int i = 0; i < 7; ++i)
+ if (code == QLatin1String(kDays[i]))
+ return i + 1;
+ return 0;
+}
+
+/// Splits "-1FR" into ordinal -1 and Friday. A plain "FR" gives ordinal 0.
+bool splitByDay(const QString &token, int *ordinal, int *day)
+{
+ if (token.size() < 2)
+ return false;
+ *day = dayFromCode(token.right(2));
+ const QString head = token.left(token.size() - 2);
+ bool ok = true;
+ *ordinal = head.isEmpty() ? 0 : head.toInt(&ok);
+ return *day != 0 && ok;
+}
+
+// Array literals have no enclosing class, so the context must be named on each
+// literal (AGENTS.md: QT_TR_NOOP here compiles and extracts nothing).
+const char *const kOrdinals[] = {
+ QT_TRANSLATE_NOOP("RepeatRule", "first"),
+ QT_TRANSLATE_NOOP("RepeatRule", "second"),
+ QT_TRANSLATE_NOOP("RepeatRule", "third"),
+ QT_TRANSLATE_NOOP("RepeatRule", "fourth"),
+};
+
+QString tr(const char *text) { return QCoreApplication::translate("RepeatRule", text); }
+
+QString ordinalWord(int ordinal)
+{
+ if (ordinal == -1)
+ return tr("last");
+ return (ordinal >= 1 && ordinal <= 4) ? tr(kOrdinals[ordinal - 1]) : QString();
+}
+
+} // namespace
+
+RepeatRule RepeatRule::fromRRule(const QString &value, const QDate &start)
+{
+ RepeatRule rule;
+ auto markCustom = [&]() {
+ RepeatRule custom;
+ custom.custom = true;
+ custom.customText = value;
+ return custom;
+ };
+
+ QMap<QString, QString> parts;
+ for (const QString &pair : value.split(QLatin1Char(';'), Qt::SkipEmptyParts)) {
+ const int eq = pair.indexOf(QLatin1Char('='));
+ if (eq <= 0)
+ return markCustom();
+ parts.insert(pair.left(eq).toUpper(), pair.mid(eq + 1).toUpper());
+ }
+
+ const QString freq = parts.take(QStringLiteral("FREQ"));
+ if (freq == QLatin1String("DAILY")) rule.freq = Freq::Daily;
+ else if (freq == QLatin1String("WEEKLY")) rule.freq = Freq::Weekly;
+ else if (freq == QLatin1String("MONTHLY")) rule.freq = Freq::Monthly;
+ else if (freq == QLatin1String("YEARLY")) rule.freq = Freq::Yearly;
+ else return markCustom();
+
+ if (parts.contains(QStringLiteral("INTERVAL"))) {
+ rule.interval = parts.take(QStringLiteral("INTERVAL")).toInt();
+ if (rule.interval < 1)
+ return markCustom();
+ }
+ if (parts.contains(QStringLiteral("COUNT"))) {
+ rule.end = End::Count;
+ rule.count = parts.take(QStringLiteral("COUNT")).toInt();
+ if (rule.count < 1)
+ return markCustom();
+ }
+ if (parts.contains(QStringLiteral("UNTIL"))) {
+ const QString until = parts.take(QStringLiteral("UNTIL"));
+ rule.end = End::Until;
+ rule.until = QDate::fromString(until.left(8), QStringLiteral("yyyyMMdd"));
+ if (!rule.until.isValid())
+ return markCustom();
+ }
+ rule.wkst = parts.take(QStringLiteral("WKST"));
+
+ const QStringList byDay = parts.contains(QStringLiteral("BYDAY"))
+ ? parts.take(QStringLiteral("BYDAY")).split(QLatin1Char(',')) : QStringList();
+ const QStringList byMonthDay = parts.contains(QStringLiteral("BYMONTHDAY"))
+ ? parts.take(QStringLiteral("BYMONTHDAY")).split(QLatin1Char(',')) : QStringList();
+ const QStringList byMonth = parts.contains(QStringLiteral("BYMONTH"))
+ ? parts.take(QStringLiteral("BYMONTH")).split(QLatin1Char(',')) : QStringList();
+
+ if (!parts.isEmpty()) // BYSETPOS, BYWEEKNO, BYHOUR, ... : outside the table.
+ return markCustom();
+
+ switch (rule.freq) {
+ case Freq::Daily:
+ if (!byDay.isEmpty() || !byMonthDay.isEmpty() || !byMonth.isEmpty())
+ return markCustom();
+ break;
+ case Freq::Weekly:
+ if (!byMonthDay.isEmpty() || !byMonth.isEmpty())
+ return markCustom();
+ for (const QString &token : byDay) {
+ int ordinal = 0, day = 0;
+ if (!splitByDay(token, &ordinal, &day) || ordinal != 0)
+ return markCustom();
+ rule.weekdays.append(day);
+ }
+ if (rule.weekdays.isEmpty())
+ rule.weekdays.append(start.dayOfWeek());
+ std::sort(rule.weekdays.begin(), rule.weekdays.end());
+ break;
+ case Freq::Monthly:
+ case Freq::Yearly: {
+ const bool yearly = rule.freq == Freq::Yearly;
+ if (!byMonthDay.isEmpty() && !byDay.isEmpty())
+ return markCustom();
+ if (!byDay.isEmpty()) {
+ int ordinal = 0, day = 0;
+ if (byDay.size() != 1 || !splitByDay(byDay.first(), &ordinal, &day)
+ || !(ordinal == -1 || (ordinal >= 1 && ordinal <= 4)))
+ return markCustom();
+ if (yearly && byMonth.size() != 1)
+ return markCustom();
+ rule.by = By::Weekday;
+ rule.ordinal = ordinal;
+ rule.weekday = day;
+ if (yearly)
+ rule.month = byMonth.first().toInt();
+ } else if (yearly) {
+ if (!byMonth.isEmpty() || !byMonthDay.isEmpty())
+ return markCustom(); // "on the start date" writes neither
+ } else {
+ if (!byMonth.isEmpty() || byMonthDay.size() > 1)
+ return markCustom();
+ rule.monthDay = byMonthDay.isEmpty() ? start.day() : byMonthDay.first().toInt();
+ if (rule.monthDay < 1 || rule.monthDay > 31)
+ return markCustom();
+ }
+ break;
+ }
+ case Freq::None:
+ break;
+ }
+ return rule;
+}
+
+QString RepeatRule::toRRule(const QString &untilValue) const
+{
+ if (custom)
+ return customText;
+ if (freq == Freq::None)
+ return {};
+
+ QStringList parts;
+ static const char *const kFreq[] = { "", "DAILY", "WEEKLY", "MONTHLY", "YEARLY" };
+ parts << QStringLiteral("FREQ=%1").arg(QLatin1String(kFreq[static_cast<int>(freq)]));
+ if (interval > 1)
+ parts << QStringLiteral("INTERVAL=%1").arg(interval);
+
+ auto byDayToken = [](int ordinal, int day) {
+ return (ordinal ? QString::number(ordinal) : QString()) + QLatin1String(kDays[day - 1]);
+ };
+ if (freq == Freq::Weekly && !weekdays.isEmpty()) {
+ QStringList days;
+ for (int d : weekdays)
+ days << QLatin1String(kDays[d - 1]);
+ parts << QStringLiteral("BYDAY=%1").arg(days.join(QLatin1Char(',')));
+ } else if (freq == Freq::Monthly) {
+ if (by == By::Weekday)
+ parts << QStringLiteral("BYDAY=%1").arg(byDayToken(ordinal, weekday));
+ else
+ parts << QStringLiteral("BYMONTHDAY=%1").arg(monthDay);
+ } else if (freq == Freq::Yearly && by == By::Weekday) {
+ parts << QStringLiteral("BYMONTH=%1").arg(month)
+ << QStringLiteral("BYDAY=%1").arg(byDayToken(ordinal, weekday));
+ }
+
+ if (end == End::Count)
+ parts << QStringLiteral("COUNT=%1").arg(count);
+ else if (end == End::Until)
+ parts << QStringLiteral("UNTIL=%1").arg(untilValue);
+ if (!wkst.isEmpty())
+ parts << QStringLiteral("WKST=%1").arg(wkst);
+ return parts.join(QLatin1Char(';'));
+}
+
+QString RepeatRule::describe() const
+{
+ if (custom)
+ return tr("Custom rule");
+ if (freq == Freq::None)
+ return tr("Does not repeat");
+
+ const QLocale locale;
+ QString text;
+ switch (freq) {
+ case Freq::Daily:
+ text = interval == 1 ? tr("Daily") : tr("Every %1 days").arg(interval);
+ break;
+ case Freq::Weekly: {
+ QStringList days;
+ for (int d : weekdays)
+ days << locale.dayName(d, QLocale::ShortFormat);
+ text = (interval == 1 ? tr("Weekly") : tr("Every %1 weeks").arg(interval))
+ + tr(", on %1").arg(days.join(QStringLiteral(", ")));
+ break;
+ }
+ case Freq::Monthly:
+ text = interval == 1 ? tr("Monthly") : tr("Every %1 months").arg(interval);
+ text += by == By::Weekday
+ ? tr(", on the %1 %2").arg(ordinalWord(ordinal), locale.dayName(weekday))
+ : tr(", on day %1").arg(monthDay);
+ break;
+ case Freq::Yearly:
+ text = interval == 1 ? tr("Yearly") : tr("Every %1 years").arg(interval);
+ if (by == By::Weekday)
+ text += tr(", on the %1 %2 of %3")
+ .arg(ordinalWord(ordinal), locale.dayName(weekday),
+ locale.monthName(month));
+ break;
+ case Freq::None:
+ break;
+ }
+ if (end == End::Count)
+ text += tr(", %1 times").arg(count);
+ else if (end == End::Until)
+ text += tr(", until %1").arg(locale.toString(until, QLocale::ShortFormat));
+ return text;
+}
+
+bool RepeatRule::operator==(const RepeatRule &o) const
+{
+ return freq == o.freq && interval == o.interval && weekdays == o.weekdays
+ && by == o.by && monthDay == o.monthDay && ordinal == o.ordinal
+ && weekday == o.weekday && month == o.month && end == o.end
+ && until == o.until && count == o.count && wkst == o.wkst
+ && custom == o.custom && customText == o.customText;
+}
+```
+
+A trap to check while implementing: in `roundTripsEveryRowOfTheTable`, the "monthly on day" row is `FREQ=MONTHLY;BYMONTHDAY=18`, and `toRRule` writes `BYMONTHDAY` always for `By::MonthDay`, so the round trip holds. A rule read as `FREQ=MONTHLY` with no `BYMONTHDAY` round-trips to `FREQ=MONTHLY;BYMONTHDAY=18`: the text changes, the meaning does not. That is acceptable and is why that row is not in the table test.
+
+- [ ] **Step 4: Run to verify they pass**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_repeatrule`
+Expected: all PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/repeatrule.cpp tests/test_repeatrule.cpp
+git commit -m "feat: model the calendar's repeat control as RRULE text"
+```
+
+---
+
+### Task 4: Expanding occurrences
+
+**Files:**
+- Modify: `src/calendarstore.cpp` (`parseEvent`'s recurrence half, `occurrences`)
+- Test: `tests/test_calendarstore.cpp`
+
+- [ ] **Step 1: Write the failing tests**
+
+```cpp
+ void expandsAWeeklyEventAcrossDstInItsOwnZone();
+ void expandsAnAllDayEventOntoOneDay();
+ void removesAnExdate();
+ void anOverrideReplacesItsSlotAndMovesFreely();
+ void anInfiniteSeriesYieldsOnlyTheWindow();
+ void honoursCountAndUntil();
+ void expandsMonthlyLastFridayAndYearlyByMonth();
+ void readsTheRepeatRuleExdatesAndOverrides();
+```
+
+```cpp
+namespace {
+
+QList<Occurrence> expand(const QString &body, const QDateTime &from, const QDateTime &to,
+ CalEvent *out = nullptr)
+{
+ const CalEvent event = parse(body);
+ if (out)
+ *out = event;
+ return CalendarStore::occurrences({ event }, from, to);
+}
+
+QDateTime utc(int y, int m, int d, int h = 0, int min = 0)
+{
+ return QDateTime(QDate(y, m, d), QTime(h, min), QTimeZone::utc());
+}
+
+} // namespace
+
+void TestCalendarStore::expandsAWeeklyEventAcrossDstInItsOwnZone()
+{
+ // 2026-10-25 is the last Sunday of October: Rome leaves CEST (+2) for CET
+ // (+1). A weekly 10:00 meeting must stay at 10:00 in Rome on both sides,
+ // which means its UTC time MOVES. Expanding in UTC would keep 08:00Z and
+ // draw the second one at 09:00 local.
+ const QTimeZone rome("Europe/Rome");
+ const QList<Occurrence> occ = expand(vevent(QStringLiteral(
+ "UID:w@example.org\r\nDTSTART;TZID=Europe/Rome:20261019T100000\r\n"
+ "DTEND;TZID=Europe/Rome:20261019T110000\r\nRRULE:FREQ=WEEKLY\r\n")),
+ utc(2026, 10, 18), utc(2026, 11, 1));
+ QCOMPARE(occ.size(), 2);
+ QCOMPARE(occ[0].start.toTimeZone(rome).time(), QTime(10, 0));
+ QCOMPARE(occ[1].start.toTimeZone(rome).time(), QTime(10, 0));
+ QCOMPARE(occ[0].start.toUTC().time(), QTime(8, 0));
+ QCOMPARE(occ[1].start.toUTC().time(), QTime(9, 0));
+ QCOMPARE(occ[1].end.toTimeZone(rome).time(), QTime(11, 0));
+}
+
+void TestCalendarStore::expandsAnAllDayEventOntoOneDay()
+{
+ const QList<Occurrence> occ = expand(vevent(QStringLiteral(
+ "UID:ad@example.org\r\nDTSTART;VALUE=DATE:20260924\r\nDTEND;VALUE=DATE:20260925\r\n")),
+ QDateTime(QDate(2026, 9, 25), QTime(0, 0)), QDateTime(QDate(2026, 9, 26), QTime(0, 0)));
+ // The window starts on the 25th, where the event has already ENDED: an
+ // inclusive end would draw it on the 25th too.
+ QCOMPARE(occ.size(), 0);
+}
+
+void TestCalendarStore::removesAnExdate()
+{
+ const QList<Occurrence> occ = expand(vevent(QStringLiteral(
+ "UID:x@example.org\r\nDTSTART:20260921T080000Z\r\nDTEND:20260921T090000Z\r\n"
+ "RRULE:FREQ=DAILY\r\nEXDATE:20260922T080000Z\r\n")),
+ utc(2026, 9, 21), utc(2026, 9, 24));
+ QCOMPARE(occ.size(), 2);
+ QCOMPARE(occ[0].start, utc(2026, 9, 21, 8));
+ QCOMPARE(occ[1].start, utc(2026, 9, 23, 8));
+}
+
+void TestCalendarStore::anOverrideReplacesItsSlotAndMovesFreely()
+{
+ // A five-day series (21st-25th) whose 22nd is moved to the 30th. A window
+ // over the 21st-23rd must NOT show the 22nd, since its slot is taken; a
+ // window over the 30th, where the series has no slot at all, MUST show
+ // the override, since an override is placed by its own start.
+ const QString body = vevent(QStringLiteral(
+ "UID:o@example.org\r\nDTSTART:20260921T080000Z\r\nDTEND:20260921T090000Z\r\n"
+ "RRULE:FREQ=DAILY;COUNT=5\r\nSUMMARY:Series\r\n"))
+ + vevent(QStringLiteral(
+ "UID:o@example.org\r\nRECURRENCE-ID:20260922T080000Z\r\n"
+ "DTSTART:20260930T150000Z\r\nDTEND:20260930T160000Z\r\nSUMMARY:Moved\r\n"));
+ CalEvent event;
+ QList<Occurrence> occ = expand(body, utc(2026, 9, 21), utc(2026, 9, 24), &event);
+ QCOMPARE(occ.size(), 2); // the 21st and 23rd
+ QCOMPARE(event.overrides.size(), 1);
+ QCOMPARE(event.overrides[0].summary, QStringLiteral("Moved"));
+
+ occ = expand(body, utc(2026, 9, 30), utc(2026, 10, 1));
+ QCOMPARE(occ.size(), 1); // COUNT=5 ends on the 25th; only the override is here
+ QVERIFY(occ[0].isOverride);
+ QCOMPARE(occ[0].start, utc(2026, 9, 30, 15));
+ QCOMPARE(occ[0].recurrenceId, utc(2026, 9, 22, 8));
+}
+
+void TestCalendarStore::anInfiniteSeriesYieldsOnlyTheWindow()
+{
+ const QList<Occurrence> occ = expand(vevent(QStringLiteral(
+ "UID:inf@example.org\r\nDTSTART:20000101T080000Z\r\nRRULE:FREQ=DAILY\r\n")),
+ utc(2026, 9, 1), utc(2026, 9, 8));
+ QCOMPARE(occ.size(), 7);
+ QCOMPARE(occ.first().start, utc(2026, 9, 1, 8));
+}
+
+void TestCalendarStore::honoursCountAndUntil()
+{
+ QCOMPARE(expand(vevent(QStringLiteral(
+ "UID:c@example.org\r\nDTSTART:20260901T080000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\n")),
+ utc(2026, 9, 1), utc(2026, 10, 1)).size(), 3);
+ QCOMPARE(expand(vevent(QStringLiteral(
+ "UID:u@example.org\r\nDTSTART:20260901T080000Z\r\n"
+ "RRULE:FREQ=DAILY;UNTIL=20260905T080000Z\r\n")),
+ utc(2026, 9, 1), utc(2026, 10, 1)).size(), 5);
+}
+
+void TestCalendarStore::expandsMonthlyLastFridayAndYearlyByMonth()
+{
+ // Last Fridays: 2026-09-25, 2026-10-30.
+ QList<Occurrence> occ = expand(vevent(QStringLiteral(
+ "UID:lf@example.org\r\nDTSTART:20260925T080000Z\r\nRRULE:FREQ=MONTHLY;BYDAY=-1FR\r\n")),
+ utc(2026, 9, 1), utc(2026, 11, 1));
+ QCOMPARE(occ.size(), 2);
+ QCOMPARE(occ[1].start.date(), QDate(2026, 10, 30));
+
+ // Last Friday of September: 2026-09-25, 2027-09-24.
+ occ = expand(vevent(QStringLiteral(
+ "UID:ly@example.org\r\nDTSTART:20260925T080000Z\r\n"
+ "RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1FR\r\n")),
+ utc(2026, 1, 1), utc(2028, 1, 1));
+ QCOMPARE(occ.size(), 2);
+ QCOMPARE(occ[1].start.date(), QDate(2027, 9, 24));
+}
+
+void TestCalendarStore::readsTheRepeatRuleExdatesAndOverrides()
+{
+ CalEvent e;
+ expand(vevent(QStringLiteral(
+ "UID:r@example.org\r\nDTSTART:20260921T080000Z\r\n"
+ "RRULE:FREQ=MONTHLY;BYDAY=-1FR\r\nEXDATE:20261030T080000Z\r\n")),
+ utc(2026, 9, 1), utc(2026, 9, 2), &e);
+ QCOMPARE(e.repeat.freq, RepeatRule::Freq::Monthly);
+ QCOMPARE(e.repeat.ordinal, -1);
+ QCOMPARE(e.exdates, QList<QDateTime>{ utc(2026, 10, 30, 8) });
+}
+```
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_calendarstore`
+Expected: the eight new tests FAIL (no recurrence read, `occurrences` is a stub).
+
+- [ ] **Step 3: Read recurrence, exceptions and overrides in `parseEvent`**
+
+Replace the `// Recurrence, exceptions and overrides: Task 4.` line:
+
+```cpp
+ if (icalproperty *rrule = icalcomponent_get_first_property(master, ICAL_RRULE_PROPERTY)) {
+ // The value text rather than icalproperty_get_rrule(): RepeatRule is
+ // pure text, and libical re-serialising the struct would reorder parts.
+ IcalString value(icalproperty_get_value_as_string_r(rrule));
+ event.repeat = RepeatRule::fromRRule(str(value.get()), event.start.date());
+ }
+ for (icalproperty *ex = icalcomponent_get_first_property(master, ICAL_EXDATE_PROPERTY);
+ ex; ex = icalcomponent_get_next_property(master, ICAL_EXDATE_PROPERTY))
+ event.exdates.append(toDateTime(icalproperty_get_exdate(ex), tzidOf(ex), unknownZone));
+
+ for (icalcomponent *c = icalcomponent_get_first_component(root.get(), ICAL_VEVENT_COMPONENT);
+ c; c = icalcomponent_get_next_component(root.get(), ICAL_VEVENT_COMPONENT)) {
+ icalproperty *rid = icalcomponent_get_first_property(c, ICAL_RECURRENCEID_PROPERTY);
+ if (c == master || !rid)
+ continue;
+ CalOverride ov;
+ ov.recurrenceId = toDateTime(icalcomponent_get_recurrenceid(c), tzidOf(rid), unknownZone);
+ icalproperty *sp = icalcomponent_get_first_property(c, ICAL_DTSTART_PROPERTY);
+ const icaltimetype s = icalcomponent_get_dtstart(c);
+ ov.allDay = s.is_date;
+ ov.start = toDateTime(s, tzidOf(sp), unknownZone);
+ ov.end = endOf(c, ov.start, ov.allDay, unknownZone);
+ ov.summary = str(icalcomponent_get_summary(c));
+ ov.location = str(icalcomponent_get_location(c));
+ ov.description = str(icalcomponent_get_description(c));
+ ov.cancelled = icalcomponent_get_status(c) == ICAL_STATUS_CANCELLED;
+ event.overrides.append(ov);
+ }
+```
+
+- [ ] **Step 4: Implement `occurrences`**
+
+Expansion re-parses each repeating event's `rawText` so the iterator gets libical's own `icaltimetype` with its zone; the parse costs microseconds and keeps `CalEvent` free of libical. Add to the anonymous namespace:
+
+```cpp
+/// ponytail: iterates from DTSTART rather than icalrecur_iterator_set_start,
+/// which is unsupported with COUNT. Capped per series; raise the cap only if
+/// a real rule is measured hitting it.
+constexpr int kMaxIterations = 10000;
+
+bool overlaps(const QDateTime &start, const QDateTime &end,
+ const QDateTime &from, const QDateTime &to)
+{
+ // A zero-length event (no DTEND) still shows on its instant.
+ return start < to && (end > from || (end == start && start >= from));
+}
+
+/// The occurrence start times of the master in `text`, up to `to`, in the
+/// event's own zone and converted after: a weekly 10:00 Rome meeting stays
+/// 10:00 in Rome across DST (spec, "Time and recurrence").
+QList<QDateTime> seriesStarts(const QByteArray &text, const QDateTime &to)
+{
+ QList<QDateTime> starts;
+ IcalComponent root = parseRoot(text);
+ icalcomponent *master = root ? masterOf(root.get()) : nullptr;
+ icalproperty *rrule = master ? icalcomponent_get_first_property(master, ICAL_RRULE_PROPERTY) : nullptr;
+ if (!rrule)
+ return starts;
+ icalproperty *startProp = icalcomponent_get_first_property(master, ICAL_DTSTART_PROPERTY);
+ const QByteArray tzid = tzidOf(startProp);
+ const icaltimetype dtstart = icalcomponent_get_dtstart(master);
+
+ IcalRecurIterator it(icalrecur_iterator_new(icalproperty_get_rrule(rrule), dtstart));
+ if (!it)
+ return starts;
+ for (int i = 0; i < kMaxIterations; ++i) {
+ icaltimetype t = icalrecur_iterator_next(it.get());
+ if (icaltime_is_null_time(t))
+ break;
+ t.zone = dtstart.zone;
+ t.is_date = dtstart.is_date;
+ const QDateTime start = toDateTime(t, tzid, nullptr);
+ if (start >= to)
+ break;
+ starts.append(start);
+ }
+ return starts;
+}
+
+} // namespace (reopen below if the helpers above were added mid-namespace)
+```
+
+```cpp
+QList<Occurrence> occurrences(const QList<CalEvent> &events,
+ const QDateTime &from, const QDateTime &to)
+{
+ QList<Occurrence> result;
+ for (int i = 0; i < events.size(); ++i) {
+ const CalEvent &e = events[i];
+ const qint64 length = e.start.msecsTo(e.end);
+
+ if (e.repeat.freq == RepeatRule::Freq::None) {
+ if (overlaps(e.start, e.end, from, to))
+ result.append({ i, e.start, e.end, e.allDay, {}, false, -1 });
+ continue;
+ }
+
+ for (const QDateTime &slot : seriesStarts(e.rawText, to)) {
+ // An EXDATE or an override takes this slot. Compared as instants:
+ // an EXDATE may be written in UTC while DTSTART carries a TZID.
+ const auto sameInstant = [&](const QDateTime &d) { return d == slot; };
+ if (std::any_of(e.exdates.cbegin(), e.exdates.cend(), sameInstant))
+ continue;
+ if (std::any_of(e.overrides.cbegin(), e.overrides.cend(),
+ [&](const CalOverride &o) { return o.recurrenceId == slot; }))
+ continue;
+ const QDateTime end = e.allDay ? slot.addDays(e.start.daysTo(e.end))
+ : slot.addMSecs(length);
+ if (overlaps(slot, end, from, to))
+ result.append({ i, slot, end, e.allDay, slot, false, -1 });
+ }
+ // Placed by their OWN start: an override may move into a window its
+ // slot is outside of, or out of the window its slot is in.
+ for (int o = 0; o < e.overrides.size(); ++o) {
+ const CalOverride &ov = e.overrides[o];
+ if (!ov.cancelled && overlaps(ov.start, ov.end, from, to))
+ result.append({ i, ov.start, ov.end, ov.allDay, ov.recurrenceId, true, o });
+ }
+ }
+ std::sort(result.begin(), result.end(),
+ [](const Occurrence &a, const Occurrence &b) { return a.start < b.start; });
+ return result;
+}
+```
+
+Two things to check against the tests rather than trust: all-day slots add whole DAYS (`addDays`), never milliseconds, because a day across a DST change is 23 or 25 hours; and `QDateTime::operator==` compares instants across zones, which is what the EXDATE and override matching needs.
+
+- [ ] **Step 5: Run to verify they pass**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_calendarstore`
+Expected: all PASS. If the DST test fails with both at 08:00Z, the iterator lost the zone: check `t.zone = dtstart.zone` and that `toDateTime` receives the TZID.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/calendarstore.cpp tests/test_calendarstore.cpp
+git commit -m "feat: expand calendar occurrences in each event's own zone"
+```
+
+---
+
+### Task 5: Who may edit an event
+
+**Files:**
+- Modify: `src/calendarstore.cpp` (`isEditable`)
+- Test: `tests/test_calendarstore.cpp`
+
+- [ ] **Step 1: Write the failing test**
+
+```cpp
+ void editableOnlyWhenTheUserOrganisesIt();
+```
+
+```cpp
+void TestCalendarStore::editableOnlyWhenTheUserOrganisesIt()
+{
+ const QStringList own = { QStringLiteral("me@example.org"), QStringLiteral("alt@example.org") };
+ CalCollection writable;
+ writable.dir = QStringLiteral("x");
+ CalCollection readOnly = writable;
+ readOnly.readOnly = true;
+
+ const CalEvent mine = parse(vevent(QStringLiteral(
+ "UID:1@example.org\r\nDTSTART:20260922T080000Z\r\n"
+ "ORGANIZER;CN=Me:mailto:ME@Example.org\r\n"
+ "ATTENDEE;CN=Other;PARTSTAT=ACCEPTED:mailto:other@example.org\r\n")));
+ const CalEvent theirs = parse(vevent(QStringLiteral(
+ "UID:2@example.org\r\nDTSTART:20260922T080000Z\r\n"
+ "ORGANIZER;CN=Other:mailto:other@example.org\r\n"
+ "ATTENDEE:mailto:me@example.org\r\n")));
+ const CalEvent nobodys = parse(vevent(QStringLiteral(
+ "UID:3@example.org\r\nDTSTART:20260922T080000Z\r\n")));
+
+ // Case-insensitive, and "mailto:" is not part of the address.
+ QVERIFY(CalendarStore::isEditable(mine, writable, own));
+ QVERIFY(!CalendarStore::isEditable(theirs, writable, own));
+ QVERIFY(CalendarStore::isEditable(nobodys, writable, own));
+ QVERIFY(!CalendarStore::isEditable(nobodys, readOnly, own));
+
+ QCOMPARE(mine.organizer.name, QStringLiteral("Me"));
+ QCOMPARE(mine.attendees.size(), 1);
+ QCOMPARE(mine.attendees[0].partstat, QStringLiteral("ACCEPTED"));
+ QCOMPARE(mine.attendees[0].address, QStringLiteral("other@example.org"));
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_calendarstore`
+Expected: FAIL on the first `QVERIFY` (the stub returns false).
+
+- [ ] **Step 3: Implement**
+
+```cpp
+bool isEditable(const CalEvent &event, const CalCollection &collection,
+ const QStringList &ownAddresses)
+{
+ if (collection.readOnly)
+ return false;
+ if (event.organizer.address.isEmpty())
+ return true;
+ return ownAddresses.contains(event.organizer.address, Qt::CaseInsensitive);
+}
+```
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_calendarstore`
+Expected: all PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/calendarstore.cpp tests/test_calendarstore.cpp
+git commit -m "feat: an event is editable only when the user organises it"
+```
+
+---
+
+### Task 6: Editing a whole event, and writing a new one
+
+**Files:**
+- Modify: `src/calendarstore.cpp` (`applyEdit` for `Scope::All`, `newEvent`, `sameMeaning`)
+- Test: `tests/test_calendarstore.cpp`
+
+- [ ] **Step 1: Write the failing tests**
+
+```cpp
+ void anEditKeepsWhatTheFormDoesNotOwn();
+ void anEditKeepsTheEventsZone();
+ void anEditRewritesTheRepeatRuleButNeverACustomOne();
+ void anEditCanTurnAnEventAllDayAndBack();
+ void aNewEventIsCompleteAndEmbedsItsZone();
+ void sameMeaningIgnoresFormatting();
+```
+
+```cpp
+namespace {
+
+EventEdit editOf(const CalEvent &e)
+{
+ EventEdit edit;
+ edit.summary = e.summary;
+ edit.location = e.location;
+ edit.description = e.description;
+ edit.start = e.start;
+ edit.end = e.end;
+ edit.allDay = e.allDay;
+ edit.repeat = e.repeat;
+ edit.collectionDir = e.collectionDir;
+ return edit;
+}
+
+CalEvent reparse(const QByteArray &text)
+{
+ bool unknown = false;
+ return CalendarStore::parseEvent(text, QStringLiteral("/x/e.ics"), QStringLiteral("x"), &unknown);
+}
+
+const QString kRichEvent = QStringLiteral(
+ "UID:rich@example.org\r\nDTSTAMP:20260901T000000Z\r\nSEQUENCE:2\r\n"
+ "DTSTART;TZID=Europe/Rome:20260922T100000\r\nDTEND;TZID=Europe/Rome:20260922T110000\r\n"
+ "SUMMARY:Review\r\nORGANIZER;CN=Me:mailto:me@example.org\r\n"
+ "ATTENDEE;CN=Other;PARTSTAT=ACCEPTED:mailto:other@example.org\r\n"
+ "X-EXAMPLE-FLAG:keep me\r\n"
+ "BEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nTRIGGER:-PT15M\r\nEND:VALARM\r\n");
+
+} // namespace
+
+void TestCalendarStore::anEditKeepsWhatTheFormDoesNotOwn()
+{
+ const CalEvent before = parse(vevent(kRichEvent));
+ EventEdit edit = editOf(before);
+ edit.summary = QStringLiteral("Review, moved");
+ const QByteArray after = CalendarStore::applyEdit(
+ before.rawText, edit, CalendarStore::Scope::All, {});
+ QVERIFY(!after.isEmpty());
+
+ const CalEvent e = reparse(after);
+ QCOMPARE(e.summary, QStringLiteral("Review, moved"));
+ QCOMPARE(e.uid, before.uid);
+ QCOMPARE(e.sequence, 3); // bumped
+ // Value for value, not byte for byte: libical re-serialises (plan ruling 2).
+ QVERIFY(e.hasAlarm);
+ QVERIFY(after.contains("TRIGGER:-PT15M"));
+ QVERIFY(after.contains("X-EXAMPLE-FLAG:keep me"));
+ QCOMPARE(e.attendees.size(), 1);
+ QCOMPARE(e.attendees[0].partstat, QStringLiteral("ACCEPTED"));
+ QVERIFY(after.contains("LAST-MODIFIED:"));
+}
+
+void TestCalendarStore::anEditKeepsTheEventsZone()
+{
+ // The source has NO VTIMEZONE, as 120 of the user's files do: the TZID
+ // must still be written back, not converted to UTC or floating.
+ const CalEvent before = parse(vevent(kRichEvent));
+ EventEdit edit = editOf(before);
+ edit.start = before.start.addSecs(3600);
+ edit.end = before.end.addSecs(3600);
+ const QByteArray after = CalendarStore::applyEdit(before.rawText, edit, CalendarStore::Scope::All, {});
+ QVERIFY2(after.contains("DTSTART;TZID=Europe/Rome:20260922T110000"), after.constData());
+ QCOMPARE(reparse(after).start, edit.start);
+}
+
+void TestCalendarStore::anEditRewritesTheRepeatRuleButNeverACustomOne()
+{
+ const CalEvent weekly = parse(vevent(QStringLiteral(
+ "UID:r@example.org\r\nDTSTART:20260921T080000Z\r\nRRULE:FREQ=WEEKLY;WKST=MO\r\n")));
+ EventEdit edit = editOf(weekly);
+ edit.repeat = RepeatRule::fromRRule(QStringLiteral("FREQ=MONTHLY;BYDAY=-1FR"), weekly.start.date());
+ QByteArray after = CalendarStore::applyEdit(weekly.rawText, edit, CalendarStore::Scope::All, {});
+ QCOMPARE(reparse(after).repeat.toRRule(), QStringLiteral("FREQ=MONTHLY;BYDAY=-1FR"));
+ QCOMPARE(after.count("RRULE"), 1);
+
+ const CalEvent custom = parse(vevent(QStringLiteral(
+ "UID:c@example.org\r\nDTSTART:20260921T080000Z\r\n"
+ "RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1\r\n")));
+ edit = editOf(custom);
+ edit.summary = QStringLiteral("Renamed");
+ after = CalendarStore::applyEdit(custom.rawText, edit, CalendarStore::Scope::All, {});
+ QVERIFY(after.contains("BYSETPOS=-1"));
+
+ edit.repeat = RepeatRule(); // Does not repeat
+ after = CalendarStore::applyEdit(custom.rawText, edit, CalendarStore::Scope::All, {});
+ QVERIFY(!after.contains("RRULE"));
+}
+
+void TestCalendarStore::anEditCanTurnAnEventAllDayAndBack()
+{
+ const CalEvent timed = parse(vevent(kRichEvent));
+ EventEdit edit = editOf(timed);
+ edit.allDay = true;
+ edit.start = QDateTime(QDate(2026, 9, 22), QTime(0, 0));
+ edit.end = QDateTime(QDate(2026, 9, 23), QTime(0, 0));
+ const QByteArray allDay = CalendarStore::applyEdit(timed.rawText, edit, CalendarStore::Scope::All, {});
+ QVERIFY(allDay.contains("DTSTART;VALUE=DATE:20260922"));
+ QVERIFY(allDay.contains("DTEND;VALUE=DATE:20260923"));
+
+ const CalEvent back = reparse(allDay);
+ edit = editOf(back);
+ edit.allDay = false;
+ edit.start = QDateTime(QDate(2026, 9, 22), QTime(9, 0), QTimeZone("Europe/Rome"));
+ edit.end = edit.start.addSecs(3600);
+ const QByteArray timedAgain = CalendarStore::applyEdit(allDay, edit, CalendarStore::Scope::All, {});
+ const CalEvent e = reparse(timedAgain);
+ QVERIFY(!e.allDay);
+ QCOMPARE(e.start, edit.start);
+}
+
+void TestCalendarStore::aNewEventIsCompleteAndEmbedsItsZone()
+{
+ EventEdit edit;
+ edit.summary = QStringLiteral("Dentist");
+ edit.start = QDateTime(QDate(2026, 9, 24), QTime(15, 0), QTimeZone("Europe/Rome"));
+ edit.end = edit.start.addSecs(1800);
+ const QByteArray text = CalendarStore::newEvent(edit, "Europe/Rome");
+ QVERIFY(text.contains("BEGIN:VCALENDAR"));
+ QVERIFY(text.contains("PRODID:-//qtmaildir//EN"));
+ QVERIFY(text.contains("BEGIN:VTIMEZONE"));
+ QVERIFY(text.contains("TZID:Europe/Rome"));
+ QVERIFY(text.contains("SEQUENCE:0"));
+ QVERIFY(text.contains("DTSTAMP:"));
+ const CalEvent e = reparse(text);
+ QVERIFY(!e.uid.isEmpty());
+ QCOMPARE(e.summary, QStringLiteral("Dentist"));
+ QCOMPARE(e.start, edit.start);
+ QCOMPARE(e.end, edit.end);
+
+ // Two new events never share a UID.
+ QVERIFY(reparse(CalendarStore::newEvent(edit, "Europe/Rome")).uid != e.uid);
+}
+
+void TestCalendarStore::sameMeaningIgnoresFormatting()
+{
+ const QByteArray a = ics(vevent(QStringLiteral(
+ "UID:s@example.org\r\nDTSTART:20260922T080000Z\r\nSUMMARY:Standup\r\n")));
+ // Folded, properties reordered, DTSTAMP added: what a server may send back.
+ const QByteArray b = ics(vevent(QStringLiteral(
+ "SUMMARY:Stand\r\n up\r\nDTSTAMP:20260930T000000Z\r\n"
+ "DTSTART:20260922T080000Z\r\nUID:s@example.org\r\n")));
+ const QByteArray moved = ics(vevent(QStringLiteral(
+ "UID:s@example.org\r\nDTSTART:20260922T090000Z\r\nSUMMARY:Standup\r\n")));
+ QVERIFY(CalendarStore::sameMeaning(a, b));
+ QVERIFY(!CalendarStore::sameMeaning(a, moved));
+}
+```
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_calendarstore`
+Expected: the six new tests FAIL.
+
+- [ ] **Step 3: Implement the write helpers**
+
+Add `#include <QUuid>` and add to the anonymous namespace in `calendarstore.cpp`:
+
+```cpp
+/// How a component's times are written, read off its DTSTART and reused for
+/// every time written back, so an edit keeps the event's zone.
+struct TimeForm
+{
+ enum Kind { Date, Utc, Zoned, Floating } kind = Floating;
+ QByteArray tzid;
+ icaltimezone *zone = nullptr; ///< Embedded or built-in; not owned.
+};
+
+TimeForm formOf(icalcomponent *root, icalcomponent *c)
+{
+ TimeForm form;
+ icalproperty *prop = icalcomponent_get_first_property(c, ICAL_DTSTART_PROPERTY);
+ const icaltimetype t = icalcomponent_get_dtstart(c);
+ if (t.is_date) {
+ form.kind = TimeForm::Date;
+ } else if (icaltime_is_utc(t)) {
+ form.kind = TimeForm::Utc;
+ } else if (!tzidOf(prop).isEmpty()) {
+ form.kind = TimeForm::Zoned;
+ form.tzid = tzidOf(prop);
+ form.zone = icalcomponent_get_timezone(root, form.tzid.constData());
+ if (!form.zone)
+ form.zone = icaltimezone_get_builtin_timezone(form.tzid.constData());
+ }
+ return form;
+}
+
+TimeForm zonedForm(const QByteArray &tzid)
+{
+ TimeForm form;
+ form.kind = TimeForm::Zoned;
+ form.tzid = tzid;
+ form.zone = icaltimezone_get_builtin_timezone(tzid.constData());
+ return form;
+}
+
+/// A QDateTime as the wall-clock icaltimetype `form` writes, zone left unset:
+/// the TZID travels as a parameter, so the output never depends on whether
+/// the file carried a VTIMEZONE.
+icaltimetype wallTime(const QDateTime &dt, const TimeForm &form)
+{
+ if (form.kind == TimeForm::Date) {
+ icaltimetype t = icaltime_null_date();
+ const QDate d = dt.date();
+ t.year = d.year(); t.month = d.month(); t.day = d.day();
+ return t;
+ }
+ QDateTime local;
+ if (form.kind == TimeForm::Utc) {
+ local = dt.toUTC();
+ } else if (form.kind == TimeForm::Zoned && form.zone) {
+ icaltimetype t = icaltime_from_timet_with_zone(dt.toSecsSinceEpoch(), 0, form.zone);
+ t.zone = nullptr;
+ return t;
+ } else if (form.kind == TimeForm::Zoned && QTimeZone(form.tzid).isValid()) {
+ local = dt.toTimeZone(QTimeZone(form.tzid));
+ } else {
+ local = dt.toLocalTime();
+ }
+ icaltimetype t = icaltime_null_time();
+ t.is_date = 0;
+ t.year = local.date().year(); t.month = local.date().month(); t.day = local.date().day();
+ t.hour = local.time().hour(); t.minute = local.time().minute(); t.second = local.time().second();
+ if (form.kind == TimeForm::Utc)
+ t = icaltime_convert_to_zone(t, icaltimezone_get_utc_timezone());
+ return t;
+}
+```
+
+Check `icaltime_convert_to_zone` on a zone-less time before trusting the UTC branch: if it does not mark the result UTC, set `t.zone = icaltimezone_get_utc_timezone()` directly instead. The test `aNewEventIsCompleteAndEmbedsItsZone` does not cover UTC; `anEditKeepsWhatTheFormDoesNotOwn` edits a zoned event. Add a UTC edit assertion if the branch is changed.
+
+```cpp
+/// Replaces every `kind` property on `c` with one holding `dt` in `form`.
+/// DTSTART, DTEND and RECURRENCE-ID only: EXDATE is ADDITIVE, and replacing
+/// it would drop every exception but the newest (deleteOccurrence adds its
+/// own).
+void setTime(icalcomponent *c, icalproperty_kind kind, const QDateTime &dt, const TimeForm &form)
+{
+ while (icalproperty *old = icalcomponent_get_first_property(c, kind)) {
+ icalcomponent_remove_property(c, old);
+ icalproperty_free(old);
+ }
+ const icaltimetype t = wallTime(dt, form);
+ icalproperty *prop = kind == ICAL_DTSTART_PROPERTY ? icalproperty_new_dtstart(t)
+ : kind == ICAL_DTEND_PROPERTY ? icalproperty_new_dtend(t)
+ : icalproperty_new_recurrenceid(t);
+ if (form.kind == TimeForm::Zoned)
+ icalproperty_add_parameter(prop, icalparameter_new_tzid(form.tzid.constData()));
+ icalcomponent_add_property(c, prop);
+}
+
+void removeAll(icalcomponent *c, icalproperty_kind kind)
+{
+ while (icalproperty *p = icalcomponent_get_first_property(c, kind)) {
+ icalcomponent_remove_property(c, p);
+ icalproperty_free(p);
+ }
+}
+
+void setText(icalcomponent *c, icalproperty_kind kind, const QString &value)
+{
+ removeAll(c, kind);
+ if (value.isEmpty())
+ return;
+ const QByteArray utf8 = value.toUtf8();
+ icalproperty *p = kind == ICAL_SUMMARY_PROPERTY ? icalproperty_new_summary(utf8.constData())
+ : kind == ICAL_LOCATION_PROPERTY ? icalproperty_new_location(utf8.constData())
+ : icalproperty_new_description(utf8.constData());
+ icalcomponent_add_property(c, p);
+}
+
+/// Embeds the VTIMEZONE for `tzid` from libical's built-in database unless
+/// the calendar already carries one, so what this application writes is
+/// RFC-correct even where what it read was not.
+void ensureVtimezone(icalcomponent *root, const QByteArray &tzid)
+{
+ if (tzid.isEmpty() || icalcomponent_get_timezone(root, tzid.constData()))
+ return;
+ icaltimezone *zone = icaltimezone_get_builtin_timezone(tzid.constData());
+ if (!zone)
+ return;
+ icalcomponent *vtz = icalcomponent_new_clone(icaltimezone_get_component(zone));
+ // libical's built-in components carry a "/citadel.org/..." style TZID;
+ // rewrite it to the plain IANA name the events reference.
+ removeAll(vtz, ICAL_TZID_PROPERTY);
+ icalcomponent_add_property(vtz, icalproperty_new_tzid(tzid.constData()));
+ icalcomponent_add_component(root, vtz);
+}
+
+void stamp(icalcomponent *c, bool bumpSequence)
+{
+ const icaltimetype now = icaltime_current_time_with_zone(icaltimezone_get_utc_timezone());
+ removeAll(c, ICAL_DTSTAMP_PROPERTY);
+ icalcomponent_add_property(c, icalproperty_new_dtstamp(now));
+ removeAll(c, ICAL_LASTMODIFIED_PROPERTY);
+ icalcomponent_add_property(c, icalproperty_new_lastmodified(now));
+ if (bumpSequence)
+ icalcomponent_set_sequence(c, icalcomponent_get_sequence(c) + 1);
+}
+
+/// The form a written time takes after an edit: all-day is a date; a timed
+/// event keeps its zone, or takes the system zone when it was all-day.
+TimeForm editedForm(icalcomponent *root, icalcomponent *c, bool allDay)
+{
+ if (allDay) {
+ TimeForm date;
+ date.kind = TimeForm::Date;
+ return date;
+ }
+ TimeForm form = formOf(root, c);
+ if (form.kind == TimeForm::Date) {
+ form = zonedForm(QTimeZone::systemTimeZoneId());
+ ensureVtimezone(root, form.tzid);
+ }
+ return form;
+}
+
+/// Writes the fields the form owns onto `c`. RRULE only for a master.
+void writeFields(icalcomponent *root, icalcomponent *c, const EventEdit &edit, bool master)
+{
+ setText(c, ICAL_SUMMARY_PROPERTY, edit.summary);
+ setText(c, ICAL_LOCATION_PROPERTY, edit.location);
+ setText(c, ICAL_DESCRIPTION_PROPERTY, edit.description);
+ const TimeForm form = editedForm(root, c, edit.allDay);
+ removeAll(c, ICAL_DURATION_PROPERTY);
+ setTime(c, ICAL_DTSTART_PROPERTY, edit.start, form);
+ setTime(c, ICAL_DTEND_PROPERTY, edit.end, form);
+
+ if (!master || edit.repeat.custom)
+ return; // a custom RRULE is never rewritten (spec, repeat control)
+ removeAll(c, ICAL_RRULE_PROPERTY);
+ if (edit.repeat.freq == RepeatRule::Freq::None)
+ return;
+ // UNTIL follows DTSTART's type: a DATE for all-day, else UTC (RFC 5545).
+ QString until;
+ if (edit.repeat.end == RepeatRule::End::Until) {
+ until = edit.allDay
+ ? edit.repeat.until.toString(QStringLiteral("yyyyMMdd"))
+ : QDateTime(edit.repeat.until, QTime(23, 59, 59),
+ form.kind == TimeForm::Zoned && QTimeZone(form.tzid).isValid()
+ ? QTimeZone(form.tzid) : QTimeZone::systemTimeZone())
+ .toUTC().toString(QStringLiteral("yyyyMMdd'T'HHmmss'Z'"));
+ }
+ const QByteArray rule = "RRULE:" + edit.repeat.toRRule(until).toUtf8();
+ icalcomponent_add_property(c, icalproperty_new_from_string(rule.constData()));
+}
+
+QByteArray serialise(icalcomponent *root)
+{
+ IcalString text(icalcomponent_as_ical_string_r(root));
+ return text ? QByteArray(text.get()) : QByteArray();
+}
+```
+
+- [ ] **Step 4: Implement `applyEdit` (All only for now), `newEvent`, `sameMeaning`**
+
+```cpp
+QByteArray applyEdit(const QByteArray &text, const EventEdit &edit,
+ Scope scope, const QDateTime &recurrenceId)
+{
+ IcalComponent root = parseRoot(text);
+ icalcomponent *master = root ? masterOf(root.get()) : nullptr;
+ if (!master)
+ return {};
+ icalcomponent *target = master;
+ if (scope == Scope::ThisOccurrence)
+ target = overrideFor(root.get(), master, recurrenceId); // Task 7
+ writeFields(root.get(), target, edit, target == master);
+ stamp(target, true);
+ return serialise(root.get());
+}
+
+QByteArray newEvent(const EventEdit &edit, const QByteArray &zoneId)
+{
+ IcalComponent root(icalcomponent_new(ICAL_VCALENDAR_COMPONENT));
+ icalcomponent_add_property(root.get(), icalproperty_new_version("2.0"));
+ icalcomponent_add_property(root.get(), icalproperty_new_prodid("-//qtmaildir//EN"));
+ icalcomponent *event = icalcomponent_new(ICAL_VEVENT_COMPONENT);
+ icalcomponent_add_component(root.get(), event);
+ icalcomponent_set_uid(event, QUuid::createUuid()
+ .toString(QUuid::WithoutBraces).toUtf8().constData());
+ // A DTSTART must exist before editedForm() reads it; a date one makes the
+ // form fall through to the system zone for a timed event, which is
+ // exactly the rule for new events, so pass the requested zone instead.
+ if (!edit.allDay) {
+ ensureVtimezone(root.get(), zoneId);
+ setTime(event, ICAL_DTSTART_PROPERTY, edit.start, zonedForm(zoneId));
+ } else {
+ TimeForm date;
+ date.kind = TimeForm::Date;
+ setTime(event, ICAL_DTSTART_PROPERTY, edit.start, date);
+ }
+ writeFields(root.get(), event, edit, true);
+ icalcomponent_set_sequence(event, 0);
+ stamp(event, false);
+ return serialise(root.get());
+}
+
+bool sameMeaning(const QByteArray &a, const QByteArray &b)
+{
+ bool ignored = false;
+ const CalEvent x = parseEvent(a, {}, {}, &ignored);
+ const CalEvent y = parseEvent(b, {}, {}, &ignored);
+ if (x.uid.isEmpty() || y.uid.isEmpty())
+ return x.uid.isEmpty() && y.uid.isEmpty();
+ if (x.overrides.size() != y.overrides.size())
+ return false;
+ for (int i = 0; i < x.overrides.size(); ++i) {
+ const CalOverride &p = x.overrides[i], &q = y.overrides[i];
+ if (p.recurrenceId != q.recurrenceId || p.start != q.start
+ || p.end != q.end || p.summary != q.summary || p.cancelled != q.cancelled)
+ return false;
+ }
+ return x.summary == y.summary && x.start == y.start && x.end == y.end
+ && x.allDay == y.allDay && x.repeat == y.repeat && x.exdates == y.exdates;
+}
+```
+
+`applyEdit` references `overrideFor`, which Task 7 writes. For this task add a placeholder in the anonymous namespace that returns the master, so the library links:
+
+```cpp
+icalcomponent *overrideFor(icalcomponent *, icalcomponent *master, const QDateTime &)
+{
+ return master; // replaced in Task 7
+}
+```
+
+- [ ] **Step 5: Run to verify they pass**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_calendarstore`
+Expected: all PASS. Likely snags, each a libical API detail to check in `/usr/include/libical/`: the built-in zone's own TZID (the `ensureVtimezone` rewrite); `icalproperty_new_from_string` needing the `RRULE:` prefix; and `icaltime_from_timet_with_zone` producing a date when its second argument is non-zero.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/calendarstore.cpp tests/test_calendarstore.cpp
+git commit -m "feat: edit a calendar event in place and write new ones"
+```
+
+---
+
+### Task 7: One occurrence: override and delete
+
+**Files:**
+- Modify: `src/calendarstore.cpp` (`overrideFor`, `deleteOccurrence`)
+- Test: `tests/test_calendarstore.cpp`
+
+- [ ] **Step 1: Write the failing tests**
+
+```cpp
+ void editingOneOccurrenceWritesAnOverride();
+ void editingTheSameOccurrenceAgainReplacesItsOverride();
+ void deletingOneOccurrenceAddsAnExdateAndDropsItsOverride();
+```
+
+```cpp
+namespace {
+
+const QString kDailySeries = QStringLiteral(
+ "UID:d@example.org\r\nDTSTAMP:20260901T000000Z\r\n"
+ "DTSTART;TZID=Europe/Rome:20260921T100000\r\nDTEND;TZID=Europe/Rome:20260921T110000\r\n"
+ "RRULE:FREQ=DAILY;COUNT=5\r\nSUMMARY:Daily\r\n");
+
+QDateTime rome(int d, int h)
+{
+ return QDateTime(QDate(2026, 9, d), QTime(h, 0), QTimeZone("Europe/Rome"));
+}
+
+} // namespace
+
+void TestCalendarStore::editingOneOccurrenceWritesAnOverride()
+{
+ const CalEvent series = parse(vevent(kDailySeries));
+ EventEdit edit = editOf(series);
+ edit.summary = QStringLiteral("Just this one");
+ edit.start = rome(22, 15);
+ edit.end = rome(22, 16);
+ const QByteArray after = CalendarStore::applyEdit(
+ series.rawText, edit, CalendarStore::Scope::ThisOccurrence, rome(22, 10));
+
+ const CalEvent e = reparse(after);
+ QCOMPARE(e.summary, QStringLiteral("Daily")); // master untouched
+ QCOMPARE(e.repeat.count, 5);
+ QCOMPARE(e.overrides.size(), 1);
+ QCOMPARE(e.overrides[0].recurrenceId, rome(22, 10));
+ QCOMPARE(e.overrides[0].summary, QStringLiteral("Just this one"));
+ QCOMPARE(after.count("RRULE"), 1); // the override carries none
+
+ const QList<Occurrence> occ = CalendarStore::occurrences(
+ { e }, rome(21, 0), rome(26, 0));
+ QCOMPARE(occ.size(), 5); // still five, one moved
+ QVERIFY(std::any_of(occ.cbegin(), occ.cend(),
+ [](const Occurrence &o) { return o.isOverride && o.start == rome(22, 15); }));
+}
+
+void TestCalendarStore::editingTheSameOccurrenceAgainReplacesItsOverride()
+{
+ const CalEvent series = parse(vevent(kDailySeries));
+ EventEdit edit = editOf(series);
+ edit.start = rome(22, 15);
+ edit.end = rome(22, 16);
+ QByteArray text = CalendarStore::applyEdit(
+ series.rawText, edit, CalendarStore::Scope::ThisOccurrence, rome(22, 10));
+ edit.start = rome(22, 17);
+ edit.end = rome(22, 18);
+ text = CalendarStore::applyEdit(text, edit, CalendarStore::Scope::ThisOccurrence, rome(22, 10));
+ const CalEvent e = reparse(text);
+ QCOMPARE(e.overrides.size(), 1);
+ QCOMPARE(e.overrides[0].start, rome(22, 17));
+}
+
+void TestCalendarStore::deletingOneOccurrenceAddsAnExdateAndDropsItsOverride()
+{
+ const CalEvent series = parse(vevent(kDailySeries));
+ EventEdit edit = editOf(series);
+ edit.start = rome(23, 15);
+ edit.end = rome(23, 16);
+ const QByteArray withOverride = CalendarStore::applyEdit(
+ series.rawText, edit, CalendarStore::Scope::ThisOccurrence, rome(23, 10));
+
+ const QByteArray after = CalendarStore::deleteOccurrence(withOverride, rome(23, 10));
+ const CalEvent e = reparse(after);
+ QCOMPARE(e.overrides.size(), 0);
+ QCOMPARE(e.exdates, QList<QDateTime>{ rome(23, 10) });
+ QVERIFY2(after.contains("EXDATE;TZID=Europe/Rome:20260923T100000"), after.constData());
+ QCOMPARE(CalendarStore::occurrences({ e }, rome(21, 0), rome(26, 0)).size(), 4);
+}
+```
+
+Add `#include <algorithm>` to the test's includes.
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_calendarstore`
+Expected: the three new tests FAIL (the placeholder edits the master).
+
+- [ ] **Step 3: Implement `overrideFor` and `deleteOccurrence`**
+
+Replace the Task 6 placeholder:
+
+```cpp
+/// The override component for `recurrenceId`, created when absent: a copy of
+/// the master without RRULE, RDATE or EXDATE, carrying a RECURRENCE-ID in the
+/// master's own time form.
+icalcomponent *overrideFor(icalcomponent *root, icalcomponent *master,
+ const QDateTime &recurrenceId)
+{
+ for (icalcomponent *c = icalcomponent_get_first_component(root, ICAL_VEVENT_COMPONENT);
+ c; c = icalcomponent_get_next_component(root, ICAL_VEVENT_COMPONENT)) {
+ icalproperty *rid = icalcomponent_get_first_property(c, ICAL_RECURRENCEID_PROPERTY);
+ if (c != master && rid
+ && toDateTime(icalcomponent_get_recurrenceid(c), tzidOf(rid), nullptr) == recurrenceId)
+ return c;
+ }
+ icalcomponent *copy = icalcomponent_new_clone(master);
+ removeAll(copy, ICAL_RRULE_PROPERTY);
+ removeAll(copy, ICAL_RDATE_PROPERTY);
+ removeAll(copy, ICAL_EXDATE_PROPERTY);
+ setTime(copy, ICAL_RECURRENCEID_PROPERTY, recurrenceId, formOf(root, master));
+ icalcomponent_add_component(root, copy);
+ return copy;
+}
+```
+
+In `deleteOccurrence`, remove the matching override component and add the EXDATE:
+
+```cpp
+QByteArray deleteOccurrence(const QByteArray &text, const QDateTime &recurrenceId)
+{
+ IcalComponent root = parseRoot(text);
+ icalcomponent *master = root ? masterOf(root.get()) : nullptr;
+ if (!master)
+ return {};
+ // Collect first: removing a component mid-walk invalidates the iterator.
+ QList<icalcomponent *> doomed;
+ for (icalcomponent *c = icalcomponent_get_first_component(root.get(), ICAL_VEVENT_COMPONENT);
+ c; c = icalcomponent_get_next_component(root.get(), ICAL_VEVENT_COMPONENT)) {
+ icalproperty *rid = icalcomponent_get_first_property(c, ICAL_RECURRENCEID_PROPERTY);
+ if (c != master && rid
+ && toDateTime(icalcomponent_get_recurrenceid(c), tzidOf(rid), nullptr) == recurrenceId)
+ doomed.append(c);
+ }
+ for (icalcomponent *c : doomed) {
+ icalcomponent_remove_component(root.get(), c);
+ icalcomponent_free(c);
+ }
+ // setTime replaces every property of its kind, which for EXDATE would
+ // drop the existing ones: add this one alongside instead.
+ const TimeForm form = formOf(root.get(), master);
+ icalproperty *ex = icalproperty_new_exdate(wallTime(recurrenceId, form));
+ if (form.kind == TimeForm::Zoned)
+ icalproperty_add_parameter(ex, icalparameter_new_tzid(form.tzid.constData()));
+ icalcomponent_add_property(master, ex);
+ stamp(master, true);
+ return serialise(root.get());
+}
+```
+
+- [ ] **Step 4: Run to verify they pass**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_calendarstore`
+Expected: all PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/calendarstore.cpp tests/test_calendarstore.cpp
+git commit -m "feat: edit or delete one occurrence of a repeating event"
+```
+
+---
+
+### Task 8: `CalendarWriter`
+
+**Files:**
+- Create: `src/calendarwriter.h`, `src/calendarwriter.cpp`
+- Modify: `src/CMakeLists.txt` (add `calendarwriter.cpp`), `tests/CMakeLists.txt` (`add_qtmaildir_test(calendarwriter)`)
+- Test: `tests/test_calendarwriter.cpp`
+
+- [ ] **Step 1: Write the failing tests**
+
+```cpp
+/* GPLv2 header */
+
+#include <QtTest>
+#include <QDir>
+#include <QFile>
+#include <QTemporaryDir>
+
+#include "calendarwriter.h"
+
+namespace {
+
+QByteArray read(const QString &path)
+{
+ QFile f(path);
+ return f.open(QIODevice::ReadOnly) ? f.readAll() : QByteArray();
+}
+
+} // namespace
+
+class TestCalendarWriter : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void createsAFileThatDidNotExist();
+ void refusesToCreateOverAnExistingFile();
+ void replacesWhenTheFileIsWhatWasExpected();
+ void refusesWhenTheFileChangedUnderneath();
+ void deletes();
+ void leavesNoTemporaryFileBehind();
+};
+
+void TestCalendarWriter::createsAFileThatDidNotExist()
+{
+ QTemporaryDir dir;
+ const QString path = dir.filePath(QStringLiteral("a.ics"));
+ QString error;
+ QCOMPARE(CalendarWriter::replace(path, std::nullopt, QByteArray("new"), &error),
+ CalendarWriter::Result::Ok);
+ QCOMPARE(read(path), QByteArray("new"));
+}
+
+void TestCalendarWriter::refusesToCreateOverAnExistingFile()
+{
+ QTemporaryDir dir;
+ const QString path = dir.filePath(QStringLiteral("a.ics"));
+ QFile f(path);
+ QVERIFY(f.open(QIODevice::WriteOnly));
+ f.write("synced in meanwhile");
+ f.close();
+ QString error;
+ QCOMPARE(CalendarWriter::replace(path, std::nullopt, QByteArray("new"), &error),
+ CalendarWriter::Result::Stale);
+ QCOMPARE(read(path), QByteArray("synced in meanwhile"));
+}
+
+void TestCalendarWriter::replacesWhenTheFileIsWhatWasExpected()
+{
+ QTemporaryDir dir;
+ const QString path = dir.filePath(QStringLiteral("a.ics"));
+ QString error;
+ CalendarWriter::replace(path, std::nullopt, QByteArray("one"), &error);
+ QCOMPARE(CalendarWriter::replace(path, QByteArray("one"), QByteArray("two"), &error),
+ CalendarWriter::Result::Ok);
+ QCOMPARE(read(path), QByteArray("two"));
+}
+
+void TestCalendarWriter::refusesWhenTheFileChangedUnderneath()
+{
+ QTemporaryDir dir;
+ const QString path = dir.filePath(QStringLiteral("a.ics"));
+ QString error;
+ CalendarWriter::replace(path, std::nullopt, QByteArray("synced"), &error);
+ // The caller loaded "one"; a sync wrote "synced" since. Nothing is clobbered.
+ QCOMPARE(CalendarWriter::replace(path, QByteArray("one"), QByteArray("two"), &error),
+ CalendarWriter::Result::Stale);
+ QCOMPARE(read(path), QByteArray("synced"));
+ // Also stale: expecting a file that a sync has removed.
+ QFile::remove(path);
+ QCOMPARE(CalendarWriter::replace(path, QByteArray("synced"), QByteArray("two"), &error),
+ CalendarWriter::Result::Stale);
+ QVERIFY(!QFile::exists(path));
+}
+
+void TestCalendarWriter::deletes()
+{
+ QTemporaryDir dir;
+ const QString path = dir.filePath(QStringLiteral("a.ics"));
+ QString error;
+ CalendarWriter::replace(path, std::nullopt, QByteArray("one"), &error);
+ QCOMPARE(CalendarWriter::replace(path, QByteArray("one"), std::nullopt, &error),
+ CalendarWriter::Result::Ok);
+ QVERIFY(!QFile::exists(path));
+}
+
+void TestCalendarWriter::leavesNoTemporaryFileBehind()
+{
+ QTemporaryDir dir;
+ const QString path = dir.filePath(QStringLiteral("a.ics"));
+ QString error;
+ CalendarWriter::replace(path, std::nullopt, QByteArray("one"), &error);
+ CalendarWriter::replace(path, QByteArray("one"), QByteArray("two"), &error);
+ QCOMPARE(QDir(dir.path()).entryList(QDir::Files | QDir::Hidden),
+ QStringList{ QStringLiteral("a.ics") });
+}
+
+QTEST_MAIN(TestCalendarWriter)
+#include "test_calendarwriter.moc"
+```
+
+- [ ] **Step 2: Write the header**
+
+`src/calendarwriter.h`:
+
+```cpp
+/* GPLv2 header */
+
+#pragma once
+
+#include <QByteArray>
+#include <QString>
+
+#include <optional>
+
+/// Every write to the calendar vdir goes through here.
+///
+/// One function covers create, edit, delete and every undo of those, because
+/// each is the same act: the file must currently be X, and must become Y,
+/// where either may be "absent". vdirsyncer writes the same files from cron,
+/// so "must currently be X" is the stale check that stops a save from
+/// silently discarding what a sync just pulled.
+namespace CalendarWriter {
+
+enum class Result { Ok, Stale, IoError };
+
+/// `expected` nullopt: the file must not exist. `newText` nullopt: delete it.
+/// A write goes to a hidden temporary in the same directory and is renamed
+/// into place, so vdirsyncer, which lists only *.ics, never reads half a file.
+Result replace(const QString &path, const std::optional<QByteArray> &expected,
+ const std::optional<QByteArray> &newText, QString *error);
+
+} // namespace CalendarWriter
+```
+
+- [ ] **Step 3: Run to verify they fail**
+
+Run: `cmake --build build 2>&1 | tail -3`
+Expected: link failure, undefined `CalendarWriter::replace`.
+
+- [ ] **Step 4: Implement**
+
+`src/calendarwriter.cpp`:
+
+```cpp
+/* GPLv2 header */
+
+#include "calendarwriter.h"
+
+#include <QFile>
+#include <QFileInfo>
+#include <QSaveFile>
+
+namespace CalendarWriter {
+
+Result replace(const QString &path, const std::optional<QByteArray> &expected,
+ const std::optional<QByteArray> &newText, QString *error)
+{
+ QFile current(path);
+ const bool exists = current.exists();
+ if (expected.has_value() != exists)
+ return Result::Stale;
+ if (exists) {
+ if (!current.open(QIODevice::ReadOnly)) {
+ *error = current.errorString();
+ return Result::IoError;
+ }
+ if (current.readAll() != *expected)
+ return Result::Stale;
+ current.close();
+ }
+
+ if (!newText) {
+ if (!QFile::remove(path)) {
+ *error = QFile(path).errorString();
+ return Result::IoError;
+ }
+ return Result::Ok;
+ }
+
+ // QSaveFile is the platform's atomic write: a temporary in the same
+ // directory, renamed on commit(). Its temporary is named "<name>.XXXXXX",
+ // which does not end in .ics, so vdirsyncer never lists it.
+ QSaveFile file(path);
+ if (!file.open(QIODevice::WriteOnly) || file.write(*newText) != newText->size()
+ || !file.commit()) {
+ *error = file.errorString();
+ return Result::IoError;
+ }
+ return Result::Ok;
+}
+
+} // namespace CalendarWriter
+```
+
+`QSaveFile` replaces the hand-rolled `.<name>.tmp` + `rename()` the spec describes: same guarantee, stdlib-of-Qt, fewer lines (ponytail ladder, rung 2). Record that in Task 16's spec amendment.
+
+- [ ] **Step 5: Run to verify they pass**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_calendarwriter`
+Expected: all PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/calendarwriter.h src/calendarwriter.cpp src/CMakeLists.txt tests/CMakeLists.txt \
+ tests/test_calendarwriter.cpp
+git commit -m "feat: write calendar files atomically, refusing stale ones"
+```
+
+---
+
+### Task 9: Config keys
+
+**Files:**
+- Modify: `src/config.h` (accessors beside `contactsDir()`, members beside `m_contactsDir`)
+- Modify: `src/config.cpp` (read beside `contacts_dir`)
+- Test: `tests/test_config.cpp`
+
+- [ ] **Step 1: Write the failing tests**
+
+Add beside the `contactsDir*` slots, following their `writeIni` helper:
+
+```cpp
+ void calendarKeysAreOffWithoutThem();
+ void calendarKeysAreRead();
+ void calendarSyncCommandSurvivesACommaAndDefaults();
+```
+
+```cpp
+void TestConfig::calendarKeysAreOffWithoutThem()
+{
+ // Plan ruling 1: absent is OFF, silently, as contacts_dir is.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral("[general]\n")));
+ QVERIFY(config.calendarsDir().isEmpty());
+ QVERIFY(config.problems().isEmpty());
+}
+
+void TestConfig::calendarKeysAreRead()
+{
+ QTemporaryDir dir;
+ const QString cal = dir.filePath(QStringLiteral("calendars"));
+ QDir().mkpath(cal);
+ Config config;
+ config.load(writeIni(dir, QStringLiteral("[general]\n"
+ "calendars_dir = %1\n"
+ "default_calendar = 52\n"
+ "calendar_sync_delay_ms = 500\n").arg(cal)));
+ QCOMPARE(config.calendarsDir(), cal);
+ QCOMPARE(config.defaultCalendar(), QStringLiteral("52"));
+ QCOMPARE(config.calendarSyncDelayMs(), 500);
+ QVERIFY(config.problems().isEmpty());
+}
+
+void TestConfig::calendarSyncCommandSurvivesACommaAndDefaults()
+{
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral("[general]\n")));
+ QCOMPARE(config.calendarSyncCommand(),
+ QStringLiteral("flock -w 60 /tmp/vdirsyncer.lock vdirsyncer sync calendars"));
+ QCOMPARE(config.calendarSyncDelayMs(), 2000);
+
+ // QSettings splits an unquoted value on commas into a QStringList, and
+ // toString() on a list is EMPTY: a command holding a comma would silently
+ // turn syncing off. The reader joins the list back.
+ Config commas;
+ commas.load(writeIni(dir, QStringLiteral("[general]\n"
+ "calendar_sync_command = mysync --only a,b\n")));
+ QCOMPARE(commas.calendarSyncCommand(), QStringLiteral("mysync --only a,b"));
+
+ // Written but empty disables, which is different from absent.
+ Config off;
+ off.load(writeIni(dir, QStringLiteral("[general]\ncalendar_sync_command = \n")));
+ QVERIFY(off.calendarSyncCommand().isEmpty());
+}
+```
+
+Check that `writeIni` overwrites the same file on a second call in one test; if it creates `qtmaildir.conf` once and refuses, use a fresh `QTemporaryDir` per `Config`.
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `cmake --build build 2>&1 | tail -3`
+Expected: compile error, `calendarsDir` is not a member of `Config`.
+
+- [ ] **Step 3: Implement**
+
+`src/config.h`, beside `contactsDir()`:
+
+```cpp
+ /// The vdirsyncer calendar root (item 206). Empty: the calendar is off.
+ QString calendarsDir() const { return m_calendarsDir; }
+ /// Directory name of the collection new events go to; empty means the
+ /// first collection by display name.
+ QString defaultCalendar() const { return m_defaultCalendar; }
+ /// Run after calendar writes. Empty: no sync.
+ QString calendarSyncCommand() const { return m_calendarSyncCommand; }
+ int calendarSyncDelayMs() const { return m_calendarSyncDelayMs; }
+```
+
+Members beside `m_contactsDir`:
+
+```cpp
+ QString m_calendarsDir;
+ QString m_defaultCalendar;
+ QString m_calendarSyncCommand =
+ QStringLiteral("flock -w 60 /tmp/vdirsyncer.lock vdirsyncer sync calendars");
+ int m_calendarSyncDelayMs = 2000;
+```
+
+`src/config.cpp`, directly after the `contacts_dir` block:
+
+```cpp
+ // Item 206. The same shape as contacts_dir: absent or blank is the feature
+ // off, silently; set and missing is reported and kept, since a vdirsyncer
+ // target may not exist before its first run.
+ const QString calendarsDir =
+ settings.value(QStringLiteral("calendars_dir")).toString().trimmed();
+ if (!calendarsDir.isEmpty()) {
+ m_calendarsDir = expandTilde(calendarsDir);
+ if (!QFileInfo::exists(m_calendarsDir)) {
+ addProblem(tr("Calendars directory '%1' does not exist; the "
+ "calendar will be empty.").arg(m_calendarsDir));
+ }
+ }
+ m_defaultCalendar =
+ settings.value(QStringLiteral("default_calendar")).toString().trimmed();
+
+ // An unquoted value holding a comma reaches here as a QStringList, whose
+ // toString() is empty; join it back rather than silently losing the
+ // command. Absent keeps the default; present and blank disables.
+ const QVariant calSync = settings.value(QStringLiteral("calendar_sync_command"));
+ if (calSync.isValid()) {
+ m_calendarSyncCommand = calSync.typeId() == QMetaType::QStringList
+ ? calSync.toStringList().join(QLatin1Char(','))
+ : calSync.toString();
+ m_calendarSyncCommand = m_calendarSyncCommand.trimmed();
+ }
+ const QVariant calDelay = settings.value(QStringLiteral("calendar_sync_delay_ms"));
+ if (calDelay.isValid()) {
+ bool ok = false;
+ const int value = calDelay.toString().toInt(&ok);
+ if (ok && value >= 0)
+ m_calendarSyncDelayMs = value;
+ else
+ addProblem(tr("Calendar sync delay '%1' is not a number; using the default.")
+ .arg(calDelay.toString()));
+ }
+```
+
+`QStringList::join` on a list QSettings split at `a,b` rejoins with no spaces, which matches the original only because QSettings split without trimming. Verify with the test before trusting it; if spaces are lost, the documented workaround is quoting the value in the INI, and the test should assert that instead.
+
+- [ ] **Step 4: Run to verify they pass**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_config`
+Expected: all PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/config.h src/config.cpp tests/test_config.cpp
+git commit -m "feat: read the calendar's config keys"
+```
+
+---
+
+### Task 10: `CalendarSync`
+
+**Files:**
+- Create: `src/calendarsync.h`, `src/calendarsync.cpp`
+- Modify: `src/CMakeLists.txt`, `tests/CMakeLists.txt` (`add_qtmaildir_test(calendarsync)`)
+- Test: `tests/test_calendarsync.cpp`
+
+- [ ] **Step 1: Write the failing tests**
+
+The command is a real shell-free process: `sh -c` would defeat `splitCommand`, so the test uses a tiny script written into a temp dir.
+
+```cpp
+/* GPLv2 header */
+
+#include <QtTest>
+#include <QFile>
+#include <QSignalSpy>
+#include <QTemporaryDir>
+
+#include "calendarsync.h"
+
+namespace {
+
+/// A script that appends a line to `log` each run and exits with `code`.
+QString script(const QTemporaryDir &dir, int code)
+{
+ const QString path = dir.filePath(QStringLiteral("sync.sh"));
+ QFile f(path);
+ f.open(QIODevice::WriteOnly);
+ f.write(QStringLiteral("#!/bin/sh\necho run >> %1\necho output\nexit %2\n")
+ .arg(dir.filePath(QStringLiteral("log"))).arg(code).toUtf8());
+ f.close();
+ f.setPermissions(f.permissions() | QFileDevice::ExeOwner);
+ return path;
+}
+
+int runs(const QTemporaryDir &dir)
+{
+ QFile f(dir.filePath(QStringLiteral("log")));
+ return f.open(QIODevice::ReadOnly) ? f.readAll().count('\n') : 0;
+}
+
+} // namespace
+
+class TestCalendarSync : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void aBurstOfWritesRunsOnce();
+ void reportsFailureWithTheOutput();
+ void anEmptyCommandNeverRuns();
+};
+
+void TestCalendarSync::aBurstOfWritesRunsOnce()
+{
+ QTemporaryDir dir;
+ CalendarSync sync(script(dir, 0), 50);
+ QSignalSpy finished(&sync, &CalendarSync::finished);
+ sync.schedule();
+ sync.schedule();
+ sync.schedule();
+ QVERIFY(finished.wait(5000));
+ QCOMPARE(finished.first().at(0).toBool(), true);
+ QTest::qWait(200); // nothing else is queued, so nothing else may run
+ QCOMPARE(runs(dir), 1);
+}
+
+void TestCalendarSync::reportsFailureWithTheOutput()
+{
+ QTemporaryDir dir;
+ CalendarSync sync(script(dir, 3), 0);
+ QSignalSpy finished(&sync, &CalendarSync::finished);
+ sync.schedule();
+ QVERIFY(finished.wait(5000));
+ QCOMPARE(finished.first().at(0).toBool(), false);
+ QVERIFY(finished.first().at(1).toString().contains(QStringLiteral("output")));
+}
+
+void TestCalendarSync::anEmptyCommandNeverRuns()
+{
+ CalendarSync sync(QString(), 0);
+ QSignalSpy started(&sync, &CalendarSync::started);
+ sync.schedule();
+ QTest::qWait(100);
+ QCOMPARE(started.count(), 0);
+}
+
+QTEST_MAIN(TestCalendarSync)
+#include "test_calendarsync.moc"
+```
+
+`aBurstOfWritesRunsOnce` waits a fixed 200 ms to assert a NEGATIVE, which AGENTS.md warns passes when the thing never arrives. Here that is the assertion's point (no second run) and the positive half is waited on properly first, so it stands; do not copy the pattern for a positive check.
+
+- [ ] **Step 2: Write the header**
+
+```cpp
+/* GPLv2 header */
+
+#pragma once
+
+#include <QObject>
+#include <QProcess>
+#include <QTimer>
+
+/// Runs calendar_sync_command a short delay after the last write.
+///
+/// The calendar's counterpart to MailSync, and deliberately smaller: no lock
+/// monitoring and no status file, because the command itself takes the
+/// flock that cron shares. A write arriving while a run is in progress is not
+/// dropped: it queues exactly one more run for when this one finishes, since
+/// the running sync may have listed the vdir before the write landed.
+class CalendarSync : public QObject
+{
+ Q_OBJECT
+
+public:
+ CalendarSync(const QString &command, int delayMs, QObject *parent = nullptr);
+
+ void schedule();
+ bool isRunning() const;
+
+signals:
+ void started();
+ void finished(bool ok, const QString &output);
+
+private:
+ void run();
+
+ QString m_command;
+ QTimer m_timer;
+ QProcess m_process;
+ QByteArray m_output;
+ bool m_again = false;
+};
+```
+
+- [ ] **Step 3: Run to verify they fail**
+
+Run: `cmake --build build 2>&1 | tail -3`
+Expected: link failure for `CalendarSync`.
+
+- [ ] **Step 4: Implement**
+
+```cpp
+/* GPLv2 header */
+
+#include "calendarsync.h"
+
+CalendarSync::CalendarSync(const QString &command, int delayMs, QObject *parent)
+ : QObject(parent), m_command(command)
+{
+ m_timer.setSingleShot(true);
+ m_timer.setInterval(delayMs);
+ connect(&m_timer, &QTimer::timeout, this, &CalendarSync::run);
+ m_process.setProcessChannelMode(QProcess::MergedChannels);
+ connect(&m_process, &QProcess::readyRead, this,
+ [this]() { m_output += m_process.readAll(); });
+ connect(&m_process, &QProcess::finished, this,
+ [this](int code, QProcess::ExitStatus status) {
+ emit finished(status == QProcess::NormalExit && code == 0,
+ QString::fromLocal8Bit(m_output));
+ if (m_again) {
+ m_again = false;
+ m_timer.start();
+ }
+ });
+ connect(&m_process, &QProcess::errorOccurred, this,
+ [this](QProcess::ProcessError error) {
+ if (error == QProcess::FailedToStart)
+ emit finished(false, m_process.errorString());
+ });
+}
+
+void CalendarSync::schedule()
+{
+ if (m_command.isEmpty())
+ return;
+ if (isRunning())
+ m_again = true;
+ else
+ m_timer.start(); // restarting is the debounce
+}
+
+bool CalendarSync::isRunning() const
+{
+ return m_process.state() != QProcess::NotRunning;
+}
+
+void CalendarSync::run()
+{
+ // splitCommand, not a shell: quoting works and nothing is interpreted.
+ QStringList parts = QProcess::splitCommand(m_command);
+ if (parts.isEmpty())
+ return;
+ const QString program = parts.takeFirst();
+ m_output.clear();
+ emit started();
+ m_process.start(program, parts);
+}
+```
+
+- [ ] **Step 5: Run to verify they pass**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_calendarsync`
+Expected: all PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/calendarsync.h src/calendarsync.cpp src/CMakeLists.txt tests/CMakeLists.txt \
+ tests/test_calendarsync.cpp
+git commit -m "feat: debounce the calendar sync command after writes"
+```
+
+---
+
+### Task 11: `MonthLayout`
+
+**Files:**
+- Create: `src/monthlayout.h`, `src/monthlayout.cpp`
+- Modify: `src/CMakeLists.txt`, `tests/CMakeLists.txt` (`add_qtmaildir_test(monthlayout)`)
+- Test: `tests/test_monthlayout.cpp`
+
+- [ ] **Step 1: Write the failing tests**
+
+```cpp
+/* GPLv2 header */
+
+#include <QtTest>
+
+#include "monthlayout.h"
+
+class TestMonthLayout : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void theGridStartsOnTheLocalesFirstDay();
+ void cellsTileTheAreaWithNoGapOrOverlap();
+ void chipsThatDoNotFitBecomeMore();
+ void aPointFindsItsCell();
+};
+
+void TestMonthLayout::theGridStartsOnTheLocalesFirstDay()
+{
+ // 2026-09-01 is a Tuesday.
+ QCOMPARE(MonthLayout::gridStart(2026, 9, Qt::Monday), QDate(2026, 8, 31));
+ QCOMPARE(MonthLayout::gridStart(2026, 9, Qt::Sunday), QDate(2026, 8, 30));
+ // A month starting ON the first weekday starts on its own first day.
+ QCOMPARE(MonthLayout::gridStart(2026, 6, Qt::Monday), QDate(2026, 6, 1));
+ const MonthLayout layout(QRect(0, 0, 700, 600), 2026, 9, Qt::Monday, 20, 16);
+ QCOMPARE(layout.dateAt(0), QDate(2026, 8, 31));
+ QCOMPARE(layout.dateAt(41), QDate(2026, 10, 11));
+}
+
+void TestMonthLayout::cellsTileTheAreaWithNoGapOrOverlap()
+{
+ // 703 does not divide by 7: the remainder must go somewhere, not vanish
+ // as a gap down the right edge.
+ const QRect area(0, 0, 703, 605);
+ const MonthLayout layout(area, 2026, 9, Qt::Monday, 20, 16);
+ QCOMPARE(layout.cellRect(0).left(), area.left());
+ QCOMPARE(layout.cellRect(6).right(), area.right());
+ QCOMPARE(layout.cellRect(41).bottom(), area.bottom());
+ for (int i = 0; i < 6; ++i)
+ QCOMPARE(layout.cellRect(i).right() + 1, layout.cellRect(i + 1).left());
+ QCOMPARE(layout.cellRect(0).bottom() + 1, layout.cellRect(7).top());
+}
+
+void TestMonthLayout::chipsThatDoNotFitBecomeMore()
+{
+ // 600 / 6 rows = 100 px a cell; 20 px of day number; 16 px chips: 5 fit.
+ const MonthLayout layout(QRect(0, 0, 700, 600), 2026, 9, Qt::Monday, 20, 16);
+ QCOMPARE(layout.capacity(), 5);
+ QCOMPARE(layout.visibleChips(3), 3);
+ QCOMPARE(layout.visibleChips(5), 5);
+ // Six do not fit: four chips and a "+2 more" line in the fifth slot.
+ QCOMPARE(layout.visibleChips(6), 4);
+ QCOMPARE(layout.chipRect(0, 0).top(), layout.cellRect(0).top() + 20);
+ QVERIFY(layout.cellRect(0).contains(layout.chipRect(0, 4)));
+}
+
+void TestMonthLayout::aPointFindsItsCell()
+{
+ const MonthLayout layout(QRect(0, 0, 700, 600), 2026, 9, Qt::Monday, 20, 16);
+ QCOMPARE(layout.cellAt(layout.cellRect(17).center()), 17);
+ QCOMPARE(layout.cellAt(QPoint(-5, 10)), -1);
+}
+
+QTEST_MAIN(TestMonthLayout)
+#include "test_monthlayout.moc"
+```
+
+- [ ] **Step 2: Write the header**
+
+```cpp
+/* GPLv2 header */
+
+#pragma once
+
+#include <QDate>
+#include <QPoint>
+#include <QRect>
+
+/// Where everything in the month grid goes, with no painter and no widget.
+///
+/// The CardLayout precedent: a geometric claim is tested as a function call,
+/// because AGENTS.md records how rendering probes lie. Six rows of seven
+/// always, so the grid does not jump in height between months.
+class MonthLayout
+{
+public:
+ static constexpr int kCells = 42;
+
+ MonthLayout(const QRect &area, int year, int month, Qt::DayOfWeek firstDay,
+ int dayNumberHeight, int chipHeight);
+
+ static QDate gridStart(int year, int month, Qt::DayOfWeek firstDay);
+
+ QDate dateAt(int cell) const;
+ int cellOf(const QDate &date) const; ///< -1 when not on the grid.
+ QRect cellRect(int cell) const;
+ int cellAt(const QPoint &point) const; ///< -1 outside the grid.
+
+ /// How many chip slots fit under a cell's day number.
+ int capacity() const;
+ /// How many of `total` chips are drawn; when fewer than `total`, the
+ /// next slot holds "+N more".
+ int visibleChips(int total) const;
+ QRect chipRect(int cell, int slot) const;
+
+private:
+ QRect m_area;
+ QDate m_start;
+ int m_dayNumberHeight;
+ int m_chipHeight;
+};
+```
+
+- [ ] **Step 3: Run to verify they fail**
+
+Run: `cmake --build build 2>&1 | tail -3`
+Expected: link failure.
+
+- [ ] **Step 4: Implement**
+
+```cpp
+/* GPLv2 header */
+
+#include "monthlayout.h"
+
+MonthLayout::MonthLayout(const QRect &area, int year, int month, Qt::DayOfWeek firstDay,
+ int dayNumberHeight, int chipHeight)
+ : m_area(area), m_start(gridStart(year, month, firstDay)),
+ m_dayNumberHeight(dayNumberHeight), m_chipHeight(chipHeight)
+{
+}
+
+QDate MonthLayout::gridStart(int year, int month, Qt::DayOfWeek firstDay)
+{
+ const QDate first(year, month, 1);
+ const int back = (first.dayOfWeek() - firstDay + 7) % 7;
+ return first.addDays(-back);
+}
+
+QDate MonthLayout::dateAt(int cell) const { return m_start.addDays(cell); }
+
+int MonthLayout::cellOf(const QDate &date) const
+{
+ const qint64 cell = m_start.daysTo(date);
+ return cell >= 0 && cell < kCells ? int(cell) : -1;
+}
+
+QRect MonthLayout::cellRect(int cell) const
+{
+ // Edges computed from the area by proportion, so the rounding remainder
+ // is spread across cells and the last edge lands exactly on the area's.
+ const int col = cell % 7, row = cell / 7;
+ const int left = m_area.left() + m_area.width() * col / 7;
+ const int right = m_area.left() + m_area.width() * (col + 1) / 7; // exclusive
+ const int top = m_area.top() + m_area.height() * row / 6;
+ const int bottom = m_area.top() + m_area.height() * (row + 1) / 6;
+ return QRect(QPoint(left, top), QPoint(right - 1, bottom - 1));
+}
+
+int MonthLayout::cellAt(const QPoint &point) const
+{
+ if (!m_area.contains(point))
+ return -1;
+ const int col = (point.x() - m_area.left()) * 7 / m_area.width();
+ const int row = (point.y() - m_area.top()) * 6 / m_area.height();
+ return row * 7 + col;
+}
+
+int MonthLayout::capacity() const
+{
+ const int height = m_area.height() / 6 - m_dayNumberHeight;
+ return m_chipHeight > 0 ? qMax(0, height / m_chipHeight) : 0;
+}
+
+int MonthLayout::visibleChips(int total) const
+{
+ const int cap = capacity();
+ return total <= cap ? total : qMax(0, cap - 1);
+}
+
+QRect MonthLayout::chipRect(int cell, int slot) const
+{
+ const QRect c = cellRect(cell);
+ return QRect(c.left() + 2, c.top() + m_dayNumberHeight + slot * m_chipHeight,
+ c.width() - 4, m_chipHeight - 1);
+}
+```
+
+`QRect::right()` is inclusive (AGENTS.md, CardLayout): the exclusive edge is carried as a local and converted once in `cellRect`, which is what `cellsTileTheAreaWithNoGapOrOverlap` guards.
+
+- [ ] **Step 5: Run to verify they pass**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_monthlayout`
+Expected: all PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/monthlayout.h src/monthlayout.cpp src/CMakeLists.txt tests/CMakeLists.txt \
+ tests/test_monthlayout.cpp
+git commit -m "feat: lay out the calendar's month grid without a painter"
+```
+
+---
+
+### Task 12: `MonthView` and `AgendaView`
+
+These are painting and list widgets: their correctness is the user's hand test (AGENTS.md: a green suite is not evidence a UI design is right). No tests here; the geometry they rely on is Task 11's.
+
+**Files:**
+- Create: `src/monthview.h`, `src/monthview.cpp`, `src/agendaview.h`, `src/agendaview.cpp`
+- Modify: `src/CMakeLists.txt`
+
+- [ ] **Step 1: Write `src/monthview.h`**
+
+```cpp
+/* GPLv2 header */
+
+#pragma once
+
+#include "caltypes.h"
+#include "monthlayout.h"
+
+#include <QWidget>
+
+/// The month grid. Paints a MonthLayout; knows nothing of libical, files or
+/// CalEvent, only the CalendarItems it is handed.
+class MonthView : public QWidget
+{
+ Q_OBJECT
+
+public:
+ explicit MonthView(QWidget *parent = nullptr);
+
+ void setMonth(int year, int month);
+ void setItems(const QList<CalendarItem> &items);
+ /// Index into the items, or -1. Drawn with an outline.
+ void setSelected(int item);
+
+ /// The first and one-past-last day on the grid, for the caller's query.
+ QDate firstDay() const;
+ QDate lastDay() const;
+
+signals:
+ void itemClicked(int item);
+ void dayDoubleClicked(const QDate &day);
+ void moreClicked(const QDate &day);
+
+protected:
+ void paintEvent(QPaintEvent *event) override;
+ void mousePressEvent(QMouseEvent *event) override;
+ void mouseDoubleClickEvent(QMouseEvent *event) override;
+
+private:
+ MonthLayout layout() const;
+ /// Items on each cell, in start order; a multi-day item is on every cell
+ /// it covers (spec, Month view).
+ QList<QList<int>> itemsPerCell(const MonthLayout &layout) const;
+
+ int m_year;
+ int m_month;
+ QList<CalendarItem> m_items;
+ int m_selected = -1;
+};
+```
+
+- [ ] **Step 2: Write `src/monthview.cpp`**
+
+```cpp
+/* GPLv2 header */
+
+#include "monthview.h"
+#include "monthlayout.h"
+
+#include <QLocale>
+#include <QMouseEvent>
+#include <QPainter>
+
+MonthView::MonthView(QWidget *parent)
+ : QWidget(parent),
+ m_year(QDate::currentDate().year()), m_month(QDate::currentDate().month())
+{
+ setMinimumSize(420, 320);
+}
+
+void MonthView::setMonth(int year, int month)
+{
+ m_year = year;
+ m_month = month;
+ update();
+}
+
+void MonthView::setItems(const QList<CalendarItem> &items)
+{
+ m_items = items;
+ m_selected = -1;
+ update();
+}
+
+void MonthView::setSelected(int item)
+{
+ m_selected = item;
+ update();
+}
+
+MonthLayout MonthView::layout() const
+{
+ const int line = fontMetrics().height();
+ return MonthLayout(rect().adjusted(0, line + 4, 0, 0), m_year, m_month,
+ QLocale().firstDayOfWeek(), line + 2, line + 2);
+}
+
+QDate MonthView::firstDay() const { return layout().dateAt(0); }
+QDate MonthView::lastDay() const { return layout().dateAt(MonthLayout::kCells - 1).addDays(1); }
+
+QList<QList<int>> MonthView::itemsPerCell(const MonthLayout &l) const
+{
+ QList<QList<int>> cells(MonthLayout::kCells);
+ for (int i = 0; i < m_items.size(); ++i) {
+ const Occurrence &o = m_items[i].occurrence;
+ QDate day = o.start.toLocalTime().date();
+ // The end is exclusive: an event ending at midnight does not touch
+ // the day that midnight begins.
+ const QDate last = o.end > o.start ? o.end.toLocalTime().addMSecs(-1).date() : day;
+ for (; day <= last; day = day.addDays(1)) {
+ const int cell = l.cellOf(day);
+ if (cell >= 0)
+ cells[cell].append(i);
+ }
+ }
+ return cells;
+}
+
+void MonthView::paintEvent(QPaintEvent *)
+{
+ QPainter p(this);
+ p.fillRect(rect(), palette().base());
+ const MonthLayout l = layout();
+ const QLocale locale;
+ const int line = fontMetrics().height();
+
+ // Weekday header.
+ for (int col = 0; col < 7; ++col) {
+ const QRect cell = l.cellRect(col);
+ const int dow = (locale.firstDayOfWeek() - 1 + col) % 7 + 1;
+ p.setPen(palette().color(QPalette::PlaceholderText));
+ p.drawText(QRect(cell.left(), 0, cell.width(), line + 4), Qt::AlignCenter,
+ locale.dayName(dow, QLocale::ShortFormat));
+ }
+
+ const QList<QList<int>> cells = itemsPerCell(l);
+ const QDate today = QDate::currentDate();
+ for (int c = 0; c < MonthLayout::kCells; ++c) {
+ const QRect cell = l.cellRect(c);
+ const QDate date = l.dateAt(c);
+ p.setPen(palette().color(QPalette::Mid));
+ p.drawRect(cell.adjusted(0, 0, -1, -1));
+
+ QRect number(cell.left() + 4, cell.top() + 1, fontMetrics().horizontalAdvance(QStringLiteral("00")) + 8, line);
+ if (date == today) {
+ p.setPen(Qt::NoPen);
+ p.setBrush(palette().highlight());
+ p.drawRoundedRect(number, line / 2, line / 2);
+ p.setPen(palette().color(QPalette::HighlightedText));
+ } else {
+ p.setPen(palette().color(date.month() == m_month ? QPalette::Text : QPalette::PlaceholderText));
+ }
+ p.drawText(number, Qt::AlignCenter, QString::number(date.day()));
+
+ const QList<int> &items = cells[c];
+ const int shown = l.visibleChips(items.size());
+ for (int slot = 0; slot < shown; ++slot) {
+ const CalendarItem &item = m_items[items[slot]];
+ const QRect chip = l.chipRect(c, slot);
+ if (item.occurrence.allDay) {
+ p.setPen(Qt::NoPen);
+ p.setBrush(item.color);
+ p.drawRoundedRect(chip, 3, 3);
+ p.setPen(Qt::white);
+ p.drawText(chip.adjusted(4, 0, -2, 0), Qt::AlignVCenter | Qt::AlignLeft,
+ fontMetrics().elidedText(item.title, Qt::ElideRight, chip.width() - 6));
+ } else {
+ const int dot = chip.height() / 2;
+ p.setPen(Qt::NoPen);
+ p.setBrush(item.color);
+ p.drawEllipse(QRect(chip.left() + 2, chip.center().y() - dot / 2, dot, dot));
+ p.setPen(palette().color(QPalette::Text));
+ const QString text = locale.toString(item.occurrence.start.toLocalTime().time(),
+ QLocale::ShortFormat)
+ + QLatin1Char(' ') + item.title;
+ const QRect textRect = chip.adjusted(dot + 6, 0, 0, 0);
+ p.drawText(textRect, Qt::AlignVCenter | Qt::AlignLeft,
+ fontMetrics().elidedText(text, Qt::ElideRight, textRect.width()));
+ }
+ if (items[slot] == m_selected) {
+ p.setPen(QPen(palette().color(QPalette::Highlight), 2));
+ p.setBrush(Qt::NoBrush);
+ p.drawRoundedRect(chip, 3, 3);
+ }
+ }
+ if (shown < items.size()) {
+ p.setPen(palette().color(QPalette::Link));
+ p.drawText(l.chipRect(c, shown).adjusted(4, 0, 0, 0), Qt::AlignVCenter | Qt::AlignLeft,
+ tr("+%n more", nullptr, int(items.size() - shown)));
+ }
+ }
+}
+
+void MonthView::mousePressEvent(QMouseEvent *event)
+{
+ const MonthLayout l = layout();
+ const int cell = l.cellAt(event->position().toPoint());
+ if (cell < 0)
+ return;
+ const QList<int> items = itemsPerCell(l)[cell];
+ const int shown = l.visibleChips(items.size());
+ for (int slot = 0; slot < shown; ++slot) {
+ if (l.chipRect(cell, slot).contains(event->position().toPoint())) {
+ emit itemClicked(items[slot]);
+ return;
+ }
+ }
+ if (shown < items.size() && l.chipRect(cell, shown).contains(event->position().toPoint()))
+ emit moreClicked(l.dateAt(cell));
+}
+
+void MonthView::mouseDoubleClickEvent(QMouseEvent *event)
+{
+ const MonthLayout l = layout();
+ const int cell = l.cellAt(event->position().toPoint());
+ if (cell < 0)
+ return;
+ // A double-click on a chip is two clicks on the chip, not a new event.
+ const QList<int> items = itemsPerCell(l)[cell];
+ for (int slot = 0; slot < l.visibleChips(items.size()); ++slot)
+ if (l.chipRect(cell, slot).contains(event->position().toPoint()))
+ return;
+ emit dayDoubleClicked(l.dateAt(cell));
+}
+```
+
+The `tr("+%n more", nullptr, n)` form is the plural-aware one; the Italian `.ts` then needs both plural forms in Task 16.
+
+- [ ] **Step 3: Write `src/agendaview.h` and `src/agendaview.cpp`**
+
+```cpp
+/* GPLv2 header */
+
+#pragma once
+
+#include "caltypes.h"
+
+#include <QListWidget>
+
+/// The agenda: the same items as the month grid, as a list under day headers.
+class AgendaView : public QListWidget
+{
+ Q_OBJECT
+
+public:
+ explicit AgendaView(QWidget *parent = nullptr);
+
+ void setItems(const QList<CalendarItem> &items);
+ void setSelected(int item);
+ /// Scrolls so `day`'s header, or the first day after it, is at the top.
+ void scrollToDay(const QDate &day);
+
+signals:
+ void itemClicked(int item);
+};
+```
+
+```cpp
+/* GPLv2 header */
+
+#include "agendaview.h"
+
+#include <QLocale>
+#include <QPainter>
+#include <QPixmap>
+
+namespace {
+constexpr int kItemRole = Qt::UserRole; ///< Index into the items, or -1 for a header.
+constexpr int kDayRole = Qt::UserRole + 1; ///< The header's date.
+}
+
+AgendaView::AgendaView(QWidget *parent) : QListWidget(parent)
+{
+ connect(this, &QListWidget::itemClicked, this, [this](QListWidgetItem *row) {
+ const int item = row->data(kItemRole).toInt();
+ if (item >= 0)
+ emit itemClicked(item);
+ });
+}
+
+void AgendaView::setItems(const QList<CalendarItem> &items)
+{
+ clear();
+ const QLocale locale;
+ QDate current;
+ for (int i = 0; i < items.size(); ++i) {
+ const CalendarItem &item = items[i];
+ // A blank item is a placeholder the window leaves so every row's
+ // index stays the same as the month grid's: skipped, not shown.
+ if (item.title.isEmpty())
+ continue;
+ const QDate day = item.occurrence.start.toLocalTime().date();
+ if (day != current) {
+ current = day;
+ auto *header = new QListWidgetItem(locale.toString(day, QLocale::LongFormat), this);
+ QFont bold = header->font();
+ bold.setBold(true);
+ header->setFont(bold);
+ header->setFlags(Qt::ItemIsEnabled); // not selectable
+ header->setData(kItemRole, -1);
+ header->setData(kDayRole, day);
+ }
+ const QString when = item.occurrence.allDay
+ ? tr("all day")
+ : tr("%1 - %2").arg(locale.toString(item.occurrence.start.toLocalTime().time(), QLocale::ShortFormat),
+ locale.toString(item.occurrence.end.toLocalTime().time(), QLocale::ShortFormat));
+ QPixmap swatch(10, 10);
+ swatch.fill(Qt::transparent);
+ QPainter(&swatch).fillRect(swatch.rect(), item.color);
+ auto *row = new QListWidgetItem(QIcon(swatch), when + QStringLiteral(" ") + item.title, this);
+ row->setData(kItemRole, i);
+ }
+}
+
+void AgendaView::setSelected(int item)
+{
+ for (int r = 0; r < count(); ++r) {
+ if (this->item(r)->data(kItemRole).toInt() == item) {
+ setCurrentRow(r);
+ return;
+ }
+ }
+ clearSelection();
+}
+
+void AgendaView::scrollToDay(const QDate &day)
+{
+ for (int r = 0; r < count(); ++r) {
+ const QDate header = item(r)->data(kDayRole).toDate();
+ if (header.isValid() && header >= day) {
+ scrollToItem(item(r), QAbstractItemView::PositionAtTop);
+ return;
+ }
+ }
+}
+```
+
+An item's title reaches `QListWidgetItem` as plain text: a list item has no rich-text mode, so the untrusted-text rule holds here by construction.
+
+- [ ] **Step 4: Build**
+
+Add `monthview.cpp` and `agendaview.cpp` to `src/CMakeLists.txt`.
+Run: `cmake --build build 2>&1 | tail -3`
+Expected: builds with no warnings from the new files.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/monthview.h src/monthview.cpp src/agendaview.h src/agendaview.cpp src/CMakeLists.txt
+git commit -m "feat: paint the calendar's month grid and agenda list"
+```
+
+---
+
+### Task 13: `EventPane`
+
+The side pane's two pages. Its visual layout is the user's hand test; what is tested (Task 14, through the window) is the edit state it reports.
+
+**Files:**
+- Create: `src/eventpane.h`, `src/eventpane.cpp`
+- Modify: `src/CMakeLists.txt`
+
+- [ ] **Step 1: Write `src/eventpane.h`**
+
+```cpp
+/* GPLv2 header */
+
+#pragma once
+
+#include "caltypes.h"
+
+#include <QStackedWidget>
+
+class QCheckBox;
+class QComboBox;
+class QDateEdit;
+class QDateTimeEdit;
+class QLabel;
+class QLineEdit;
+class QPlainTextEdit;
+class QPushButton;
+class QSpinBox;
+
+/// The calendar window's right-hand pane: a read-only page for the selected
+/// event, and the edit form that replaces it (spec decision 11).
+///
+/// Every value from a file is shown in a Qt::PlainText label: SUMMARY,
+/// DESCRIPTION, LOCATION and attendee names come from strangers once
+/// invitations arrive, which is MessageDetailsDialog's rule.
+class EventPane : public QStackedWidget
+{
+ Q_OBJECT
+
+public:
+ explicit EventPane(QWidget *parent = nullptr);
+
+ void showNothing();
+ void showDetails(const CalEvent &event, const CalCollection &collection,
+ const Occurrence &occurrence, bool editable);
+
+ /// Opens the form. `collections` are the writable ones, for the Calendar
+ /// combo; `repeatEditable` is false for a single occurrence, which has no
+ /// rule of its own; `collectionEditable` is false for the same reason.
+ void startEdit(const EventEdit &initial, const QList<CalCollection> &collections,
+ bool repeatEditable, bool collectionEditable,
+ const QString &reminderText, const QString &attendeesText);
+ void stopEdit();
+
+ bool isEditing() const;
+ bool isDirty() const;
+ EventEdit edit() const;
+
+signals:
+ void editRequested();
+ void deleteRequested();
+ void saveRequested();
+ void cancelRequested();
+
+private:
+ QWidget *buildDetails();
+ QWidget *buildForm();
+ QWidget *buildRepeat();
+ void setRepeat(const RepeatRule &rule);
+ RepeatRule repeat() const;
+ void proposeFromStart();
+ void refreshRepeatVisibility();
+ void refreshAllDay();
+
+ // Details page.
+ QLabel *m_title;
+ QLabel *m_when;
+ QLabel *m_repeatText;
+ QLabel *m_calendar;
+ QLabel *m_location;
+ QLabel *m_notes;
+ QLabel *m_reminder;
+ QLabel *m_people;
+ QLabel *m_notice;
+ QPushButton *m_editButton;
+ QPushButton *m_deleteButton;
+
+ // Form.
+ QLineEdit *m_titleEdit;
+ QComboBox *m_calendarBox;
+ QCheckBox *m_allDay;
+ QDateTimeEdit *m_startEdit;
+ QDateTimeEdit *m_endEdit;
+ QLineEdit *m_locationEdit;
+ QPlainTextEdit *m_notesEdit;
+ QLabel *m_formReminder;
+ QLabel *m_formPeople;
+ QLabel *m_formNotice;
+
+ // Repeat control.
+ QWidget *m_repeatBox;
+ QComboBox *m_freq; ///< None, Daily, Weekly, Monthly, Yearly, [Custom]
+ QSpinBox *m_interval;
+ QWidget *m_weekdayRow;
+ QList<QCheckBox *> m_weekdays; ///< Monday first, index = Qt::DayOfWeek - 1
+ QComboBox *m_by; ///< "on day N" / "on the Nth weekday"
+ QSpinBox *m_monthDay;
+ QComboBox *m_ordinal; ///< first..fourth, last
+ QComboBox *m_weekday;
+ QComboBox *m_month;
+ QComboBox *m_end; ///< never, on a date, after N times
+ QDateEdit *m_until;
+ QSpinBox *m_count;
+ QLabel *m_customLabel;
+
+ EventEdit m_initial;
+ RepeatRule m_customRule; ///< Kept when the rule read was custom.
+ QDateTime m_previousStart; ///< For keeping the duration as the start moves.
+ bool m_editing = false;
+};
+```
+
+- [ ] **Step 2: Write `src/eventpane.cpp`**
+
+```cpp
+/* GPLv2 header */
+
+#include "eventpane.h"
+
+#include <QCheckBox>
+#include <QComboBox>
+#include <QDateEdit>
+#include <QDateTimeEdit>
+#include <QFormLayout>
+#include <QHBoxLayout>
+#include <QLabel>
+#include <QLineEdit>
+#include <QLocale>
+#include <QPainter>
+#include <QPixmap>
+#include <QPlainTextEdit>
+#include <QPushButton>
+#include <QSpinBox>
+#include <QTimeZone>
+#include <QVBoxLayout>
+
+namespace {
+
+QLabel *plainLabel(QWidget *parent)
+{
+ auto *label = new QLabel(parent);
+ label->setTextFormat(Qt::PlainText); // untrusted: never AutoText
+ label->setWordWrap(true);
+ label->setTextInteractionFlags(Qt::TextSelectableByMouse);
+ return label;
+}
+
+QIcon swatch(const QColor &colour)
+{
+ QPixmap pixmap(12, 12);
+ pixmap.fill(Qt::transparent);
+ QPainter(&pixmap).fillRect(pixmap.rect(), colour);
+ return QIcon(pixmap);
+}
+
+bool sameEdit(const EventEdit &a, const EventEdit &b)
+{
+ return a.summary == b.summary && a.location == b.location
+ && a.description == b.description && a.start == b.start && a.end == b.end
+ && a.allDay == b.allDay && a.repeat == b.repeat && a.collectionDir == b.collectionDir;
+}
+
+constexpr int kCustomIndex = 5;
+
+} // namespace
+
+EventPane::EventPane(QWidget *parent) : QStackedWidget(parent)
+{
+ addWidget(new QWidget(this)); // 0: nothing selected
+ addWidget(buildDetails()); // 1
+ addWidget(buildForm()); // 2
+ setMinimumWidth(260);
+ showNothing();
+}
+
+QWidget *EventPane::buildDetails()
+{
+ auto *page = new QWidget(this);
+ auto *layout = new QVBoxLayout(page);
+ m_title = plainLabel(page);
+ QFont big = m_title->font();
+ big.setBold(true);
+ big.setPointSizeF(big.pointSizeF() > 0 ? big.pointSizeF() * 1.3 : big.pointSizeF());
+ m_title->setFont(big);
+ layout->addWidget(m_title);
+
+ auto *form = new QFormLayout;
+ m_when = plainLabel(page);
+ m_repeatText = plainLabel(page);
+ m_calendar = plainLabel(page);
+ m_location = plainLabel(page);
+ m_notes = plainLabel(page);
+ m_reminder = plainLabel(page);
+ m_people = plainLabel(page);
+ form->addRow(tr("When"), m_when);
+ form->addRow(tr("Repeats"), m_repeatText);
+ form->addRow(tr("Calendar"), m_calendar);
+ form->addRow(tr("Location"), m_location);
+ form->addRow(tr("Notes"), m_notes);
+ form->addRow(tr("Reminder"), m_reminder);
+ form->addRow(tr("People"), m_people);
+ layout->addLayout(form);
+
+ m_notice = plainLabel(page);
+ m_notice->setObjectName(QStringLiteral("eventNotice"));
+ layout->addWidget(m_notice);
+
+ auto *buttons = new QHBoxLayout;
+ m_editButton = new QPushButton(tr("&Edit"), page);
+ m_editButton->setObjectName(QStringLiteral("editEvent"));
+ m_deleteButton = new QPushButton(tr("&Delete"), page);
+ m_deleteButton->setObjectName(QStringLiteral("deleteEvent"));
+ buttons->addWidget(m_editButton);
+ buttons->addWidget(m_deleteButton);
+ buttons->addStretch();
+ layout->addLayout(buttons);
+ layout->addStretch();
+
+ connect(m_editButton, &QPushButton::clicked, this, &EventPane::editRequested);
+ connect(m_deleteButton, &QPushButton::clicked, this, &EventPane::deleteRequested);
+ return page;
+}
+
+QWidget *EventPane::buildForm()
+{
+ auto *page = new QWidget(this);
+ auto *layout = new QVBoxLayout(page);
+ auto *form = new QFormLayout;
+
+ m_titleEdit = new QLineEdit(page);
+ m_titleEdit->setObjectName(QStringLiteral("eventTitle"));
+ m_calendarBox = new QComboBox(page);
+ m_calendarBox->setObjectName(QStringLiteral("eventCalendar"));
+ m_allDay = new QCheckBox(tr("All day"), page);
+ m_startEdit = new QDateTimeEdit(page);
+ m_startEdit->setObjectName(QStringLiteral("eventStart"));
+ m_startEdit->setCalendarPopup(true);
+ m_endEdit = new QDateTimeEdit(page);
+ m_endEdit->setObjectName(QStringLiteral("eventEnd"));
+ m_endEdit->setCalendarPopup(true);
+ m_locationEdit = new QLineEdit(page);
+ m_notesEdit = new QPlainTextEdit(page);
+ m_formReminder = plainLabel(page);
+ m_formPeople = plainLabel(page);
+
+ form->addRow(tr("Title"), m_titleEdit);
+ form->addRow(tr("Calendar"), m_calendarBox);
+ form->addRow(QString(), m_allDay);
+ form->addRow(tr("Starts"), m_startEdit);
+ form->addRow(tr("Ends"), m_endEdit);
+ form->addRow(tr("Repeat"), buildRepeat());
+ form->addRow(tr("Location"), m_locationEdit);
+ form->addRow(tr("Notes"), m_notesEdit);
+ form->addRow(tr("Reminder"), m_formReminder);
+ form->addRow(tr("People"), m_formPeople);
+ layout->addLayout(form);
+
+ m_formNotice = plainLabel(page);
+ layout->addWidget(m_formNotice);
+
+ auto *buttons = new QHBoxLayout;
+ auto *save = new QPushButton(tr("&Save"), page);
+ save->setObjectName(QStringLiteral("saveEvent"));
+ save->setDefault(true);
+ auto *cancel = new QPushButton(tr("Cancel"), page);
+ cancel->setObjectName(QStringLiteral("cancelEvent"));
+ buttons->addStretch();
+ buttons->addWidget(cancel);
+ buttons->addWidget(save);
+ layout->addLayout(buttons);
+
+ connect(save, &QPushButton::clicked, this, &EventPane::saveRequested);
+ connect(cancel, &QPushButton::clicked, this, &EventPane::cancelRequested);
+ connect(m_allDay, &QCheckBox::toggled, this, &EventPane::refreshAllDay);
+ // Keep the duration when the start moves, as every calendar does.
+ connect(m_startEdit, &QDateTimeEdit::dateTimeChanged, this, [this](const QDateTime &start) {
+ if (m_previousStart.isValid() && m_editing) {
+ const qint64 length = m_previousStart.secsTo(m_endEdit->dateTime());
+ m_endEdit->setDateTime(start.addSecs(qMax<qint64>(0, length)));
+ }
+ m_previousStart = start;
+ proposeFromStart();
+ });
+ return page;
+}
+```
+
+`m_previousStart` is a member, reset in `startEdit`: a function-local static would carry one edit's start into the next.
+
+```cpp
+QWidget *EventPane::buildRepeat()
+{
+ m_repeatBox = new QWidget(this);
+ auto *box = new QVBoxLayout(m_repeatBox);
+ box->setContentsMargins(0, 0, 0, 0);
+ const QLocale locale;
+
+ auto *row = new QHBoxLayout;
+ m_freq = new QComboBox(m_repeatBox);
+ m_freq->setObjectName(QStringLiteral("eventRepeat"));
+ m_freq->addItems({ tr("Does not repeat"), tr("Daily"), tr("Weekly"),
+ tr("Monthly"), tr("Yearly") });
+ m_interval = new QSpinBox(m_repeatBox);
+ m_interval->setRange(1, 99);
+ m_interval->setPrefix(tr("every "));
+ row->addWidget(m_freq);
+ row->addWidget(m_interval);
+ box->addLayout(row);
+
+ m_weekdayRow = new QWidget(m_repeatBox);
+ auto *days = new QHBoxLayout(m_weekdayRow);
+ days->setContentsMargins(0, 0, 0, 0);
+ for (int d = 1; d <= 7; ++d) {
+ auto *check = new QCheckBox(locale.dayName(d, QLocale::NarrowFormat), m_weekdayRow);
+ m_weekdays.append(check);
+ days->addWidget(check);
+ }
+ box->addWidget(m_weekdayRow);
+
+ auto *byRow = new QHBoxLayout;
+ m_by = new QComboBox(m_repeatBox);
+ m_by->addItems({ tr("on day"), tr("on the") });
+ m_monthDay = new QSpinBox(m_repeatBox);
+ m_monthDay->setRange(1, 31);
+ m_ordinal = new QComboBox(m_repeatBox);
+ m_ordinal->addItem(tr("first"), 1);
+ m_ordinal->addItem(tr("second"), 2);
+ m_ordinal->addItem(tr("third"), 3);
+ m_ordinal->addItem(tr("fourth"), 4);
+ m_ordinal->addItem(tr("last"), -1);
+ m_weekday = new QComboBox(m_repeatBox);
+ for (int d = 1; d <= 7; ++d)
+ m_weekday->addItem(locale.dayName(d), d);
+ m_month = new QComboBox(m_repeatBox);
+ for (int m = 1; m <= 12; ++m)
+ m_month->addItem(tr("of %1").arg(locale.monthName(m)), m);
+ byRow->addWidget(m_by);
+ byRow->addWidget(m_monthDay);
+ byRow->addWidget(m_ordinal);
+ byRow->addWidget(m_weekday);
+ byRow->addWidget(m_month);
+ box->addLayout(byRow);
+
+ auto *endRow = new QHBoxLayout;
+ m_end = new QComboBox(m_repeatBox);
+ m_end->addItems({ tr("forever"), tr("until"), tr("for") });
+ m_until = new QDateEdit(m_repeatBox);
+ m_until->setCalendarPopup(true);
+ m_count = new QSpinBox(m_repeatBox);
+ m_count->setRange(1, 999);
+ m_count->setSuffix(tr(" times"));
+ endRow->addWidget(m_end);
+ endRow->addWidget(m_until);
+ endRow->addWidget(m_count);
+ box->addLayout(endRow);
+
+ m_customLabel = new QLabel(tr("Custom rule, kept as is"), m_repeatBox);
+ box->addWidget(m_customLabel);
+
+ for (QComboBox *combo : { m_freq, m_by, m_end })
+ connect(combo, &QComboBox::currentIndexChanged, this, &EventPane::refreshRepeatVisibility);
+ connect(m_freq, &QComboBox::currentIndexChanged, this, &EventPane::proposeFromStart);
+ return m_repeatBox;
+}
+
+void EventPane::refreshRepeatVisibility()
+{
+ const int freq = m_freq->currentIndex();
+ const bool custom = freq == kCustomIndex;
+ const bool repeats = freq > 0 && !custom;
+ const bool monthly = freq == 3, yearly = freq == 4;
+ const bool byWeekday = m_by->currentIndex() == 1;
+ m_interval->setVisible(repeats);
+ m_weekdayRow->setVisible(freq == 2);
+ m_by->setVisible(monthly || yearly);
+ // Yearly "on day" means on the start date, which needs no control.
+ m_monthDay->setVisible(monthly && !byWeekday);
+ m_ordinal->setVisible((monthly || yearly) && byWeekday);
+ m_weekday->setVisible((monthly || yearly) && byWeekday);
+ m_month->setVisible(yearly && byWeekday);
+ m_end->setVisible(repeats);
+ m_until->setVisible(repeats && m_end->currentIndex() == 1);
+ m_count->setVisible(repeats && m_end->currentIndex() == 2);
+ m_customLabel->setVisible(custom);
+}
+
+/// Re-proposes the day-dependent defaults from the start date, as Thunderbird
+/// does: "on day 24", or "the fourth Thursday" (the fifth becomes "last").
+void EventPane::proposeFromStart()
+{
+ const QDate start = m_startEdit->date();
+ m_monthDay->setValue(start.day());
+ const int ordinal = (start.day() - 1) / 7 + 1;
+ m_ordinal->setCurrentIndex(m_ordinal->findData(ordinal > 4 ? -1 : ordinal));
+ m_weekday->setCurrentIndex(m_weekday->findData(start.dayOfWeek()));
+ m_month->setCurrentIndex(start.month() - 1);
+ if (m_freq->currentIndex() == 2
+ && std::none_of(m_weekdays.cbegin(), m_weekdays.cend(),
+ [](QCheckBox *c) { return c->isChecked(); }))
+ m_weekdays[start.dayOfWeek() - 1]->setChecked(true);
+}
+
+void EventPane::setRepeat(const RepeatRule &rule)
+{
+ if (m_freq->count() > kCustomIndex)
+ m_freq->removeItem(kCustomIndex);
+ m_customRule = RepeatRule();
+ if (rule.custom) {
+ m_customRule = rule;
+ m_freq->addItem(tr("Custom rule"));
+ m_freq->setCurrentIndex(kCustomIndex);
+ refreshRepeatVisibility();
+ return;
+ }
+ m_freq->setCurrentIndex(static_cast<int>(rule.freq));
+ proposeFromStart();
+ m_interval->setValue(rule.interval);
+ for (int d = 0; d < 7; ++d)
+ m_weekdays[d]->setChecked(rule.weekdays.contains(d + 1));
+ m_by->setCurrentIndex(rule.by == RepeatRule::By::Weekday ? 1 : 0);
+ if (rule.monthDay)
+ m_monthDay->setValue(rule.monthDay);
+ if (rule.ordinal)
+ m_ordinal->setCurrentIndex(m_ordinal->findData(rule.ordinal));
+ if (rule.weekday)
+ m_weekday->setCurrentIndex(m_weekday->findData(rule.weekday));
+ if (rule.month)
+ m_month->setCurrentIndex(rule.month - 1);
+ m_end->setCurrentIndex(static_cast<int>(rule.end));
+ m_until->setDate(rule.until.isValid() ? rule.until : m_startEdit->date().addMonths(1));
+ m_count->setValue(qMax(1, rule.count));
+ refreshRepeatVisibility();
+}
+
+RepeatRule EventPane::repeat() const
+{
+ if (m_freq->currentIndex() == kCustomIndex)
+ return m_customRule;
+ RepeatRule rule;
+ rule.freq = static_cast<RepeatRule::Freq>(m_freq->currentIndex());
+ if (rule.freq == RepeatRule::Freq::None)
+ return rule;
+ rule.interval = m_interval->value();
+ rule.wkst = m_initial.repeat.wkst; // carried through, spec
+ if (rule.freq == RepeatRule::Freq::Weekly)
+ for (int d = 0; d < 7; ++d)
+ if (m_weekdays[d]->isChecked())
+ rule.weekdays.append(d + 1);
+ if (rule.freq == RepeatRule::Freq::Monthly || rule.freq == RepeatRule::Freq::Yearly) {
+ rule.by = m_by->currentIndex() == 1 ? RepeatRule::By::Weekday : RepeatRule::By::MonthDay;
+ if (rule.by == RepeatRule::By::Weekday) {
+ rule.ordinal = m_ordinal->currentData().toInt();
+ rule.weekday = m_weekday->currentData().toInt();
+ if (rule.freq == RepeatRule::Freq::Yearly)
+ rule.month = m_month->currentData().toInt();
+ } else if (rule.freq == RepeatRule::Freq::Monthly) {
+ rule.monthDay = m_monthDay->value();
+ }
+ }
+ rule.end = static_cast<RepeatRule::End>(m_end->currentIndex());
+ if (rule.end == RepeatRule::End::Until)
+ rule.until = m_until->date();
+ else if (rule.end == RepeatRule::End::Count)
+ rule.count = m_count->value();
+ return rule;
+}
+
+void EventPane::refreshAllDay()
+{
+ // All-day shows DATES, and the end is the LAST day, inclusive: what a
+ // person means by "24 to 25 September". edit() converts back to the
+ // exclusive end iCalendar needs.
+ const QString format = m_allDay->isChecked()
+ ? QLocale().dateFormat(QLocale::ShortFormat)
+ : QLocale().dateTimeFormat(QLocale::ShortFormat);
+ m_startEdit->setDisplayFormat(format);
+ m_endEdit->setDisplayFormat(format);
+}
+
+void EventPane::showNothing()
+{
+ m_editing = false;
+ setCurrentIndex(0);
+}
+
+void EventPane::showDetails(const CalEvent &event, const CalCollection &collection,
+ const Occurrence &occurrence, bool editable)
+{
+ m_editing = false;
+ const QLocale locale;
+ const CalOverride *ov = occurrence.isOverride ? &event.overrides[occurrence.overrideIndex] : nullptr;
+ const QString summary = ov ? ov->summary : event.summary;
+ m_title->setText(summary.isEmpty() ? tr("(no title)") : summary);
+
+ const QDateTime start = occurrence.start.toLocalTime(), end = occurrence.end.toLocalTime();
+ if (occurrence.allDay) {
+ const QDate last = end.date().addDays(-1);
+ m_when->setText(last > start.date()
+ ? tr("%1 to %2, all day").arg(locale.toString(start.date(), QLocale::LongFormat),
+ locale.toString(last, QLocale::LongFormat))
+ : tr("%1, all day").arg(locale.toString(start.date(), QLocale::LongFormat)));
+ } else {
+ m_when->setText(tr("%1, %2 - %3").arg(locale.toString(start.date(), QLocale::LongFormat),
+ locale.toString(start.time(), QLocale::ShortFormat),
+ locale.toString(end.time(), QLocale::ShortFormat)));
+ }
+ m_repeatText->setText(event.repeat.freq == RepeatRule::Freq::None && !event.repeat.custom
+ ? tr("Does not repeat") : event.repeat.describe());
+ m_calendar->setText(collection.displayName);
+ m_location->setText(ov ? ov->location : event.location);
+ m_notes->setText(ov ? ov->description : event.description);
+ m_reminder->setText(event.hasAlarm ? tr("Yes, kept as set elsewhere") : tr("None"));
+
+ QStringList people;
+ if (!event.organizer.address.isEmpty())
+ people << tr("Organiser: %1").arg(event.organizer.name.isEmpty()
+ ? event.organizer.address : event.organizer.name);
+ for (const CalPerson &a : event.attendees)
+ people << QStringLiteral("%1 (%2)").arg(a.name.isEmpty() ? a.address : a.name,
+ a.partstat.isEmpty() ? tr("no reply") : a.partstat.toLower());
+ m_people->setText(people.join(QLatin1Char('\n')));
+
+ QString notice;
+ if (collection.readOnly)
+ notice = tr("Read-only calendar.");
+ else if (!editable)
+ notice = tr("Organised by %1: read-only.").arg(event.organizer.name.isEmpty()
+ ? event.organizer.address : event.organizer.name);
+ m_notice->setText(notice);
+ m_notice->setVisible(!notice.isEmpty());
+ // Absent, not disabled (AGENTS.md precedent).
+ m_editButton->setVisible(editable);
+ m_deleteButton->setVisible(editable);
+ setCurrentIndex(1);
+}
+
+void EventPane::startEdit(const EventEdit &initial, const QList<CalCollection> &collections,
+ bool repeatEditable, bool collectionEditable,
+ const QString &reminderText, const QString &attendeesText)
+{
+ m_initial = initial;
+ m_editing = false; // no duration-keeping while the fields are filled
+ m_calendarBox->clear();
+ for (const CalCollection &c : collections)
+ m_calendarBox->addItem(swatch(c.color), c.displayName, c.dir);
+ m_calendarBox->setCurrentIndex(qMax(0, m_calendarBox->findData(initial.collectionDir)));
+ m_calendarBox->setEnabled(collectionEditable);
+
+ m_previousStart = QDateTime();
+ m_titleEdit->setText(initial.summary);
+ m_allDay->setChecked(initial.allDay);
+ refreshAllDay();
+ m_startEdit->setDateTime(initial.start.toLocalTime());
+ m_endEdit->setDateTime(initial.allDay ? initial.end.toLocalTime().addDays(-1)
+ : initial.end.toLocalTime());
+ m_locationEdit->setText(initial.location);
+ m_notesEdit->setPlainText(initial.description);
+ setRepeat(initial.repeat);
+ m_repeatBox->setEnabled(repeatEditable);
+ m_formReminder->setText(reminderText);
+ m_formPeople->setText(attendeesText);
+ m_formNotice->setText(attendeesText.isEmpty() ? QString()
+ : tr("Attendees have not been notified of changes."));
+ m_formNotice->setVisible(!attendeesText.isEmpty());
+
+ // The form rounds what it shows (seconds, the display format), so the
+ // baseline for isDirty() is what the form PRODUCES, not what came in.
+ m_initial = edit();
+ m_editing = true;
+ setCurrentIndex(2);
+ m_titleEdit->setFocus();
+}
+
+void EventPane::stopEdit() { m_editing = false; }
+bool EventPane::isEditing() const { return m_editing; }
+bool EventPane::isDirty() const { return m_editing && !sameEdit(edit(), m_initial); }
+
+EventEdit EventPane::edit() const
+{
+ EventEdit e;
+ e.summary = m_titleEdit->text().trimmed();
+ e.location = m_locationEdit->text().trimmed();
+ e.description = m_notesEdit->toPlainText();
+ e.allDay = m_allDay->isChecked();
+ e.collectionDir = m_calendarBox->currentData().toString();
+ if (e.allDay) {
+ e.start = QDateTime(m_startEdit->date(), QTime(0, 0));
+ e.end = QDateTime(qMax(m_endEdit->date(), m_startEdit->date()).addDays(1), QTime(0, 0));
+ } else {
+ e.start = m_startEdit->dateTime();
+ e.end = qMax(m_endEdit->dateTime(), e.start);
+ }
+ e.repeat = repeat();
+ return e;
+}
+```
+
+Add `#include <algorithm>`. `QDateTimeEdit::dateTime()` returns local time with the edit's time spec; check it is `Qt::LocalTime` so `applyEdit` converts it into the event's zone correctly, and that the test `saveWritesAndUndoRestores` (Task 14) still round-trips the unchanged start.
+
+- [ ] **Step 3: Build**
+
+Add `eventpane.cpp` to `src/CMakeLists.txt`.
+Run: `cmake --build build 2>&1 | tail -3`
+Expected: builds cleanly.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/eventpane.h src/eventpane.cpp src/CMakeLists.txt
+git commit -m "feat: show and edit a calendar event in the side pane"
+```
+
+---
+
+### Task 14: `CalendarWindow`
+
+**Files:**
+- Create: `src/calendarwindow.h`, `src/calendarwindow.cpp`
+- Modify: `src/CMakeLists.txt`, `tests/CMakeLists.txt` (`add_qtmaildir_test(calendarwindow)`)
+- Test: `tests/test_calendarwindow.cpp`
+
+- [ ] **Step 1: Write the failing tests**
+
+```cpp
+/* GPLv2 header */
+
+#include <QtTest>
+#include <QApplication>
+#include <QDir>
+#include <QFile>
+#include <QLineEdit>
+#include <QMessageBox>
+#include <QPushButton>
+#include <QTemporaryDir>
+#include <QUndoStack>
+
+#include "calendarwindow.h"
+#include "config.h"
+
+namespace {
+
+QByteArray eventText(const QString &uid, const QString &summary, int day)
+{
+ return QStringLiteral(
+ "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//example//test//EN\r\n"
+ "BEGIN:VEVENT\r\nUID:%1\r\nDTSTAMP:20260901T000000Z\r\n"
+ "DTSTART:202609%2T080000Z\r\nDTEND:202609%2T090000Z\r\nSUMMARY:%3\r\n"
+ "END:VEVENT\r\nEND:VCALENDAR\r\n")
+ .arg(uid).arg(day, 2, 10, QLatin1Char('0')).arg(summary).toUtf8();
+}
+
+void writeFile(const QString &path, const QByteArray &content)
+{
+ QDir().mkpath(QFileInfo(path).absolutePath());
+ QFile f(path);
+ QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Truncate));
+ f.write(content);
+}
+
+QByteArray read(const QString &path)
+{
+ QFile f(path);
+ return f.open(QIODevice::ReadOnly) ? f.readAll() : QByteArray();
+}
+
+/// A vdir with collections "a" and "b", two events in "a", and a config
+/// pointing at it with syncing off, so no test ever runs vdirsyncer.
+struct Fixture
+{
+ QTemporaryDir dir;
+ Config config;
+ QString a() const { return dir.filePath(QStringLiteral("cal/a/one.ics")); }
+
+ Fixture()
+ {
+ writeFile(a(), eventText(QStringLiteral("one@example.org"), QStringLiteral("One"), 22));
+ writeFile(dir.filePath(QStringLiteral("cal/a/two.ics")),
+ eventText(QStringLiteral("two@example.org"), QStringLiteral("Two"), 23));
+ QDir().mkpath(dir.filePath(QStringLiteral("cal/b")));
+ const QString ini = dir.filePath(QStringLiteral("qtmaildir.conf"));
+ writeFile(ini, QStringLiteral("[general]\ncalendars_dir = %1\n"
+ "default_calendar = b\ncalendar_sync_command = \n")
+ .arg(dir.filePath(QStringLiteral("cal"))).toUtf8());
+ config.load(ini);
+ }
+
+ CalendarWindow *window()
+ {
+ auto *w = new CalendarWindow(config, { QStringLiteral("me@example.org") },
+ dir.filePath(QStringLiteral("uistate.conf")));
+ w->showMonth(2026, 9);
+ w->show();
+ return w;
+ }
+};
+
+/// Answers the next modal QMessageBox with `button` once it is up.
+void answerNextBox(QMessageBox::StandardButton button)
+{
+ QTimer::singleShot(0, [button]() {
+ auto *box = qobject_cast<QMessageBox *>(QApplication::activeModalWidget());
+ QVERIFY2(box, "no modal message box appeared");
+ box->button(button)->click();
+ });
+}
+
+void editTitle(CalendarWindow *w, const QString &title)
+{
+ w->findChild<QPushButton *>(QStringLiteral("editEvent"))->click();
+ w->findChild<QLineEdit *>(QStringLiteral("eventTitle"))->setText(title);
+}
+
+} // namespace
+
+class TestCalendarWindow : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void loadsAndSelects();
+ void theEditLockRefusesASelectionChange();
+ void closingWithUnsavedChangesAsksOnEveryRoute();
+ void saveWritesAndUndoRestoresTheOriginalBytes();
+ void aNewEventGoesToTheDefaultCalendar();
+ void aStaleSaveKeepsTheFormAndTheSyncedFile();
+};
+
+void TestCalendarWindow::loadsAndSelects()
+{
+ Fixture f;
+ std::unique_ptr<CalendarWindow> w(f.window());
+ QVERIFY(w->selectEvent(QStringLiteral("two@example.org")));
+ QCOMPARE(w->selectedUid(), QStringLiteral("two@example.org"));
+ QVERIFY(!w->selectEvent(QStringLiteral("absent@example.org")));
+}
+
+void TestCalendarWindow::theEditLockRefusesASelectionChange()
+{
+ Fixture f;
+ std::unique_ptr<CalendarWindow> w(f.window());
+ QVERIFY(w->selectEvent(QStringLiteral("one@example.org")));
+ editTitle(w.get(), QStringLiteral("Changed"));
+ QVERIFY(!w->selectEvent(QStringLiteral("two@example.org")));
+ QCOMPARE(w->selectedUid(), QStringLiteral("one@example.org"));
+}
+
+void TestCalendarWindow::closingWithUnsavedChangesAsksOnEveryRoute()
+{
+ Fixture f;
+ const QByteArray original = read(f.a());
+
+ // Cancel keeps the window and the file. Route: close().
+ std::unique_ptr<CalendarWindow> w(f.window());
+ w->selectEvent(QStringLiteral("one@example.org"));
+ editTitle(w.get(), QStringLiteral("Changed"));
+ answerNextBox(QMessageBox::Cancel);
+ w->close();
+ QVERIFY(w->isVisible());
+ QCOMPARE(read(f.a()), original);
+
+ // Discard closes and writes nothing. Route: the File menu's Close action.
+ answerNextBox(QMessageBox::Discard);
+ w->findChild<QAction *>(QStringLiteral("closeCalendar"))->trigger();
+ QVERIFY(!w->isVisible());
+ QCOMPARE(read(f.a()), original);
+
+ // Save closes and writes.
+ std::unique_ptr<CalendarWindow> again(f.window());
+ again->selectEvent(QStringLiteral("one@example.org"));
+ editTitle(again.get(), QStringLiteral("Saved on close"));
+ answerNextBox(QMessageBox::Save);
+ again->close();
+ QVERIFY(!again->isVisible());
+ QVERIFY(read(f.a()).contains("SUMMARY:Saved on close"));
+}
+
+void TestCalendarWindow::saveWritesAndUndoRestoresTheOriginalBytes()
+{
+ Fixture f;
+ const QByteArray original = read(f.a());
+ std::unique_ptr<CalendarWindow> w(f.window());
+ w->selectEvent(QStringLiteral("one@example.org"));
+ editTitle(w.get(), QStringLiteral("Renamed"));
+ w->findChild<QPushButton *>(QStringLiteral("saveEvent"))->click();
+ QVERIFY(read(f.a()).contains("SUMMARY:Renamed"));
+
+ w->undoStack()->undo();
+ QCOMPARE(read(f.a()), original); // exactly the bytes it had: item 176's rule
+ w->undoStack()->redo();
+ QVERIFY(read(f.a()).contains("SUMMARY:Renamed"));
+}
+
+void TestCalendarWindow::aNewEventGoesToTheDefaultCalendar()
+{
+ Fixture f;
+ std::unique_ptr<CalendarWindow> w(f.window());
+ w->findChild<QAction *>(QStringLiteral("newEvent"))->trigger();
+ w->findChild<QLineEdit *>(QStringLiteral("eventTitle"))->setText(QStringLiteral("Fresh"));
+ w->findChild<QPushButton *>(QStringLiteral("saveEvent"))->click();
+ const QStringList created = QDir(f.dir.filePath(QStringLiteral("cal/b")))
+ .entryList({ QStringLiteral("*.ics") }, QDir::Files);
+ QCOMPARE(created.size(), 1);
+ QVERIFY(read(f.dir.filePath(QStringLiteral("cal/b/")) + created.first()).contains("SUMMARY:Fresh"));
+
+ w->undoStack()->undo();
+ QVERIFY(QDir(f.dir.filePath(QStringLiteral("cal/b"))).entryList({ QStringLiteral("*.ics") }).isEmpty());
+}
+
+void TestCalendarWindow::aStaleSaveKeepsTheFormAndTheSyncedFile()
+{
+ Fixture f;
+ std::unique_ptr<CalendarWindow> w(f.window());
+ w->selectEvent(QStringLiteral("one@example.org"));
+ editTitle(w.get(), QStringLiteral("Mine"));
+ // A sync pulls a server change while the form is open.
+ const QByteArray synced = eventText(QStringLiteral("one@example.org"), QStringLiteral("Theirs"), 22);
+ writeFile(f.a(), synced);
+ w->findChild<QPushButton *>(QStringLiteral("saveEvent"))->click();
+ QCOMPARE(read(f.a()), synced); // nothing clobbered
+ QVERIFY(w->isEditing()); // the user's values are still there
+ QCOMPARE(w->findChild<QLineEdit *>(QStringLiteral("eventTitle"))->text(), QStringLiteral("Mine"));
+}
+
+QTEST_MAIN(TestCalendarWindow)
+#include "test_calendarwindow.moc"
+```
+
+`std::unique_ptr<CalendarWindow>` requires the window NOT to set `WA_DeleteOnClose` itself; `MainWindow` sets that when it opens one (Task 15), as it does for composers. Add `#include <memory>` and `#include <QTimer>`.
+
+- [ ] **Step 2: Write `src/calendarwindow.h`**
+
+```cpp
+/* GPLv2 header */
+
+#pragma once
+
+#include "caltypes.h"
+#include "config.h"
+
+#include <QFileSystemWatcher>
+#include <QHash>
+#include <QMainWindow>
+#include <QTimer>
+#include <QUndoStack>
+
+#include <optional>
+
+class AgendaView;
+class CalendarSync;
+class EventPane;
+class MonthView;
+class QComboBox;
+class QLabel;
+class QSpinBox;
+class QStackedWidget;
+class QToolButton;
+
+/// The calendar (item 206): its own top-level window, like ComposeWindow.
+/// Spec: docs/superpowers/specs/2026-09-24-calendar-design.md.
+class CalendarWindow : public QMainWindow
+{
+ Q_OBJECT
+
+public:
+ CalendarWindow(const Config &config, const QStringList &ownAddresses,
+ const QString &uiStatePath, QWidget *parent = nullptr);
+ ~CalendarWindow() override;
+
+ void showMonth(int year, int month);
+ /// Selects an event's occurrence (the first in view when `start` is
+ /// invalid). False when not found, or refused by the edit lock.
+ bool selectEvent(const QString &uid, const QDateTime &start = {});
+ QString selectedUid() const;
+ bool isEditing() const;
+ QUndoStack *undoStack() { return &m_undo; }
+
+ /// One file's before and after, either absent. What a write and its undo
+ /// are made of.
+ struct Change
+ {
+ QString path;
+ std::optional<QByteArray> before;
+ std::optional<QByteArray> after;
+ };
+ /// Applies `changes` in order through CalendarWriter. On a failure the
+ /// ones already applied are rolled back and the reason is reported.
+ bool applyChanges(const QList<Change> &changes, bool reverse);
+
+protected:
+ void closeEvent(QCloseEvent *event) override;
+
+private:
+ void buildUi();
+ void buildActions();
+ void reload();
+ void rebuildItems();
+ void refreshToolbar();
+ void select(int item);
+ void startEdit();
+ void startNew(const QDate &day);
+ QDate defaultNewDay() const;
+ bool save();
+ void cancelEdit();
+ void deleteSelected();
+ bool writeAndRecord(const QString &label, const QList<Change> &changes);
+ void checkAfterSync();
+ const CalCollection *collection(const QString &dir) const;
+ QList<CalCollection> writableCollections() const;
+ QString defaultCollection() const;
+ void status(const QString &text);
+
+ Config m_config;
+ QStringList m_ownAddresses;
+ QString m_uiStatePath;
+
+ LoadResult m_data;
+ QList<CalendarItem> m_items;
+ int m_year;
+ int m_month;
+ int m_selected = -1;
+ QString m_selectedUid; ///< survives a reload
+ QDateTime m_selectedStart;
+
+ // The open edit. m_editPath empty with m_editing: a new event.
+ QString m_editPath;
+ QByteArray m_editBase;
+ bool m_editWholeSeries = true;
+ QDateTime m_editRecurrenceId;
+
+ QUndoStack m_undo;
+ CalendarSync *m_sync;
+ QFileSystemWatcher m_watcher;
+ QTimer m_reloadTimer;
+ /// Written since the last sync started: path to the text written, or
+ /// nullopt for a deletion. Checked by meaning after the next sync.
+ QHash<QString, std::optional<QByteArray>> m_written;
+ QHash<QString, std::optional<QByteArray>> m_checking;
+
+ QComboBox *m_collectionBox;
+ QComboBox *m_monthBox;
+ QSpinBox *m_yearSpin;
+ QToolButton *m_monthButton;
+ QToolButton *m_agendaButton;
+ QStackedWidget *m_views;
+ MonthView *m_monthView;
+ AgendaView *m_agendaView;
+ EventPane *m_pane;
+ QLabel *m_dataWarning;
+};
+```
+
+- [ ] **Step 3: Run to verify the tests fail**
+
+Run: `cmake --build build 2>&1 | tail -3`
+Expected: link failure for `CalendarWindow`.
+
+- [ ] **Step 4: Write `src/calendarwindow.cpp`**
+
+The undo command first, then the window. It is long; the reviewer should read it against the spec's "Writing" and "The window" sections line by line.
+
+```cpp
+/* GPLv2 header */
+
+#include "calendarwindow.h"
+#include "agendaview.h"
+#include "calendarstore.h"
+#include "calendarsync.h"
+#include "calendarwriter.h"
+#include "eventpane.h"
+#include "monthview.h"
+
+#include <QButtonGroup>
+#include <QCloseEvent>
+#include <QComboBox>
+#include <QDir>
+#include <QFile>
+#include <QFileInfo>
+#include <QHBoxLayout>
+#include <QLabel>
+#include <QLocale>
+#include <QMenuBar>
+#include <QMessageBox>
+#include <QPainter>
+#include <QPixmap>
+#include <QPushButton>
+#include <QSettings>
+#include <QSpinBox>
+#include <QSplitter>
+#include <QStackedWidget>
+#include <QStatusBar>
+#include <QTimeZone>
+#include <QToolBar>
+#include <QToolButton>
+#include <QUndoCommand>
+
+namespace {
+
+/// One undoable write. The write itself happened before the push, so the
+/// first redo() (which QUndoStack::push calls) does nothing. Undo restores
+/// exactly the bytes the write replaced, expecting on disk exactly the bytes
+/// it wrote: a sync in between makes it refuse, and the command is dropped.
+class FileCommand : public QUndoCommand
+{
+public:
+ FileCommand(const QString &text, const QList<CalendarWindow::Change> &changes,
+ CalendarWindow *window)
+ : QUndoCommand(text), m_changes(changes), m_window(window) {}
+
+ void undo() override
+ {
+ if (!m_window->applyChanges(m_changes, true))
+ setObsolete(true);
+ }
+
+ void redo() override
+ {
+ if (m_first) {
+ m_first = false;
+ return;
+ }
+ if (!m_window->applyChanges(m_changes, false))
+ setObsolete(true);
+ }
+
+private:
+ QList<CalendarWindow::Change> m_changes;
+ CalendarWindow *m_window;
+ bool m_first = true;
+};
+
+QIcon swatch(const QColor &colour)
+{
+ QPixmap pixmap(12, 12);
+ pixmap.fill(Qt::transparent);
+ QPainter(&pixmap).fillRect(pixmap.rect(), colour);
+ return QIcon(pixmap);
+}
+
+QByteArray readFile(const QString &path)
+{
+ QFile f(path);
+ return f.open(QIODevice::ReadOnly) ? f.readAll() : QByteArray();
+}
+
+} // namespace
+
+CalendarWindow::CalendarWindow(const Config &config, const QStringList &ownAddresses,
+ const QString &uiStatePath, QWidget *parent)
+ : QMainWindow(parent), m_config(config), m_ownAddresses(ownAddresses),
+ m_uiStatePath(uiStatePath),
+ m_year(QDate::currentDate().year()), m_month(QDate::currentDate().month())
+{
+ setWindowTitle(tr("Calendar"));
+ m_sync = new CalendarSync(m_config.calendarSyncCommand(),
+ m_config.calendarSyncDelayMs(), this);
+ connect(m_sync, &CalendarSync::started, this, [this]() {
+ status(tr("Syncing calendars..."));
+ m_checking.insert(m_written); // insert(QHash) merges
+ m_written.clear();
+ });
+ connect(m_sync, &CalendarSync::finished, this, [this](bool ok, const QString &output) {
+ if (!ok) {
+ QMessageBox::warning(this, tr("Calendar sync failed"), output);
+ status(tr("Calendar sync failed."));
+ }
+ checkAfterSync();
+ });
+
+ m_reloadTimer.setSingleShot(true);
+ m_reloadTimer.setInterval(500);
+ connect(&m_reloadTimer, &QTimer::timeout, this, &CalendarWindow::reload);
+ connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this,
+ [this]() { m_reloadTimer.start(); });
+
+ buildUi();
+ buildActions();
+
+ QSettings state(m_uiStatePath, QSettings::IniFormat);
+ state.beginGroup(QStringLiteral("calendar"));
+ restoreGeometry(state.value(QStringLiteral("geometry")).toByteArray());
+ const bool agenda = state.value(QStringLiteral("agenda"), false).toBool();
+ const QString collectionDir = state.value(QStringLiteral("collection")).toString();
+ state.endGroup();
+
+ reload();
+ const int index = m_collectionBox->findData(collectionDir);
+ if (index >= 0)
+ m_collectionBox->setCurrentIndex(index);
+ (agenda ? m_agendaButton : m_monthButton)->click();
+}
+
+CalendarWindow::~CalendarWindow() = default;
+
+void CalendarWindow::buildUi()
+{
+ auto *toolbar = addToolBar(tr("Calendar"));
+ toolbar->setObjectName(QStringLiteral("calendarToolbar"));
+ toolbar->setMovable(false);
+
+ m_collectionBox = new QComboBox(this);
+ m_collectionBox->setObjectName(QStringLiteral("collectionBox"));
+ toolbar->addWidget(m_collectionBox);
+ toolbar->addSeparator();
+
+ auto addButton = [&](const QString &text, auto slot) {
+ auto *b = new QToolButton(this);
+ b->setText(text);
+ connect(b, &QToolButton::clicked, this, slot);
+ toolbar->addWidget(b);
+ return b;
+ };
+ addButton(QStringLiteral("‹"), [this]() {
+ const QDate d = QDate(m_year, m_month, 1).addMonths(-1);
+ showMonth(d.year(), d.month());
+ })->setToolTip(tr("Previous month"));
+ addButton(tr("Today"), [this]() {
+ showMonth(QDate::currentDate().year(), QDate::currentDate().month());
+ m_agendaView->scrollToDay(QDate::currentDate());
+ })->setToolTip(tr("Go to today"));
+ addButton(QStringLiteral("›"), [this]() {
+ const QDate d = QDate(m_year, m_month, 1).addMonths(1);
+ showMonth(d.year(), d.month());
+ })->setToolTip(tr("Next month"));
+
+ m_monthBox = new QComboBox(this);
+ for (int m = 1; m <= 12; ++m)
+ m_monthBox->addItem(QLocale().standaloneMonthName(m), m);
+ toolbar->addWidget(m_monthBox);
+ m_yearSpin = new QSpinBox(this);
+ m_yearSpin->setRange(1900, 2200);
+ toolbar->addWidget(m_yearSpin);
+ connect(m_monthBox, &QComboBox::activated, this,
+ [this](int index) { showMonth(m_year, index + 1); });
+ connect(m_yearSpin, &QSpinBox::valueChanged, this, [this](int year) {
+ if (year != m_year)
+ showMonth(year, m_month);
+ });
+
+ auto *spacer = new QWidget(this);
+ spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
+ toolbar->addWidget(spacer);
+
+ m_monthButton = new QToolButton(this);
+ m_monthButton->setText(tr("Month"));
+ m_monthButton->setCheckable(true);
+ m_agendaButton = new QToolButton(this);
+ m_agendaButton->setText(tr("Agenda"));
+ m_agendaButton->setCheckable(true);
+ auto *group = new QButtonGroup(this);
+ group->addButton(m_monthButton, 0);
+ group->addButton(m_agendaButton, 1);
+ toolbar->addWidget(m_monthButton);
+ toolbar->addWidget(m_agendaButton);
+
+ auto *splitter = new QSplitter(this);
+ m_views = new QStackedWidget(splitter);
+ m_monthView = new MonthView(m_views);
+ m_agendaView = new AgendaView(m_views);
+ m_views->addWidget(m_monthView);
+ m_views->addWidget(m_agendaView);
+ m_pane = new EventPane(splitter);
+ splitter->addWidget(m_views);
+ splitter->addWidget(m_pane);
+ splitter->setStretchFactor(0, 3);
+ splitter->setStretchFactor(1, 1);
+ setCentralWidget(splitter);
+
+ m_dataWarning = new QLabel(this);
+ statusBar()->addPermanentWidget(m_dataWarning);
+
+ connect(group, &QButtonGroup::idClicked, m_views, &QStackedWidget::setCurrentIndex);
+ connect(m_collectionBox, &QComboBox::currentIndexChanged, this, &CalendarWindow::rebuildItems);
+ connect(m_monthView, &MonthView::itemClicked, this, &CalendarWindow::select);
+ connect(m_agendaView, &AgendaView::itemClicked, this, &CalendarWindow::select);
+ connect(m_monthView, &MonthView::dayDoubleClicked, this, &CalendarWindow::startNew);
+ connect(m_monthView, &MonthView::moreClicked, this, [this](const QDate &day) {
+ m_agendaButton->click();
+ m_agendaView->scrollToDay(day);
+ });
+ connect(m_pane, &EventPane::editRequested, this, &CalendarWindow::startEdit);
+ connect(m_pane, &EventPane::deleteRequested, this, &CalendarWindow::deleteSelected);
+ connect(m_pane, &EventPane::saveRequested, this, &CalendarWindow::save);
+ connect(m_pane, &EventPane::cancelRequested, this, &CalendarWindow::cancelEdit);
+}
+```
+
+The actions. **The shortcut contexts are load-bearing.** A `QAction` shortcut is dispatched before the focused widget sees the key (AGENTS.md), so a window-wide `Delete` would delete the EVENT while the user deletes a character in the Title field, `Ctrl+Z` would undo a file write instead of a typo, and `PgUp`/`Home` would change month from inside the Notes box. Those actions are therefore added to the views widget with `Qt::WidgetWithChildrenShortcut`, so they fire only when focus is in the grid or the list. The menu entries still work from anywhere, because a menu triggers the action directly.
+
+```cpp
+void CalendarWindow::buildActions()
+{
+ auto make = [this](const QString &name, const QString &text, const QKeySequence &key,
+ QWidget *scope, auto slot) {
+ auto *a = new QAction(text, this);
+ a->setObjectName(name);
+ a->setShortcut(key);
+ if (scope) {
+ a->setShortcutContext(Qt::WidgetWithChildrenShortcut);
+ scope->addAction(a);
+ } else {
+ addAction(a);
+ }
+ connect(a, &QAction::triggered, this, slot);
+ return a;
+ };
+
+ QAction *newEvent = make(QStringLiteral("newEvent"), tr("&New event"),
+ QKeySequence(Qt::CTRL | Qt::Key_N), nullptr,
+ [this]() { startNew(defaultNewDay()); });
+ QAction *close = make(QStringLiteral("closeCalendar"), tr("&Close"),
+ QKeySequence(Qt::CTRL | Qt::Key_W), nullptr, [this]() { this->close(); });
+ QAction *edit = make(QStringLiteral("editEvent"), tr("&Edit event"),
+ QKeySequence(Qt::CTRL | Qt::Key_E), nullptr, &CalendarWindow::startEdit);
+ QAction *del = make(QStringLiteral("deleteEvent"), tr("&Delete event"),
+ QKeySequence(Qt::Key_Delete), m_views, &CalendarWindow::deleteSelected);
+ QAction *cancel = make(QStringLiteral("cancelEdit"), tr("Cancel &editing"),
+ QKeySequence(Qt::Key_Escape), m_pane, &CalendarWindow::cancelEdit);
+
+ QAction *undo = m_undo.createUndoAction(this, tr("&Undo"));
+ undo->setShortcut(QKeySequence::Undo);
+ undo->setShortcutContext(Qt::WidgetWithChildrenShortcut);
+ m_views->addAction(undo);
+ QAction *redo = m_undo.createRedoAction(this, tr("&Redo"));
+ redo->setShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Z));
+ redo->setShortcutContext(Qt::WidgetWithChildrenShortcut);
+ m_views->addAction(redo);
+
+ QAction *prev = make(QStringLiteral("previousMonth"), tr("&Previous month"),
+ QKeySequence(Qt::Key_PageUp), m_views, [this]() {
+ const QDate d = QDate(m_year, m_month, 1).addMonths(-1);
+ showMonth(d.year(), d.month());
+ });
+ QAction *next = make(QStringLiteral("nextMonth"), tr("&Next month"),
+ QKeySequence(Qt::Key_PageDown), m_views, [this]() {
+ const QDate d = QDate(m_year, m_month, 1).addMonths(1);
+ showMonth(d.year(), d.month());
+ });
+ QAction *today = make(QStringLiteral("today"), tr("&Today"),
+ QKeySequence(Qt::Key_Home), m_views, [this]() {
+ showMonth(QDate::currentDate().year(), QDate::currentDate().month());
+ });
+ QAction *month = make(QStringLiteral("monthView"), tr("&Month"), {}, nullptr,
+ [this]() { m_monthButton->click(); });
+ QAction *agenda = make(QStringLiteral("agendaView"), tr("&Agenda"), {}, nullptr,
+ [this]() { m_agendaButton->click(); });
+
+ QMenu *file = menuBar()->addMenu(tr("&File"));
+ file->addAction(newEvent);
+ file->addSeparator();
+ file->addAction(close);
+ QMenu *editMenu = menuBar()->addMenu(tr("&Edit"));
+ editMenu->addAction(undo);
+ editMenu->addAction(redo);
+ editMenu->addSeparator();
+ editMenu->addAction(edit);
+ editMenu->addAction(del);
+ editMenu->addAction(cancel);
+ QMenu *view = menuBar()->addMenu(tr("&View"));
+ view->addAction(month);
+ view->addAction(agenda);
+ view->addSeparator();
+ view->addAction(prev);
+ view->addAction(next);
+ view->addAction(today);
+}
+```
+
+```cpp
+/// Where + New puts an event: today when today is in the shown month,
+/// otherwise the month's first day, so the new event is on screen.
+QDate CalendarWindow::defaultNewDay() const
+{
+ const QDate first(m_year, m_month, 1);
+ const QDate today = QDate::currentDate();
+ return today >= first && today < first.addMonths(1) ? today : first;
+}
+
+void CalendarWindow::showMonth(int year, int month)
+{
+ m_year = year;
+ m_month = month;
+ m_monthView->setMonth(year, month);
+ refreshToolbar();
+ rebuildItems();
+}
+
+void CalendarWindow::refreshToolbar()
+{
+ const QSignalBlocker a(m_monthBox), b(m_yearSpin);
+ m_monthBox->setCurrentIndex(m_month - 1);
+ m_yearSpin->setValue(m_year);
+}
+
+void CalendarWindow::reload()
+{
+ m_data = CalendarStore::load(m_config.calendarsDir());
+
+ if (!m_watcher.directories().isEmpty())
+ m_watcher.removePaths(m_watcher.directories());
+ QStringList dirs{ m_config.calendarsDir() };
+ for (const CalCollection &c : m_data.collections)
+ dirs << c.path;
+ m_watcher.addPaths(dirs);
+
+ const QString current = m_collectionBox->currentData().toString();
+ {
+ const QSignalBlocker block(m_collectionBox);
+ m_collectionBox->clear();
+ m_collectionBox->addItem(tr("All calendars"), QString());
+ for (const CalCollection &c : m_data.collections)
+ m_collectionBox->addItem(swatch(c.color), c.displayName, c.dir);
+ m_collectionBox->setCurrentIndex(qMax(0, m_collectionBox->findData(current)));
+ }
+
+ QStringList warnings;
+ if (m_data.unparsable)
+ warnings << tr("%n file(s) could not be read", nullptr, m_data.unparsable);
+ if (m_data.unknownZones)
+ warnings << tr("%n event(s) in an unknown time zone, shown in local time", nullptr,
+ m_data.unknownZones);
+ m_dataWarning->setText(warnings.join(QStringLiteral("; ")));
+ rebuildItems();
+}
+
+void CalendarWindow::rebuildItems()
+{
+ const QString filter = m_collectionBox->currentData().toString();
+ const QDateTime from(m_monthView->firstDay(), QTime(0, 0));
+ const QDateTime to(m_monthView->lastDay(), QTime(0, 0));
+ m_items.clear();
+ for (const Occurrence &o : CalendarStore::occurrences(m_data.events, from, to)) {
+ const CalEvent &e = m_data.events[o.eventIndex];
+ if (!filter.isEmpty() && e.collectionDir != filter)
+ continue;
+ const CalCollection *c = collection(e.collectionDir);
+ QString title = o.isOverride ? e.overrides[o.overrideIndex].summary : e.summary;
+ if (title.isEmpty())
+ title = tr("(no title)");
+ m_items.append({ o, title, c ? c->color : QColor(Qt::gray) });
+ }
+ m_monthView->setItems(m_items);
+
+ // The agenda covers the month the toolbar names, not the grid's
+ // spill-over. Out-of-month items become blanks, which AgendaView skips,
+ // so a row's index is the same index the month grid reports.
+ // ponytail: blanks rather than an index map; add the map if the agenda
+ // ever needs a range of its own.
+ const QDate first(m_year, m_month, 1);
+ QList<CalendarItem> agenda;
+ for (const CalendarItem &item : m_items) {
+ const QDate d = item.occurrence.start.toLocalTime().date();
+ agenda.append(d >= first && d < first.addMonths(1) ? item : CalendarItem{});
+ }
+ m_agendaView->setItems(agenda);
+
+ // Re-find the selection by identity: a reload renumbers everything.
+ m_selected = -1;
+ for (int i = 0; i < m_items.size(); ++i) {
+ const Occurrence &o = m_items[i].occurrence;
+ if (m_data.events[o.eventIndex].uid == m_selectedUid
+ && (!m_selectedStart.isValid() || o.start == m_selectedStart)) {
+ m_selected = i;
+ break;
+ }
+ }
+ m_monthView->setSelected(m_selected);
+ m_agendaView->setSelected(m_selected);
+ if (!m_pane->isEditing()) {
+ if (m_selected >= 0) {
+ const Occurrence &o = m_items[m_selected].occurrence;
+ const CalEvent &e = m_data.events[o.eventIndex];
+ const CalCollection *c = collection(e.collectionDir);
+ m_pane->showDetails(e, c ? *c : CalCollection{}, o,
+ c && CalendarStore::isEditable(e, *c, m_ownAddresses));
+ } else {
+ m_pane->showNothing();
+ }
+ }
+}
+```
+
+```cpp
+void CalendarWindow::select(int item)
+{
+ if (m_pane->isEditing()) {
+ status(tr("Save or cancel the event being edited first."));
+ return;
+ }
+ if (item < 0 || item >= m_items.size())
+ return;
+ const Occurrence &o = m_items[item].occurrence;
+ m_selectedUid = m_data.events[o.eventIndex].uid;
+ m_selectedStart = o.start;
+ rebuildItems();
+}
+
+bool CalendarWindow::selectEvent(const QString &uid, const QDateTime &start)
+{
+ if (m_pane->isEditing())
+ return false;
+ for (int i = 0; i < m_items.size(); ++i) {
+ const Occurrence &o = m_items[i].occurrence;
+ if (m_data.events[o.eventIndex].uid == uid && (!start.isValid() || o.start == start)) {
+ select(i);
+ return true;
+ }
+ }
+ return false;
+}
+
+QString CalendarWindow::selectedUid() const
+{
+ return m_selected >= 0 ? m_data.events[m_items[m_selected].occurrence.eventIndex].uid : QString();
+}
+
+bool CalendarWindow::isEditing() const { return m_pane->isEditing(); }
+
+const CalCollection *CalendarWindow::collection(const QString &dir) const
+{
+ for (const CalCollection &c : m_data.collections)
+ if (c.dir == dir)
+ return &c;
+ return nullptr;
+}
+
+QList<CalCollection> CalendarWindow::writableCollections() const
+{
+ QList<CalCollection> result;
+ for (const CalCollection &c : m_data.collections)
+ if (!c.readOnly)
+ result.append(c);
+ return result;
+}
+
+QString CalendarWindow::defaultCollection() const
+{
+ const QList<CalCollection> writable = writableCollections();
+ for (const CalCollection &c : writable)
+ if (c.dir == m_config.defaultCalendar())
+ return c.dir;
+ return writable.isEmpty() ? QString() : writable.first().dir;
+}
+
+void CalendarWindow::startEdit()
+{
+ if (m_pane->isEditing() || m_selected < 0)
+ return;
+ const Occurrence &o = m_items[m_selected].occurrence;
+ const CalEvent &e = m_data.events[o.eventIndex];
+ const CalCollection *c = collection(e.collectionDir);
+ if (!c || !CalendarStore::isEditable(e, *c, m_ownAddresses))
+ return;
+
+ // A repeating event asks its scope when Edit is pressed, never after.
+ m_editWholeSeries = true;
+ m_editRecurrenceId = {};
+ if (e.repeat.freq != RepeatRule::Freq::None || e.repeat.custom) {
+ QMessageBox box(QMessageBox::Question, tr("Edit a repeating event"),
+ tr("Edit only this occurrence, or every occurrence?"),
+ QMessageBox::Cancel, this);
+ QPushButton *one = box.addButton(tr("This occurrence"), QMessageBox::AcceptRole);
+ QPushButton *all = box.addButton(tr("All occurrences"), QMessageBox::AcceptRole);
+ box.exec();
+ if (box.clickedButton() != one && box.clickedButton() != all)
+ return;
+ m_editWholeSeries = box.clickedButton() == all;
+ m_editRecurrenceId = o.recurrenceId;
+ }
+
+ EventEdit initial;
+ const CalOverride *ov = o.isOverride ? &e.overrides[o.overrideIndex] : nullptr;
+ initial.summary = ov ? ov->summary : e.summary;
+ initial.location = ov ? ov->location : e.location;
+ initial.description = ov ? ov->description : e.description;
+ initial.allDay = m_editWholeSeries ? e.allDay : o.allDay;
+ initial.start = m_editWholeSeries ? e.start : o.start;
+ initial.end = m_editWholeSeries ? e.end : o.end;
+ initial.repeat = e.repeat;
+ initial.collectionDir = e.collectionDir;
+
+ QStringList people;
+ for (const CalPerson &a : e.attendees)
+ people << (a.name.isEmpty() ? a.address : a.name);
+
+ m_editPath = e.filePath;
+ m_editBase = e.rawText;
+ m_pane->startEdit(initial, writableCollections(), m_editWholeSeries, m_editWholeSeries,
+ e.hasAlarm ? tr("Kept as set elsewhere") : tr("None"),
+ people.join(QStringLiteral(", ")));
+}
+
+void CalendarWindow::startNew(const QDate &day)
+{
+ if (m_pane->isEditing() || defaultCollection().isEmpty())
+ return;
+ EventEdit initial;
+ initial.start = QDateTime(day, QTime(9, 0));
+ initial.end = initial.start.addSecs(3600);
+ initial.collectionDir = defaultCollection();
+ m_editPath.clear();
+ m_editBase.clear();
+ m_editWholeSeries = true;
+ m_editRecurrenceId = {};
+ m_pane->startEdit(initial, writableCollections(), true, true, tr("None"), QString());
+}
+
+bool CalendarWindow::save()
+{
+ const EventEdit edit = m_pane->edit();
+ const CalCollection *target = collection(edit.collectionDir);
+ if (!target)
+ return false;
+ QList<Change> changes;
+ QString label;
+
+ if (m_editPath.isEmpty()) {
+ const QByteArray text = CalendarStore::newEvent(edit, QTimeZone::systemTimeZoneId());
+ bool ignored = false;
+ const QString uid = CalendarStore::parseEvent(text, {}, {}, &ignored).uid;
+ changes.append({ target->path + QLatin1Char('/') + uid + QStringLiteral(".ics"),
+ std::nullopt, text });
+ label = tr("New event");
+ } else {
+ const QByteArray text = CalendarStore::applyEdit(
+ m_editBase, edit,
+ m_editWholeSeries ? CalendarStore::Scope::All : CalendarStore::Scope::ThisOccurrence,
+ m_editRecurrenceId);
+ if (text.isEmpty())
+ return false;
+ const QString newPath = target->path + QLatin1Char('/') + QFileInfo(m_editPath).fileName();
+ if (newPath == m_editPath) {
+ changes.append({ m_editPath, m_editBase, text });
+ } else {
+ // Moving to another calendar: the new file first, then the old
+ // one removed, so a failure between them duplicates rather than
+ // loses the event.
+ changes.append({ newPath, std::nullopt, text });
+ changes.append({ m_editPath, m_editBase, std::nullopt });
+ }
+ label = tr("Edit event");
+ }
+
+ if (!writeAndRecord(label, changes))
+ return false; // the form stays open with the user's values
+ m_pane->stopEdit();
+ bool ignored = false;
+ m_selectedUid = CalendarStore::parseEvent(*changes.first().after, {}, {}, &ignored).uid;
+ m_selectedStart = {};
+ reload();
+ return true;
+}
+
+void CalendarWindow::cancelEdit()
+{
+ if (!m_pane->isEditing())
+ return;
+ m_pane->stopEdit();
+ rebuildItems();
+}
+
+void CalendarWindow::deleteSelected()
+{
+ if (m_pane->isEditing() || m_selected < 0)
+ return;
+ const Occurrence &o = m_items[m_selected].occurrence;
+ const CalEvent &e = m_data.events[o.eventIndex];
+ const CalCollection *c = collection(e.collectionDir);
+ if (!c || !CalendarStore::isEditable(e, *c, m_ownAddresses))
+ return;
+
+ // No confirmation: the delete is undoable (spec decision 14). A repeating
+ // event asks its SCOPE, which is a choice, not a confirmation.
+ std::optional<QByteArray> after = std::nullopt;
+ if (e.repeat.freq != RepeatRule::Freq::None || e.repeat.custom) {
+ QMessageBox box(QMessageBox::Question, tr("Delete a repeating event"),
+ tr("Delete only this occurrence, or every occurrence?"),
+ QMessageBox::Cancel, this);
+ QPushButton *one = box.addButton(tr("This occurrence"), QMessageBox::AcceptRole);
+ QPushButton *all = box.addButton(tr("All occurrences"), QMessageBox::AcceptRole);
+ box.exec();
+ if (box.clickedButton() == one)
+ after = CalendarStore::deleteOccurrence(e.rawText, o.recurrenceId);
+ else if (box.clickedButton() != all)
+ return;
+ }
+ if (writeAndRecord(tr("Delete event"), { { e.filePath, e.rawText, after } })) {
+ m_selectedUid.clear();
+ reload();
+ }
+}
+
+bool CalendarWindow::applyChanges(const QList<Change> &changes, bool reverse)
+{
+ QList<Change> done;
+ for (int i = 0; i < changes.size(); ++i) {
+ const Change &c = changes[reverse ? changes.size() - 1 - i : i];
+ const auto from = reverse ? c.after : c.before;
+ const auto to = reverse ? c.before : c.after;
+ QString error;
+ const CalendarWriter::Result r = CalendarWriter::replace(c.path, from, to, &error);
+ if (r != CalendarWriter::Result::Ok) {
+ // Roll back what this call already did, newest first.
+ // ponytail: best effort; a rollback that itself fails is reported
+ // by the file being wrong on the next reload, not handled here.
+ for (int j = done.size() - 1; j >= 0; --j) {
+ QString ignored;
+ CalendarWriter::replace(done[j].path, done[j].after, done[j].before, &ignored);
+ }
+ status(r == CalendarWriter::Result::Stale
+ ? tr("The event changed on disk, probably from a sync. Check it and try again.")
+ : tr("Could not write the calendar: %1").arg(error));
+ reload();
+ return false;
+ }
+ done.append({ c.path, from, to });
+ m_written.insert(c.path, to);
+ }
+ m_sync->schedule();
+ m_reloadTimer.start();
+ return true;
+}
+
+bool CalendarWindow::writeAndRecord(const QString &label, const QList<Change> &changes)
+{
+ if (!applyChanges(changes, false))
+ return false;
+ m_undo.push(new FileCommand(label, changes, this));
+ return true;
+}
+
+void CalendarWindow::checkAfterSync()
+{
+ // Compared by MEANING: the server may normalise the text on the round
+ // trip, and a byte comparison would warn on every save.
+ QStringList lost;
+ for (auto it = m_checking.cbegin(); it != m_checking.cend(); ++it) {
+ const bool exists = QFile::exists(it.key());
+ const bool kept = it.value()
+ ? exists && CalendarStore::sameMeaning(readFile(it.key()), *it.value())
+ : !exists;
+ if (!kept) {
+ bool ignored = false;
+ const QString title = it.value()
+ ? CalendarStore::parseEvent(*it.value(), {}, {}, &ignored).summary
+ : QFileInfo(it.key()).completeBaseName();
+ lost << title;
+ }
+ }
+ m_checking.clear();
+ status(lost.isEmpty()
+ ? tr("Calendars synced.")
+ : tr("The server kept a different version of: %1").arg(lost.join(QStringLiteral(", "))));
+}
+
+void CalendarWindow::status(const QString &text)
+{
+ statusBar()->showMessage(text, 10000);
+}
+
+void CalendarWindow::closeEvent(QCloseEvent *event)
+{
+ // A QMainWindow, not a QDialog, so every route out (the title bar, the
+ // File menu, close()) arrives here; AGENTS.md's QDialog trap does not
+ // apply, but the test still drives two routes.
+ if (m_pane->isDirty()) {
+ const auto answer = QMessageBox::question(
+ this, tr("Unsaved event"), tr("Save the changes to this event?"),
+ QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel, QMessageBox::Save);
+ if (answer == QMessageBox::Cancel || (answer == QMessageBox::Save && !save())) {
+ event->ignore();
+ return;
+ }
+ m_pane->stopEdit();
+ }
+ QDir().mkpath(QFileInfo(m_uiStatePath).absolutePath());
+ QSettings state(m_uiStatePath, QSettings::IniFormat);
+ state.beginGroup(QStringLiteral("calendar"));
+ state.setValue(QStringLiteral("geometry"), saveGeometry());
+ state.setValue(QStringLiteral("agenda"), m_views->currentIndex() == 1);
+ state.setValue(QStringLiteral("collection"), m_collectionBox->currentData().toString());
+ state.endGroup();
+ QMainWindow::closeEvent(event);
+}
+```
+
+Two things to confirm while implementing rather than trust: `QHash::insert(const QHash &)` exists in Qt 6 (it does from 6.0; otherwise loop), and `QTimeZone::systemTimeZoneId()` returns an IANA id on this system (`Europe/Rome`), which `newEvent` needs for its VTIMEZONE.
+
+- [ ] **Step 5: Run the tests**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_calendarwindow`
+Expected: all PASS. A hang means a message box appeared that no test answered: the scope question for a repeating event, most likely, which none of these fixtures should trigger.
+
+- [ ] **Step 6: Mutation-check the two data-safety tests**
+
+Per AGENTS.md, a test that cannot fail proves nothing. Temporarily:
+1. In `CalendarWriter::replace`, change `if (current.readAll() != *expected)` to `if (false)`. Run `test_calendarwindow`: `aStaleSaveKeepsTheFormAndTheSyncedFile` must FAIL. Revert.
+2. In `FileCommand::undo`, make it return without calling `applyChanges`. `saveWritesAndUndoRestoresTheOriginalBytes` must FAIL. Revert.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/calendarwindow.h src/calendarwindow.cpp src/CMakeLists.txt \
+ tests/CMakeLists.txt tests/test_calendarwindow.cpp
+git commit -m "feat: the calendar window, with undo and a stale-safe save"
+```
+
+---
+
+### Task 15: Opening the calendar from the main window
+
+**Files:**
+- Modify: `src/keymap.cpp` (`knownActions`)
+- Modify: `src/mainwindow.h`, `src/mainwindow.cpp` (action, icon, View menu, toolbar, open, close)
+- Test: `tests/test_mainwindow.cpp`
+
+- [ ] **Step 1: Write the failing test**
+
+```cpp
+ void theCalendarActionWithNoCalendarOpensNothing();
+```
+
+```cpp
+void TestMainWindow::theCalendarActionWithNoCalendarOpensNothing()
+{
+ // The default test config sets no calendars_dir: the feature is off.
+ MainWindow window(Config{});
+ QAction *action = window.findChild<QAction *>(QStringLiteral("calendar"));
+ QVERIFY(action);
+ action->trigger();
+ const QWidgetList tops = QApplication::topLevelWidgets();
+ QVERIFY(std::none_of(tops.cbegin(), tops.cend(), [](QWidget *w) {
+ return qobject_cast<CalendarWindow *>(w) != nullptr;
+ }));
+}
+```
+
+Check how the other `test_mainwindow` cases build a window (they may use a fixture config rather than `Config{}`) and follow them. Check also how `MainWindow::addAction` names its QActions; if it does not set `objectName`, find the action through the map the tests already use. Add `#include "calendarwindow.h"`.
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_mainwindow theCalendarActionWithNoCalendarOpensNothing`
+Expected: FAIL, no `calendar` action.
+
+- [ ] **Step 3: Register the action in all five places**
+
+1. `src/keymap.cpp`, `knownActions()`, after `QStringLiteral("tag_rules"),`:
+
+```cpp
+ // Item 206. No default chord: the calendar is opened from the View
+ // menu and the toolbar, and a shortcut is a chosen subset (item 132).
+ QStringLiteral("calendar"),
+```
+
+2. `defaultBindings()`: no entry, deliberately.
+
+3. The icon table in `mainwindow.cpp`, beside `tag_rules`:
+
+```cpp
+ { QStringLiteral("calendar"), { QStringLiteral("x-office-calendar"), QString() } },
+```
+
+4. `addAction` beside `tag_rules`:
+
+```cpp
+ addAction(QStringLiteral("calendar"), tr("&Calendar"),
+ tr("Open the calendar"), [this]() {
+ openCalendar();
+ });
+```
+
+5. The View menu, after `zoom_reset`, and the main toolbar after `mark_all_read`:
+
+```cpp
+ viewMenu->addSeparator();
+ viewMenu->addAction(m_actions.value(QStringLiteral("calendar")));
+```
+
+```cpp
+ toolBar->addAction(m_actions.value(QStringLiteral("calendar")));
+```
+
+- [ ] **Step 4: Open it, once, and close it with the main window**
+
+`src/mainwindow.h`: forward-declare `class CalendarWindow;`, add `void openCalendar();` to the private functions and `QPointer<CalendarWindow> m_calendar;` beside `m_composers`.
+
+`src/mainwindow.cpp`:
+
+```cpp
+void MainWindow::openCalendar()
+{
+ if (m_config.calendarsDir().isEmpty()) {
+ statusBar()->showMessage(
+ tr("No calendar is configured: set calendars_dir under [general]."), 10000);
+ return;
+ }
+ if (m_calendar) {
+ m_calendar->raise();
+ m_calendar->activateWindow();
+ return;
+ }
+ QStringList own;
+ for (const Account &account : m_config.accounts())
+ if (!account.address.isEmpty())
+ own << account.address;
+ // Parentless, like a composer: its own entry in the task switcher.
+ m_calendar = new CalendarWindow(m_config, own, uiStatePath());
+ m_calendar->setAttribute(Qt::WA_DeleteOnClose);
+ m_calendar->show();
+}
+```
+
+If the status bar is reached through a member (`m_statusLabel`, AGENTS.md) rather than `statusBar()`, use the same route the neighbouring code uses for a transient message.
+
+In `MainWindow::closeEvent`, directly after the `m_syncingForExit` guard and BEFORE the first prompt, so a Cancel in the calendar's own prompt stops the quit before anything else is asked:
+
+```cpp
+ // The calendar is parentless, so Qt would leave it open with nothing
+ // behind it (the composer precedent above). Its own closeEvent asks about
+ // an unsaved event; a Cancel there cancels the quit.
+ if (m_calendar) {
+ m_calendar->close();
+ if (m_calendar && m_calendar->isVisible()) {
+ event->ignore();
+ return;
+ }
+ }
+```
+
+- [ ] **Step 5: Run the whole main-window suite**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_mainwindow`
+Expected: the new test PASSES, and so do `everyActionIsReachableFromAMenu`, the icon-uniqueness test and the `KeyMap` constructor assert, which are the other three of the five places. `undoMovesTheMessageBack` fails as before; nothing else may.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/keymap.cpp src/mainwindow.h src/mainwindow.cpp tests/test_mainwindow.cpp
+git commit -m "feat: open the calendar from the View menu and the toolbar"
+```
+
+---
+
+### Task 16: Translation, documentation, backlog, and the live check
+
+**Files:**
+- Modify: `translations/qtmaildir_it_IT.ts`, `README.md`, `CHANGELOG.md`, `AGENTS.md`
+- Modify: `docs/superpowers/specs/2026-09-24-calendar-design.md` (the rulings)
+- Modify: `docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md`
+- Test: `tests/test_calendarstore.cpp` (the opt-in live check)
+
+- [ ] **Step 1: The read-only live check**
+
+Add to `test_calendarstore.cpp`:
+
+```cpp
+ void theLiveVdirLoadsCleanly();
+```
+
+```cpp
+void TestCalendarStore::theLiveVdirLoadsCleanly()
+{
+ // Opt-in, READ-ONLY: never writes. Run by hand before handing the build
+ // over: QTMAILDIR_LIVE_CALENDARS=~/.local/share/calendars ./test_calendarstore
+ const QString dir = qEnvironmentVariable("QTMAILDIR_LIVE_CALENDARS");
+ if (dir.isEmpty())
+ QSKIP("QTMAILDIR_LIVE_CALENDARS not set");
+ const LoadResult r = CalendarStore::load(dir);
+ qInfo("collections=%lld events=%lld unparsable=%d unknownZones=%d",
+ qlonglong(r.collections.size()), qlonglong(r.events.size()),
+ r.unparsable, r.unknownZones);
+ QCOMPARE(r.unparsable, 0);
+ const QList<Occurrence> year = CalendarStore::occurrences(
+ r.events, QDateTime(QDate(2026, 1, 1), QTime(0, 0)),
+ QDateTime(QDate(2027, 1, 1), QTime(0, 0)));
+ qInfo("occurrences in 2026=%lld", qlonglong(year.size()));
+}
+```
+
+Run: `QTMAILDIR_LIVE_CALENDARS=$HOME/.local/share/calendars QT_QPA_PLATFORM=offscreen ./build/tests/test_calendarstore theLiveVdirLoadsCleanly`
+Expected: PASS with `collections=3 events=312 unparsable=0`. Report the `unknownZones` and occurrence counts to the user; they are real measurements and belong in the hand-off (AGENTS.md: counts are not personal data).
+
+- [ ] **Step 2: Translate**
+
+Run: `lupdate-qt6 src/ -ts translations/qtmaildir_it_IT.ts -no-obsolete -locations none`
+Expected: zero "tr() cannot be called without context" warnings. Translate every new entry in Italian, including BOTH plural forms of `+%n more`, `%n file(s) could not be read` and `%n event(s) in an unknown time zone...`, and the `RepeatRule` context's ordinals.
+Run: `lrelease-qt6 translations/qtmaildir_it_IT.ts`
+Expected: `0 unfinished`.
+Run: `ctest --test-dir build -R translations --output-on-failure`
+Expected: PASS.
+
+- [ ] **Step 3: README**
+
+Add a `## Calendar` section after the contact-completion section: what the window does (Month, Agenda, edit in the side pane, undo), that it reads a vdirsyncer vdir and needs no khal, that names and colours come from each collection's `displayname` and `color` files, which `vdirsyncer metasync` fills when the pair has `metadata = ["color", "displayname"]`, and that events organised by someone else are read-only. Document the four keys in the config reference table:
+
+| Key | Default | Meaning |
+|---|---|---|
+| `calendars_dir` | unset (off) | The vdir root, e.g. `~/.local/share/calendars/`. |
+| `default_calendar` | first collection | Directory name 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` | Delay before it runs. |
+
+And the cron note: prefix the vdirsyncer cron line with the same `flock -w 60 /tmp/vdirsyncer.lock` so the two never run together; append `&& vdirsyncer metasync` to keep names and colours current.
+
+- [ ] **Step 4: CHANGELOG**
+
+Under `## [Unreleased]`, `### Added`: a calendar window (item 206) with the one-line summary; and `### Upgrading`: the calendar is off until `calendars_dir` is set, and the cron line wants the `flock` prefix.
+
+- [ ] **Step 5: AGENTS.md**
+
+Add `CalendarWindow` and `EventPane` to the list of widget classes; `CalendarStore`, `CalendarWriter` and `MonthLayout` to the namespaces paragraph; the diagram line `CalendarWindow (its own top-level window) ... CalendarStore (the only libical user)`. Then a section of the calendar's traps, each learned in this plan:
+
+- libical is confined to `calendarstore.cpp` behind `icalraii.h`, the nmraii rule.
+- Expansion runs in the event's own zone: a weekly 10:00 Rome event is 10:00 on both sides of DST, so its UTC time moves.
+- All-day `DTEND` is exclusive; the form shows the inclusive last day and converts.
+- 120 of the user's files name a `TZID` without a `VTIMEZONE`; an edit must write the `TZID` parameter back, never convert.
+- The calendar's `Delete`, `Ctrl+Z`, `PgUp`/`PgDn` and `Home` shortcuts are scoped to the views with `Qt::WidgetWithChildrenShortcut`, or they fire while the user types in the form.
+- `calendar_sync_command` is read through a QStringList join, because QSettings splits a comma-holding value and `toString()` on the list is empty.
+
+- [ ] **Step 6: Amend the spec**
+
+In `docs/superpowers/specs/2026-09-24-calendar-design.md`: `Status: approved, implemented on branch calendar`; the config table's `calendars_dir` default becomes "unset (off)" (ruling 1); "byte for byte" becomes "value for value" in the Testing section (ruling 2); the atomic write names `QSaveFile`; the shortcut scoping is added under "Menus and keys".
+
+- [ ] **Step 7: Backlog**
+
+In the status table of `docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md`:
+- 206's row: `open, **built on branch calendar** YYYY-MM-DD, awaiting hand test; spec specs/2026-09-24-calendar-design.md, plan plans/2026-09-24-calendar.md`.
+- 205's row: append "its write-during-sync question is answered by 206's spec, § Writing: the same writer and stale check serve a vCard".
+- New row 209: `The calendar has no Week view | presentation | M | open, from 206's brainstorm. The occurrence list is the seam: a new painter over CalendarItem, no store change.`
+- New row 210: `A repeating event cannot be edited "this and following" | workflow | S-M | open, from 206's brainstorm. Splits a series into two files, divides EXDATEs and overrides, shifts moved RECURRENCE-IDs, converts COUNT; see the spec's Out of scope.`
+- Add sections `## 209.` and `## 210.` with Observed / Cause / Approach / Constraints, citing the spec.
+
+- [ ] **Step 8: Full suite**
+
+Run: `ctest --test-dir build --output-on-failure`
+Expected: everything passes except the known pre-existing `test_mainwindow::undoMovesTheMessageBack` (and possibly the two order-dependent flakes; rerun those alone before calling anything a regression).
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add translations/qtmaildir_it_IT.ts README.md CHANGELOG.md AGENTS.md \
+ docs/superpowers/specs/2026-09-24-calendar-design.md \
+ docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md tests/test_calendarstore.cpp
+git commit -m "docs: document the calendar, translate it, file its follow-ups"
+```
+
+- [ ] **Step 10: Hand over for the hand test**
+
+Do NOT launch the application (AGENTS.md). Tell the user the branch is ready, give the live-check counts, and say what to look at:
+1. View > Calendar opens one window; a second trigger raises it.
+2. The collection combo shows your three calendars with their server colours; "All calendars" first.
+3. Today's pill; ‹ Today › and the month/year controls; PgUp/PgDn with the grid focused.
+4. A birthday (yearly) and the monthly day-18 series fall where they should; a DST-crossing weekly event keeps its hour in late October.
+5. Clicking an invitation someone else organised shows "Organised by ...: read-only" and no Edit/Delete.
+6. Edit an event you organised: the form replaces the pane; the grid still navigates; clicking another event is refused with a hint; typing Delete or Ctrl+Z in the Title field edits the text, not the calendar.
+7. Save; Ctrl+Z (grid focused) restores it; the status bar reports the sync and, after it, "Calendars synced."
+8. + New and double-click on an empty day open a form in the default calendar.
+9. Close with an unsaved edit: Save / Discard / Cancel.
+
+Merge `calendar` to master only after the user's verdict (memory: test before commit applies to the merge).