# 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_`. 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 #include #include /// 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; struct IcalRecurFree { void operator()(icalrecur_iterator *i) const { icalrecur_iterator_free(i); } }; using IcalRecurIterator = std::unique_ptr; /// 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; ``` - [ ] **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 #include #include /// 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 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 #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 #include #include #include #include /// 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 exdates; QList overrides; bool hasAlarm = false; CalPerson organizer; ///< Empty address: no ORGANIZER. QList 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 collections; QList 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 #include #include #include #include #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 #include #include #include #include /// 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 occurrences(const QList &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 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 occurrences(const QList &, 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 ` 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 ``, ``, ``, `` and `` 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(hash[0]) * 256 + static_cast(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 #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("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{ 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("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 #include #include #include 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 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(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 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 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 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 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 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 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 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{ 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 seriesStarts(const QByteArray &text, const QDateTime &to) { QList 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 occurrences(const QList &events, const QDateTime &from, const QDateTime &to) { QList 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 ` 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 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{ 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 ` 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 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 #include #include #include #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 #include #include /// 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 &expected, const std::optional &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 #include #include namespace CalendarWriter { Result replace(const QString &path, const std::optional &expected, const std::optional &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 ".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 `..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 #include #include #include #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 #include #include /// 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 #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 #include #include /// 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 /// 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 &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> itemsPerCell(const MonthLayout &layout) const; int m_year; int m_month; QList m_items; int m_selected = -1; }; ``` - [ ] **Step 2: Write `src/monthview.cpp`** ```cpp /* GPLv2 header */ #include "monthview.h" #include "monthlayout.h" #include #include #include 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 &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> MonthView::itemsPerCell(const MonthLayout &l) const { QList> 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> 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 &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 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 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 /// 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 &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 #include #include 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 &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 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 &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 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 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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(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(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(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(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(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 &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 `. `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 #include #include #include #include #include #include #include #include #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(QApplication::activeModalWidget()); QVERIFY2(box, "no modal message box appeared"); box->button(button)->click(); }); } void editTitle(CalendarWindow *w, const QString &title) { w->findChild(QStringLiteral("editEvent"))->click(); w->findChild(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 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 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 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(QStringLiteral("closeCalendar"))->trigger(); QVERIFY(!w->isVisible()); QCOMPARE(read(f.a()), original); // Save closes and writes. std::unique_ptr 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 w(f.window()); w->selectEvent(QStringLiteral("one@example.org")); editTitle(w.get(), QStringLiteral("Renamed")); w->findChild(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 w(f.window()); w->findChild(QStringLiteral("newEvent"))->trigger(); w->findChild(QStringLiteral("eventTitle"))->setText(QStringLiteral("Fresh")); w->findChild(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 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(QStringLiteral("saveEvent"))->click(); QCOMPARE(read(f.a()), synced); // nothing clobbered QVERIFY(w->isEditing()); // the user's values are still there QCOMPARE(w->findChild(QStringLiteral("eventTitle"))->text(), QStringLiteral("Mine")); } QTEST_MAIN(TestCalendarWindow) #include "test_calendarwindow.moc" ``` `std::unique_ptr` 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 ` and `#include `. - [ ] **Step 2: Write `src/calendarwindow.h`** ```cpp /* GPLv2 header */ #pragma once #include "caltypes.h" #include "config.h" #include #include #include #include #include #include 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 before; std::optional 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 &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 &changes); void checkAfterSync(); const CalCollection *collection(const QString &dir) const; QList writableCollections() const; QString defaultCollection() const; void status(const QString &text); Config m_config; QStringList m_ownAddresses; QString m_uiStatePath; LoadResult m_data; QList 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> m_written; QHash> 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 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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 &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 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 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 CalendarWindow::writableCollections() const { QList result; for (const CalCollection &c : m_data.collections) if (!c.readOnly) result.append(c); return result; } QString CalendarWindow::defaultCollection() const { const QList 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 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 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 &changes, bool reverse) { QList 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 &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(QStringLiteral("calendar")); QVERIFY(action); action->trigger(); const QWidgetList tops = QApplication::topLevelWidgets(); QVERIFY(std::none_of(tops.cbegin(), tops.cend(), [](QWidget *w) { return qobject_cast(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 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 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).