diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-24 15:43:56 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-24 15:43:56 +0200 |
| commit | c3822fedfae813efda52f131368f0017f6b28a87 (patch) | |
| tree | 164c35bc7a71e79100331ecdcc82e96c2da7de46 /docs/superpowers/plans/2026-09-24-calendar.md | |
| parent | fe9a117bd70fae95196a7cbff1cd88b1ba9478e9 (diff) | |
| download | qtmaildir-c3822fedfae813efda52f131368f0017f6b28a87.tar.gz qtmaildir-c3822fedfae813efda52f131368f0017f6b28a87.zip | |
docs: plan the calendar window (item 206)
Sixteen TDD tasks from the libical build wiring to the hand-test
hand-off. Four rulings against the spec are recorded at the top:
calendars_dir absent means off, preserved properties are compared by
value rather than bytes, views read CalendarItem, and expansion runs
from DTSTART without icalrecur_iterator_set_start.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'docs/superpowers/plans/2026-09-24-calendar.md')
| -rw-r--r-- | docs/superpowers/plans/2026-09-24-calendar.md | 5468 |
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). |
