diff options
Diffstat (limited to 'docs/superpowers')
| -rw-r--r-- | docs/superpowers/plans/2026-10-13-calendar.md | 2202 |
1 files changed, 2202 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-13-calendar.md b/docs/superpowers/plans/2026-10-13-calendar.md new file mode 100644 index 0000000..46aaeac --- /dev/null +++ b/docs/superpowers/plans/2026-10-13-calendar.md @@ -0,0 +1,2202 @@ +# Calendar Panel 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 calendar panel dropping from the island's clock pill: month grid, day agenda, and an event editor that writes the vdirsyncer calendars. + +**Architecture:** `calctl`, a Python CLI over the vdir (icalendar + dateutil), does every read and write and answers in one JSON envelope. `Cal.js` holds the pure date logic the QML needs, tested under node. `Calendar.qml` is a singleton that runs calctl and watches the calendar directories; `CalendarPanel.qml` and `EventEditor.qml` are the UI; the clock pill hosts the panel in its capsule as the status pill hosts the sound and controls panels. + +**Tech Stack:** `/usr/bin/python3` 3.12 with `icalendar` 6.1.1 and `python-dateutil` 2.9.0 (system packages, already used by cal-notif); Quickshell 0.3.1 QML; node for JS tests; `inotifywait`. + +**Spec:** `docs/superpowers/specs/2026-10-13-calendar-design.md` + +--- + +## Read first + +- `AGENTS.md`: "The rule that bites first", "Verifying, and how not to", "Reloading", and the per-component notes. In short: the process is `qs`; never `pkill -f`; never start a detached `qs` from a tool call; read the running shell with `qs log -p ~/Programming/GIT/quickshell/desktop -t 40`; a new directory may need `./reload.sh desktop` from the user's terminal. +- `desktop/island/Island.qml`, `StatusPill.qml`, `ClockPill.qml`, `ControlsPanel.qml`: the panel mechanism this copies. +- `desktop/modules/mail/Accounts.qml`: the `inotifywait` watch + debounce + respawn pattern reused here. +- Never run calctl's write commands against the live calendars (`~/.local/share/calendars/`). Tests use temporary vdirs; the only live-data command an agent runs is the read-only `calctl list` timing check in Task 3. +- Live data facts, measured: two calendars in `~/.config/khal/config` (`light green`, `light magenta`), 281 event files (all parse with icalendar; a month list measured 0.11s), every RRULE outside VTIMEZONE is `FREQ=YEARLY;WKST=TU`, no `RECURRENCE-ID` or `EXDATE`, 97 events with alarms of which 84 are `-PT1H`. Local zone `Europe/Rome` (`/etc/localtime`). + +GPLv2 header, used in every new file (`// ` prefix in JS/QML, `# ` in Python, after the shebang): + +``` +Copyright (C) 2026 Danilo M. <danix@danix.xyz> + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License version 2 as +published by the Free Software Foundation. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. +``` + +The code blocks below write it as `// <GPLv2 header>` or `# <GPLv2 header>`. Compare `desktop/island/Logic.js` (JS with `.pragma library` first) and `desktop/modules/status/statusctl` (script with shebang first). + +## Files + +| File | Action | Responsibility | +| --- | --- | --- | +| `docs/superpowers/specs/2026-10-13-calendar-design.md` | modify | protocol details settled while planning | +| `desktop/island/calendar/calctl` | create | the vdir: calendars, list, get, new, edit, delete | +| `desktop/island/calendar/test_calctl.py` | create | CLI tests against temporary vdirs | +| `desktop/island/calendar/Cal.js` | create | pure date logic for the QML | +| `desktop/island/calendar/test_cal.js` | create | node test for Cal.js | +| `desktop/island/calendar/Calendar.qml` | create | singleton: runs calctl, watches the vdir | +| `desktop/island/calendar/CalendarPanel.qml` | create | month grid, agenda, hosts the editor | +| `desktop/island/calendar/EventEditor.qml` | create | the form | +| `desktop/island/ClockPill.qml` | modify | click opens the panel; hosts it | +| `desktop/island/Island.qml` | modify | `"calendar"` panel, focus, close | +| `desktop/island/README.md`, `AGENTS.md` | modify | docs | + +--- + +### Task 1: Spec corrections + +Planning settled four protocol details the spec left open or got differently. Edit `docs/superpowers/specs/2026-10-13-calendar-design.md`: + +- [ ] **Step 1: Commands block.** Replace the block under "Commands:" with: + +``` + calctl calendars {default, calendars: [{name, color, path}]} + calctl list <from> <to> occurrences in [from, to), local time + calctl get <uid> the series itself, for the editor + calctl new <event-json> {uid} + calctl edit <uid> <event-json> {uid} + calctl delete <uid> {} +``` + +- [ ] **Step 2: The envelope.** Replace the line `Errors go to stderr with exit 1; stdout is JSON only on success.` with: + +``` +Every command prints one JSON object on stdout: `{"ok": true, "data": ...}` +or `{"ok": false, "error": "..."}` with exit 1, the error also on stderr. +The shell reads stdout only, so it never has to order an exit code against +a stream. The event JSON is an argument rather than stdin, which keeps the +QML side to a plain `Process` command. `get` exists because the editor edits +the series: an occurrence's start is not the series' DTSTART. + +Event JSON, in and out: `calendar`, `title`, `allDay`, `start`, `end` +(`YYYY-MM-DDTHH:MM` local, or `YYYY-MM-DD` all day with an inclusive last +day), `repeat`, `until` (`YYYY-MM-DD` or ""), `alarm`, `location`, `notes`; +out also `uid`, `color` and `override`. + +Recurrence is expanded in naive local time, which keeps the wall-clock time +of a TZID event across DST, as RFC 5545 asks. ponytail: a `Z` UNTIL is read +as local time, so an until-date can be off by the UTC offset at its very +end. +``` + +- [ ] **Step 3: Files.** In "Where it lives", add the line ` Cal.js pure date logic for the QML, node-tested` after `test_calctl.py`, and ` test_cal.js node test for Cal.js` after it. + +- [ ] **Step 4: Commit** + +```bash +git add docs/superpowers/specs/2026-10-13-calendar-design.md +git commit -m "docs(spec): calendar protocol settled while planning + +One JSON envelope on stdout for every calctl command, the event as an +argument rather than stdin, a get command because the editor edits the +series rather than the occurrence clicked, and a pure Cal.js for the +date logic so it is tested under node like the island's other helpers. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 2: calctl reading (calendars, list, get) + +**Files:** +- Create: `desktop/island/calendar/calctl` (executable) +- Create: `desktop/island/calendar/test_calctl.py` + +- [ ] **Step 1: Write the reading half of the test** + +`desktop/island/calendar/test_calctl.py`: + +```python +#!/usr/bin/python3 +# <GPLv2 header> +"""Run: /usr/bin/python3 desktop/island/calendar/test_calctl.py + +Drives calctl as a CLI against temporary vdirs and a temporary khal config, +never the live calendars. Plain asserts, no framework. +""" + +import json +import os +import shutil +import subprocess +import tempfile +from pathlib import Path + +CALCTL = Path(__file__).resolve().parent / "calctl" + +tmp = Path(tempfile.mkdtemp(prefix="calctl-test-")) +(tmp / "a").mkdir() +(tmp / "b").mkdir() +(tmp / "khal").write_text(f"""[calendars] + +[[home]] +path = {tmp}/a +type = calendar +color = light green + +[[birthdays]] +path = {tmp}/b +color = '#ff8800' + +[locale] +firstweekday = 0 + +[default] +default_calendar = home +""") +ENV = dict(os.environ, + CALCTL_KHAL_CONFIG=str(tmp / "khal"), + CALCTL_OVERRIDES=str(tmp / "overrides.toml"), + CALCTL_NOSYNC="1", + TZ="Europe/Rome") + + +def run(*args, ok=True): + p = subprocess.run([str(CALCTL), *args], env=ENV, capture_output=True, text=True) + r = json.loads(p.stdout) + assert r["ok"] is ok, (args, p.stdout, p.stderr) + assert (p.returncode == 0) is ok, (args, p.returncode) + return r["data"] if ok else r["error"] + + +def put(path, body): + """A hand-written .ics, CRLF like a real one.""" + Path(path).write_text(body.strip().replace("\n", "\r\n") + "\r\n") + + +# calendars: khal colour names mapped to Theme names, a hex passed through. +c = run("calendars") +assert c["default"] == "home" +assert [(x["name"], x["color"]) for x in c["calendars"]] == [("home", "green"), ("birthdays", "#ff8800")] +assert c["calendars"][0]["path"] == str(tmp / "a") + +# A yearly all-day event from long ago is expanded into the asked window. +put(tmp / "b" / "bday.ics", """ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//test//EN +BEGIN:VEVENT +UID:bday-1 +DTSTAMP:20200101T000000Z +DTSTART;VALUE=DATE:19900417 +DTEND;VALUE=DATE:19900418 +RRULE:FREQ=YEARLY;WKST=TU +SUMMARY:Birthday +END:VEVENT +END:VCALENDAR +""") +apr = [e for e in run("list", "2026-04-01", "2026-05-01") if e["uid"] == "bday-1"] +assert len(apr) == 1, apr +assert apr[0]["start"] == "2026-04-17" and apr[0]["end"] == "2026-04-17", apr +assert apr[0]["allDay"] is True and apr[0]["repeat"] == "yearly" and apr[0]["color"] == "#ff8800" + +# A rich rule and two alarms read as custom; the weekly rule is expanded in +# local time, so 09:00 stays 09:00 across the DST change on 25 October. +put(tmp / "a" / "weird.ics", """ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//test//EN +BEGIN:VEVENT +UID:weird-1 +DTSTAMP:20260101T000000Z +DTSTART;TZID=Europe/Rome:20261005T090000 +DTEND;TZID=Europe/Rome:20261005T100000 +RRULE:FREQ=WEEKLY;BYDAY=MO,WE +SUMMARY:Standup +X-FOO:bar +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:a +TRIGGER:-PT1H +END:VALARM +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:b +TRIGGER:-PT5M +END:VALARM +END:VEVENT +END:VCALENDAR +""") +w = run("get", "weird-1") +assert w["repeat"] == "custom" and w["alarm"] == "custom" and w["title"] == "Standup", w +assert w["start"] == "2026-10-05T09:00", w +occ = [e["start"] for e in run("list", "2026-10-01", "2026-11-01") if e["uid"] == "weird-1"] +assert len(occ) == 8 and occ[0] == "2026-10-05T09:00" and "2026-10-26T09:00" in occ, occ + +# A broken file is skipped, not fatal. +(tmp / "a" / "broken.ics").write_text("this is not a calendar\n") +assert run("list", "2026-10-01", "2026-11-01") + +# Unknown uid and bad usage: the error envelope, exit 1. +assert "no event" in run("get", "nope", ok=False) +assert "usage" in run("frobnicate", ok=False) + +# --- writes (Task 3) go here --- + +shutil.rmtree(tmp) +print("test_calctl: ok") +``` + +- [ ] **Step 2: Run it to see it fail** + +Run: `/usr/bin/python3 desktop/island/calendar/test_calctl.py` +Expected: FAIL, `FileNotFoundError` or `PermissionError` for `calctl`. + +- [ ] **Step 3: Write calctl's reading half** + +`desktop/island/calendar/calctl`: + +```python +#!/usr/bin/python3 +# +# <GPLv2 header> +# +# The island calendar's backend: the vdirsyncer calendars as JSON. Reads the +# calendar list from khal's config so there is no second list to keep in +# step; khal and cal-notif read the same files and are not affected. +# +# calctl calendars {default, calendars: [{name, color, path}]} +# calctl list FROM TO occurrences in [FROM, TO), local time +# calctl get UID the series itself, for the editor +# calctl new EVENT-JSON {uid} +# calctl edit UID EVENT-JSON {uid} +# calctl delete UID {} +# +# Every command prints one object: {"ok": true, "data": ...} or +# {"ok": false, "error": "..."} with exit 1. Environment, for the tests: +# CALCTL_KHAL_CONFIG, CALCTL_OVERRIDES, CALCTL_NOSYNC. + +import json +import os +import subprocess +import sys +import tempfile +import tomllib +import uuid +from datetime import date, datetime, time, timedelta, timezone +from pathlib import Path +from zoneinfo import ZoneInfo + +import icalendar +from dateutil.rrule import rrulestr + +HOME = Path.home() +KHAL_CONFIG = Path(os.environ.get("CALCTL_KHAL_CONFIG", HOME / ".config/khal/config")) +OVERRIDES = Path(os.environ.get("CALCTL_OVERRIDES", HOME / ".config/cal-notif/overrides.toml")) +SYNC_LOG = HOME / ".local/share/vdirsyncer/sync.log" + +PRESET_REPEAT = {"daily": "DAILY", "weekly": "WEEKLY", "monthly": "MONTHLY", "yearly": "YEARLY"} +PRESET_ALARM = {"10m": timedelta(minutes=10), "1h": timedelta(hours=1), "1d": timedelta(days=1)} + +# khal's colour names as Theme property names; a #rrggbb colour passes +# through and anything else is the accent. +COLORS = { + "black": "crust", "white": "text", "gray": "subtext", "light gray": "subtext", + "dark gray": "overlay", "brown": "peach", + "red": "red", "dark red": "red", "light red": "red", + "green": "green", "dark green": "green", "light green": "green", + "yellow": "yellow", "light yellow": "yellow", + "blue": "blue", "dark blue": "blue", "light blue": "blue", + "magenta": "mauve", "dark magenta": "mauve", "light magenta": "mauve", + "cyan": "teal", "dark cyan": "teal", "light cyan": "teal", +} + +USAGE = "usage: calctl calendars | list FROM TO | get UID | new JSON | edit UID JSON | delete UID" + + +class Fail(Exception): + pass + + +def local_zone(): + tz = os.environ.get("TZ", "").lstrip(":") + if tz: + try: + return ZoneInfo(tz) + except Exception: + pass + real = os.path.realpath("/etc/localtime") + if "zoneinfo/" in real: + return ZoneInfo(real.split("zoneinfo/", 1)[1]) + return ZoneInfo("UTC") + + +LOCAL = local_zone() + + +def calendars(): + """([{name, path, color}], default name) from khal's [calendars]. + + ponytail: khal's `type = discover` globs are not expanded; every + calendar here is a plain path. + """ + try: + lines = KHAL_CONFIG.read_text().splitlines() + except OSError as e: + raise Fail(f"cannot read {KHAL_CONFIG}: {e.strerror}") + cals, section, cur, default = [], None, None, None + for raw in lines: + line = raw.strip() + # Only whole-line comments: a colour value can start with '#'. + if not line or line.startswith("#"): + continue + if line.startswith("[["): + cur = None + if section == "calendars": + cur = {"name": line.strip("[]").strip(), "path": None, "color": "accent"} + cals.append(cur) + continue + if line.startswith("["): + section, cur = line.strip("[]").strip(), None + continue + if "=" not in line: + continue + key, value = (s.strip() for s in line.split("=", 1)) + value = value.strip("'\"") + if cur is not None and key == "path": + cur["path"] = Path(os.path.expanduser(value)) + elif cur is not None and key == "color": + cur["color"] = COLORS.get(value, value if value.startswith("#") else "accent") + elif section == "default" and key == "default_calendar": + default = value + cals = [c for c in cals if c["path"]] + if not cals: + raise Fail(f"no calendars in {KHAL_CONFIG}") + names = [c["name"] for c in cals] + return cals, default if default in names else names[0] + + +def master(ics): + """The series' VEVENT: the first one that is not an occurrence override.""" + for ev in ics.walk("VEVENT"): + if "RECURRENCE-ID" not in ev: + return ev + return None + + +def events(): + """(calendar, path, VEVENT) for every event file. A file that fails to + parse is skipped: one bad file must not blank the month.""" + cals, _ = calendars() + for cal in cals: + for path in sorted(cal["path"].glob("*.ics")): + try: + ev = master(icalendar.Calendar.from_ical(path.read_bytes())) + except Exception: + continue + # ponytail: occurrence overrides (RECURRENCE-ID) and EXDATE are not + # applied; none exist in the live calendars (spec, Scope). + if ev is not None: + yield cal, path, ev + + +def find(uid): + for cal, path, ev in events(): + if str(ev.get("UID", "")) == uid: + return cal, path + raise Fail(f"no event {uid}") + + +def as_local(value): + """A date-time as naive local time; a date stays a date. A floating + time (no zone) is local by definition.""" + if isinstance(value, datetime) and value.tzinfo is not None: + return value.astimezone(LOCAL).replace(tzinfo=None) + return value + + +def span(ev): + """(start, end, all_day): naive local datetimes, or dates with an + exclusive end for an all-day event.""" + start = as_local(ev["DTSTART"].dt) + all_day = not isinstance(start, datetime) + if "DTEND" in ev: + end = as_local(ev["DTEND"].dt) + elif "DURATION" in ev: + end = start + ev["DURATION"].dt + else: + end = start + timedelta(days=1) if all_day else start + return start, end, all_day + + +def repeat_of(ev): + """(repeat, until) as the form shows them.""" + rule = ev.get("RRULE") + if rule is None: + return "none", "" + names = {v: k for k, v in PRESET_REPEAT.items()} + freq = rule.get("FREQ", [""])[0] + # WKST changes nothing for a plain FREQ; the live birthdays all carry it. + if set(rule) - {"WKST"} <= {"FREQ", "UNTIL"} and freq in names: + until = "" + if "UNTIL" in rule: + u = as_local(rule["UNTIL"][0]) + until = (u.date() if isinstance(u, datetime) else u).isoformat() + return names[freq], until + return "custom", "" + + +def alarm_of(ev): + alarms = [c for c in ev.subcomponents if c.name == "VALARM"] + if not alarms: + return "none" + if len(alarms) == 1: + trig = alarms[0].get("TRIGGER") + if (trig is not None and isinstance(trig.dt, timedelta) + and trig.params.get("RELATED", "START") == "START"): + for name, before in PRESET_ALARM.items(): + if trig.dt == -before: + return name + return "custom" + + +def overrides(): + """UIDs with a per-event alarm in cal-notif, which wins over the VALARM.""" + try: + return set(tomllib.loads(OVERRIDES.read_text())) + except (OSError, tomllib.TOMLDecodeError): + return set() + + +def stamp(value): + if isinstance(value, datetime): + return value.isoformat(timespec="minutes") + return value.isoformat() + + +def to_json(cal, ev, start, end, all_day, ov): + repeat, until = repeat_of(ev) + uid = str(ev.get("UID", "")) + return { + "uid": uid, + "calendar": cal["name"], + "color": cal["color"], + "title": str(ev.get("SUMMARY", "")), + "allDay": all_day, + "start": stamp(start), + # All-day ends go out inclusive: the last day the event covers. + "end": stamp(max(start, end - timedelta(days=1))) if all_day else stamp(end), + "location": str(ev.get("LOCATION", "")), + "notes": str(ev.get("DESCRIPTION", "")), + "repeat": repeat, + "until": until, + "alarm": alarm_of(ev), + "override": uid in ov, + } + + +def cmd_list(lo_s, hi_s): + lo, hi = datetime.fromisoformat(lo_s), datetime.fromisoformat(hi_s) + ov, out = overrides(), [] + for cal, _, ev in events(): + try: + start, end, all_day = span(ev) + s = datetime.combine(start, time()) if all_day else start + e = datetime.combine(end, time()) if all_day else end + dur = e - s + if "RRULE" in ev: + # Naive local time keeps the wall-clock time across DST. + rule = rrulestr(ev["RRULE"].to_ical().decode(), dtstart=s, ignoretz=True) + starts = rule.between(lo - dur, hi, inc=True) + else: + starts = [s] + except Exception: + continue + for o in starts: + if not (o < hi and (o + dur > lo or (not dur and o >= lo))): + continue + if all_day: + out.append(to_json(cal, ev, o.date(), (o + dur).date(), True, ov)) + else: + out.append(to_json(cal, ev, o, o + dur, False, ov)) + out.sort(key=lambda x: (x["start"][:10], not x["allDay"], x["start"])) + return out + + +def cmd_get(uid): + cal, path = find(uid) + ev = master(icalendar.Calendar.from_ical(path.read_bytes())) + start, end, all_day = span(ev) + return to_json(cal, ev, start, end, all_day, overrides()) + + +def main(argv): + cmd, args = (argv[1], argv[2:]) if len(argv) > 1 else ("", []) + try: + if cmd == "calendars" and not args: + cals, default = calendars() + data = {"default": default, + "calendars": [{"name": c["name"], "color": c["color"], "path": str(c["path"])} + for c in cals]} + elif cmd == "list" and len(args) == 2: + data = cmd_list(*args) + elif cmd == "get" and len(args) == 1: + data = cmd_get(*args) + else: + raise Fail(USAGE) + except Exception as e: + msg = str(e) if isinstance(e, Fail) else f"{type(e).__name__}: {e}" + print(json.dumps({"ok": False, "error": msg})) + print(f"calctl: {msg}", file=sys.stderr) + return 1 + print(json.dumps({"ok": True, "data": data}, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) +``` + +`new`, `edit` and `delete` are added in Task 3; until then they print the usage error. The imports they need (`subprocess`, `tempfile`, `uuid`, `date`, `timezone`) are already here. + +Then: `chmod +x desktop/island/calendar/calctl`. + +- [ ] **Step 4: Run the test** + +Run: `/usr/bin/python3 desktop/island/calendar/test_calctl.py` +Expected: `test_calctl: ok` + +- [ ] **Step 5: Commit** + +```bash +git add desktop/island/calendar/calctl desktop/island/calendar/test_calctl.py +git commit -m "feat(calendar): calctl reads the vdir as JSON + +The calendar list comes from khal's config, so there is no second list. +Recurrence is expanded with dateutil in naive local time, which keeps a +weekly 09:00 at 09:00 across the DST change; the test pins that on 26 +October. A rule richer than a plain FREQ, or alarms other than one +preset, read as custom, which is what lets an edit leave them alone. +A file that fails to parse is skipped rather than blanking the month. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 3: calctl writing (new, edit, delete) + +**Files:** +- Modify: `desktop/island/calendar/calctl` +- Modify: `desktop/island/calendar/test_calctl.py` + +- [ ] **Step 1: Write the writing half of the test** + +In `test_calctl.py`, replace the line `# --- writes (Task 3) go here ---` with: + +```python +def ev(**kw): + e = {"calendar": "home", "title": "Dentist", "allDay": False, + "start": "2026-10-15T14:30", "end": "2026-10-15T15:30", + "repeat": "none", "until": "", "alarm": "1h", "location": "", "notes": ""} + e.update(kw) + return json.dumps(e) + + +def no_temp(): + assert not list(tmp.rglob(".calctl-*")), "temp file left behind" + + +# new, timed: the local zone with its VTIMEZONE, the alarm; read back in +# local time. +uid = run("new", ev())["uid"] +f = tmp / "a" / f"{uid}.ics" +t = f.read_text() +assert "DTSTART;TZID=Europe/Rome:20261015T143000" in t, t +assert "BEGIN:VTIMEZONE" in t and "TRIGGER:-PT1H" in t, t +no_temp() +mine = [e for e in run("list", "2026-10-01", "2026-11-01") if e["uid"] == uid] +assert len(mine) == 1, mine +assert mine[0]["start"] == "2026-10-15T14:30" and mine[0]["end"] == "2026-10-15T15:30" +assert mine[0]["alarm"] == "1h" and mine[0]["repeat"] == "none" and mine[0]["color"] == "green" +assert mine[0]["override"] is False +assert run("get", uid)["title"] == "Dentist" + +# all-day, multi-day: exclusive DTEND on disk, inclusive end in JSON. +trip = run("new", ev(title="Trip", allDay=True, start="2026-10-30", end="2026-11-02", alarm="none"))["uid"] +t = (tmp / "a" / f"{trip}.ics").read_text() +assert "DTSTART;VALUE=DATE:20261030" in t and "DTEND;VALUE=DATE:20261103" in t, t +assert "VALARM" not in t +nov = [e for e in run("list", "2026-11-01", "2026-12-01") if e["uid"] == trip] +assert len(nov) == 1 and nov[0]["start"] == "2026-10-30" and nov[0]["end"] == "2026-11-02", nov +assert not [e for e in run("list", "2026-10-01", "2026-10-30") if e["uid"] == trip] + +# weekly with an until-date, across the DST change. +gym = run("new", ev(title="Gym", repeat="weekly", until="2026-10-29"))["uid"] +occ = [e["start"] for e in run("list", "2026-10-01", "2026-12-01") if e["uid"] == gym] +assert occ == ["2026-10-15T14:30", "2026-10-22T14:30", "2026-10-29T14:30"], occ +assert run("get", gym)["until"] == "2026-10-29" + +# edit keeping custom: the rich rule, both alarms and an unknown property +# survive; SEQUENCE goes up. +run("edit", "weird-1", ev(title="Standup v2", start="2026-10-05T09:30", + end="2026-10-05T10:00", repeat="custom", alarm="custom")) +t = (tmp / "a" / "weird.ics").read_text() +assert "BYDAY=MO,WE" in t and t.count("BEGIN:VALARM") == 2 and "X-FOO:bar" in t, t +assert "SUMMARY:Standup v2" in t and "SEQUENCE:1" in t, t +no_temp() + +# edit to presets: the rule goes, the alarms become one. +run("edit", "weird-1", ev(title="Standup v2", start="2026-10-05T09:30", + end="2026-10-05T10:00", repeat="none", alarm="10m")) +t = (tmp / "a" / "weird.ics").read_text() +assert "RRULE" not in t and t.count("BEGIN:VALARM") == 1 and "TRIGGER:-PT10M" in t, t + +# a calendar change moves the file. +run("edit", uid, ev(calendar="birthdays")) +assert not f.exists() and (tmp / "b" / f.name).exists() +assert run("get", uid)["calendar"] == "birthdays" + +# cal-notif's per-event override is reported. +(tmp / "overrides.toml").write_text(f'"{uid}" = ["10m"]\n') +assert run("get", uid)["override"] is True + +# delete. +assert run("delete", uid) == {} +assert not (tmp / "b" / f.name).exists() +assert "no event" in run("get", uid, ok=False) + +# validation: the error envelope, exit 1, nothing written. +before = sorted(p.name for p in tmp.rglob("*.ics")) +assert "title" in run("new", ev(title=" "), ok=False) +assert "end" in run("new", ev(end="2026-10-15T14:00"), ok=False) +assert "calendar" in run("new", ev(calendar="nope"), ok=False) +assert "json" in run("new", "{not json", ok=False) +assert sorted(p.name for p in tmp.rglob("*.ics")) == before +no_temp() +``` + +- [ ] **Step 2: Run it to see it fail** + +Run: `/usr/bin/python3 desktop/island/calendar/test_calctl.py` +Expected: FAIL on the first `run("new", ...)` with the usage error in the assertion message. + +- [ ] **Step 3: Add the writing half to calctl** + +Insert before `def main(argv):`: + +```python +def parse_payload(text, cals): + """The form's JSON, checked. Dates become date/datetime; an all-day end + becomes exclusive.""" + try: + p = json.loads(text) + except json.JSONDecodeError as e: + raise Fail(f"bad event json: {e}") + if not isinstance(p, dict): + raise Fail("bad event json: not an object") + by_name = {c["name"]: c for c in cals} + if p.get("calendar") not in by_name: + raise Fail(f"unknown calendar {p.get('calendar')!r}") + title = str(p.get("title", "")).strip() + if not title: + raise Fail("title is empty") + all_day = bool(p.get("allDay")) + try: + if all_day: + start = date.fromisoformat(p["start"]) + end = date.fromisoformat(p["end"]) + timedelta(days=1) + else: + start = datetime.fromisoformat(p["start"]) + end = datetime.fromisoformat(p["end"]) + until = date.fromisoformat(p["until"]) if p.get("until") else None + except (KeyError, TypeError, ValueError) as e: + raise Fail(f"bad date: {e}") + if end < start: + raise Fail("end is before start") + repeat = p.get("repeat", "none") + if repeat not in PRESET_REPEAT and repeat not in ("none", "custom"): + raise Fail(f"unknown repeat {repeat!r}") + alarm = p.get("alarm", "none") + if alarm not in PRESET_ALARM and alarm not in ("none", "custom"): + raise Fail(f"unknown alarm {alarm!r}") + return {"cal": by_name[p["calendar"]], "title": title, "allDay": all_day, + "start": start, "end": end, "until": until, "repeat": repeat, "alarm": alarm, + "location": str(p.get("location", "")).strip(), "notes": str(p.get("notes", ""))} + + +def apply(ev, p): + """Write the form's fields into a VEVENT. Everything else on it is left + alone; a `custom` repeat or alarm keeps what is there.""" + for key in ("DTSTART", "DTEND", "DURATION", "SUMMARY", "LOCATION", "DESCRIPTION", + "DTSTAMP", "LAST-MODIFIED"): + ev.pop(key, None) + if p["allDay"]: + ev.add("DTSTART", p["start"]) + ev.add("DTEND", p["end"]) + else: + ev.add("DTSTART", p["start"].replace(tzinfo=LOCAL)) + ev.add("DTEND", p["end"].replace(tzinfo=LOCAL)) + ev.add("SUMMARY", p["title"]) + if p["location"]: + ev.add("LOCATION", p["location"]) + if p["notes"]: + ev.add("DESCRIPTION", p["notes"]) + if p["repeat"] != "custom": + ev.pop("RRULE", None) + if p["repeat"] != "none": + rule = {"FREQ": PRESET_REPEAT[p["repeat"]]} + if p["until"]: + # RFC 5545: UNTIL is a DATE for a DATE start, UTC otherwise. + rule["UNTIL"] = (p["until"] if p["allDay"] else + datetime.combine(p["until"], time(23, 59, 59), LOCAL) + .astimezone(timezone.utc)) + ev.add("RRULE", rule) + if p["alarm"] != "custom": + ev.subcomponents = [c for c in ev.subcomponents if c.name != "VALARM"] + if p["alarm"] != "none": + alarm = icalendar.Alarm() + alarm.add("ACTION", "DISPLAY") + alarm.add("DESCRIPTION", p["title"]) + alarm.add("TRIGGER", -PRESET_ALARM[p["alarm"]]) + ev.add_component(alarm) + now = datetime.now(timezone.utc).replace(microsecond=0) + ev.add("DTSTAMP", now) + ev.add("LAST-MODIFIED", now) + + +def write(path, ics): + """Temporary file in the same directory, then rename: a reader (khal, + cal-notif, vdirsyncer, the panel's watch) never sees half a file. The + temporary name does not end in .ics, so nothing lists it.""" + fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".calctl-", suffix=".tmp") + try: + with os.fdopen(fd, "wb") as f: + f.write(ics.to_ical()) + os.replace(tmp, path) + except BaseException: + if os.path.exists(tmp): + os.unlink(tmp) + raise + + +def sync(): + """Push the change now rather than at the next cron run. Detached and + not waited for; a failure only means the cron run carries it.""" + if os.environ.get("CALCTL_NOSYNC"): + return + try: + SYNC_LOG.parent.mkdir(parents=True, exist_ok=True) + with open(SYNC_LOG, "ab") as log: + subprocess.Popen(["vdirsyncer", "-v", "ERROR", "sync"], stdin=subprocess.DEVNULL, + stdout=log, stderr=subprocess.STDOUT, start_new_session=True) + except OSError: + pass + + +def cmd_new(text): + cals, _ = calendars() + p = parse_payload(text, cals) + uid = str(uuid.uuid4()) + ics = icalendar.Calendar() + ics.add("PRODID", "-//quickshell//calctl//EN") + ics.add("VERSION", "2.0") + ev = icalendar.Event() + ev.add("UID", uid) + apply(ev, p) + ics.add_component(ev) + ics.add_missing_timezones() + write(p["cal"]["path"] / f"{uid}.ics", ics) + sync() + return {"uid": uid} + + +def cmd_edit(uid, text): + cals, _ = calendars() + p = parse_payload(text, cals) + _, path = find(uid) + ics = icalendar.Calendar.from_ical(path.read_bytes()) + ev = master(ics) + apply(ev, p) + sequence = int(ev.pop("SEQUENCE", 0)) + ev.add("SEQUENCE", sequence + 1) + ics.add_missing_timezones() + target = p["cal"]["path"] / path.name + if target != path and target.exists(): + raise Fail(f"{target.name} already exists in {p['cal']['name']}") + write(target, ics) + if target != path: + path.unlink() + sync() + return {"uid": uid} + + +def cmd_delete(uid): + _, path = find(uid) + path.unlink() + sync() + return {} +``` + +In `main`, before the final `else:` of the command chain, add: + +```python + elif cmd == "new" and len(args) == 1: + data = cmd_new(*args) + elif cmd == "edit" and len(args) == 2: + data = cmd_edit(*args) + elif cmd == "delete" and len(args) == 1: + data = cmd_delete(*args) +``` + +- [ ] **Step 4: Run the test** + +Run: `/usr/bin/python3 desktop/island/calendar/test_calctl.py` +Expected: `test_calctl: ok` + +- [ ] **Step 5: Time a read-only list on the live calendars** + +Read-only; writes nothing. + +Run: `time desktop/island/calendar/calctl list 2026-09-28 2026-11-09 | head -c 300` +Expected: `{"ok": true, "data": [...` and `real` under 1.5s. Record the time for Task 10. If it is over 1.5s, report it (DONE_WITH_CONCERNS); do not optimise here. + +- [ ] **Step 6: Commit** + +```bash +git add desktop/island/calendar/calctl desktop/island/calendar/test_calctl.py +git commit -m "feat(calendar): calctl creates, edits and deletes events + +Edits change only what the form owns: a custom rule, extra alarms and +properties this tool does not know survive, which the test checks on an +event with BYDAY, two alarms and an X- property. Writes go through a +temporary file and a rename so khal, cal-notif and vdirsyncer never read +half a file, and a vdirsyncer sync is started detached afterwards. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 4: Cal.js + +**Files:** +- Create: `desktop/island/calendar/Cal.js` +- Create: `desktop/island/calendar/test_cal.js` + +- [ ] **Step 1: Write the failing test** + +`desktop/island/calendar/test_cal.js`: + +```js +// <GPLv2 header> + +// Run: node desktop/island/calendar/test_cal.js +// Cal.js is a QML library, so its .pragma line is stripped first. + +const fs = require("fs"); +const assert = require("assert"); + +const src = fs.readFileSync(__dirname + "/Cal.js", "utf8").replace(/^\.pragma library$/m, ""); +const C = new Function(src + "\nreturn { pad, addDays, today, daysBetween, monthCells, shiftMonth," + + " lastDay, onDay, dayEvents, dots, timeLabel, validTime, stepTime, newDraft, fromEvent, pick," + + " toPayload };")(); + +// Dates are plain YYYY-MM-DD strings, arithmetic in UTC so DST never moves a day. +assert.strictEqual(C.addDays("2026-10-31", 1), "2026-11-01"); +assert.strictEqual(C.addDays("2026-03-01", -1), "2026-02-28"); +assert.strictEqual(C.addDays("2026-10-24", 2), "2026-10-26"); +assert.strictEqual(C.daysBetween("2026-10-30", "2026-11-02"), 3); +assert.strictEqual(C.today(new Date(2026, 9, 13, 23, 59)), "2026-10-13"); + +// Month cells: six weeks from Monday. October 2026 starts on a Thursday. +const oct = C.monthCells(2026, 9); +assert.strictEqual(oct.length, 42); +assert.strictEqual(oct[0].iso, "2026-09-28"); +assert.strictEqual(oct[0].inMonth, false); +assert.strictEqual(oct[3].iso, "2026-10-01"); +assert.strictEqual(oct[3].inMonth, true); +assert.strictEqual(oct[3].day, 1); +assert.strictEqual(oct[41].iso, "2026-11-08"); +// June 2026 starts on a Monday: no leading days. +assert.strictEqual(C.monthCells(2026, 5)[0].iso, "2026-06-01"); + +assert.deepStrictEqual(C.shiftMonth(2026, 11, 1), { y: 2027, m0: 0 }); +assert.deepStrictEqual(C.shiftMonth(2026, 0, -1), { y: 2025, m0: 11 }); + +// Which days an event covers. +const timed = { uid: "t", calendar: "home", allDay: false, start: "2026-10-15T14:30", end: "2026-10-15T15:30" }; +const night = { uid: "n", calendar: "home", allDay: false, start: "2026-10-15T22:00", end: "2026-10-16T00:00" }; +const late = { uid: "l", calendar: "home", allDay: false, start: "2026-10-15T23:00", end: "2026-10-16T01:00" }; +const trip = { uid: "a", calendar: "trips", allDay: true, start: "2026-10-14", end: "2026-10-16" }; +assert.strictEqual(C.lastDay(timed), "2026-10-15"); +assert.strictEqual(C.lastDay(night), "2026-10-15"); // ends at midnight: not on the 16th +assert.strictEqual(C.lastDay(late), "2026-10-16"); +assert.strictEqual(C.lastDay(trip), "2026-10-16"); +assert.ok(C.onDay(trip, "2026-10-15") && !C.onDay(trip, "2026-10-17")); + +// The day's agenda: all-day first, then by start. +const evs = [timed, late, trip, night]; +assert.deepStrictEqual(C.dayEvents(evs, "2026-10-15").map(e => e.uid), ["a", "t", "n", "l"]); +assert.deepStrictEqual(C.dayEvents(evs, "2026-10-16").map(e => e.uid), ["a", "l"]); + +// Dots: one per calendar with something that day, in calendar order. +const cals = [{ name: "home", color: "green" }, { name: "trips", color: "#ff8800" }]; +assert.deepStrictEqual(C.dots(evs, "2026-10-15", cals).map(c => c.name), ["home", "trips"]); +assert.deepStrictEqual(C.dots(evs, "2026-10-14", cals).map(c => c.name), ["trips"]); +assert.deepStrictEqual(C.dots(evs, "2026-10-20", cals), []); + +assert.strictEqual(C.timeLabel(trip, "2026-10-15"), "all day"); +assert.strictEqual(C.timeLabel(timed, "2026-10-15"), "14:30–15:30"); +assert.strictEqual(C.timeLabel(late, "2026-10-15"), "23:00–…"); +assert.strictEqual(C.timeLabel(late, "2026-10-16"), "…–01:00"); + +// Times. +assert.ok(C.validTime("09:05") && C.validTime("23:59")); +assert.ok(!C.validTime("24:00") && !C.validTime("9:05") && !C.validTime("ab:cd")); +assert.strictEqual(C.stepTime("14:30", 15), "14:45"); +assert.strictEqual(C.stepTime("23:50", 15), "00:05"); +assert.strictEqual(C.stepTime("00:05", -15), "23:50"); +assert.strictEqual(C.stepTime("bad", 15), "bad"); + +// A new event: the next whole hour, one hour long, alarm 1h. +const d = C.newDraft("2026-10-20", new Date(2026, 9, 13, 9, 20), "home"); +assert.strictEqual(d.startTime, "10:00"); +assert.strictEqual(d.endTime, "11:00"); +assert.strictEqual(d.startDay, "2026-10-20"); +assert.strictEqual(d.alarm, "1h"); +assert.strictEqual(d.calendar, "home"); +assert.strictEqual(C.newDraft("2026-10-20", new Date(2026, 9, 13, 23, 10), "home").startTime, "22:00"); + +// From calctl's JSON. +const fe = C.fromEvent({ uid: "u", calendar: "home", title: "Trip", allDay: true, start: "2026-10-14", + end: "2026-10-16", repeat: "none", until: "", alarm: "none", location: "", notes: "", override: false }); +assert.strictEqual(fe.startDay, "2026-10-14"); +assert.strictEqual(fe.endDay, "2026-10-16"); +assert.strictEqual(fe.allDay, true); +assert.strictEqual(C.fromEvent(Object.assign({}, timed, { title: "x" })).startTime, "14:30"); + +// Picking on the grid: a click moves the event and keeps its length in days; +// shift-click sets the end, or the start when before it. +const p1 = C.pick(fe, "2026-10-20", false); +assert.strictEqual(p1.startDay, "2026-10-20"); +assert.strictEqual(p1.endDay, "2026-10-22"); +assert.strictEqual(fe.startDay, "2026-10-14"); // input untouched +const p2 = C.pick(fe, "2026-10-18", true); +assert.strictEqual(p2.startDay, "2026-10-14"); +assert.strictEqual(p2.endDay, "2026-10-18"); +const p3 = C.pick(fe, "2026-10-10", true); +assert.strictEqual(p3.startDay, "2026-10-10"); +assert.strictEqual(p3.endDay, "2026-10-16"); + +// To calctl's JSON. +assert.strictEqual(C.toPayload(Object.assign({}, d, { title: " " })).error, "Title is empty"); +assert.strictEqual(C.toPayload(Object.assign({}, d, { title: "x", startTime: "9:00" })).error, "Times are HH:MM"); +assert.strictEqual(C.toPayload(Object.assign({}, d, { title: "x", endTime: "09:00" })).error, "End is before start"); +assert.strictEqual(C.toPayload(Object.assign({}, d, { title: "x", repeat: "weekly", until: "2026-10-01" })).error, + "Until is before the start"); +const ok = C.toPayload(Object.assign({}, d, { title: " Gym ", repeat: "weekly", until: "2026-12-01" })).payload; +assert.deepStrictEqual(ok, { calendar: "home", title: "Gym", allDay: false, start: "2026-10-20T10:00", + end: "2026-10-20T11:00", repeat: "weekly", until: "2026-12-01", alarm: "1h", location: "", notes: "" }); +const ad = C.toPayload(Object.assign({}, fe, { repeat: "none", until: "2026-12-01" })).payload; +assert.strictEqual(ad.start, "2026-10-14"); +assert.strictEqual(ad.end, "2026-10-16"); +assert.strictEqual(ad.until, ""); // no repeat, no until + +console.log("test_cal: ok"); +``` + +- [ ] **Step 2: Run it to see it fail** + +Run: `node desktop/island/calendar/test_cal.js` +Expected: FAIL, `ENOENT ... Cal.js`. + +- [ ] **Step 3: Write Cal.js** + +`desktop/island/calendar/Cal.js`: + +```js +.pragma library + +// <GPLv2 header> + +// Pure date logic for the calendar panel. Days are "YYYY-MM-DD" strings and +// times "HH:MM", the shapes calctl speaks; day arithmetic goes through UTC so +// a DST change never shifts a day. Tested by test_cal.js under node. + +function pad(n) { + return (n < 10 ? "0" : "") + n; +} + +function fromUtc(t) { + return t.getUTCFullYear() + "-" + pad(t.getUTCMonth() + 1) + "-" + pad(t.getUTCDate()); +} + +function utc(day) { + return Date.UTC(+day.slice(0, 4), +day.slice(5, 7) - 1, +day.slice(8, 10)); +} + +function addDays(day, n) { + return fromUtc(new Date(utc(day) + n * 86400000)); +} + +function daysBetween(a, b) { + return Math.round((utc(b) - utc(a)) / 86400000); +} + +// The local calendar day of a JS Date. +function today(now) { + return now.getFullYear() + "-" + pad(now.getMonth() + 1) + "-" + pad(now.getDate()); +} + +// 42 cells, six weeks from Monday (khal's firstweekday = 0). +function monthCells(y, m0) { + const first = new Date(Date.UTC(y, m0, 1)); + const month = fromUtc(first).slice(0, 7); + const lead = (first.getUTCDay() + 6) % 7; + const start = addDays(fromUtc(first), -lead); + const out = []; + for (let i = 0; i < 42; i++) { + const iso = addDays(start, i); + out.push({ iso: iso, day: +iso.slice(8, 10), inMonth: iso.slice(0, 7) === month }); + } + return out; +} + +function shiftMonth(y, m0, n) { + const t = new Date(Date.UTC(y, m0 + n, 1)); + return { y: t.getUTCFullYear(), m0: t.getUTCMonth() }; +} + +// The last day an event covers. calctl gives all-day ends inclusive; a +// timed event ending exactly at midnight does not reach the day it ends on. +function lastDay(ev) { + const endDay = ev.end.slice(0, 10); + if (!ev.allDay && ev.end.slice(11, 16) === "00:00" && ev.end > ev.start) + return addDays(endDay, -1); + return endDay; +} + +function onDay(ev, day) { + return ev.start.slice(0, 10) <= day && lastDay(ev) >= day; +} + +// The day's events, all-day first, then by start. +function dayEvents(events, day) { + return events.filter(e => onDay(e, day)).sort((a, b) => + (b.allDay - a.allDay) || (a.start < b.start ? -1 : a.start > b.start ? 1 : 0)); +} + +// The calendars with something on the day, in calendar order. +function dots(events, day, calendars) { + return calendars.filter(c => events.some(e => e.calendar === c.name && onDay(e, day))); +} + +function timeLabel(ev, day) { + if (ev.allDay) return "all day"; + const s = ev.start.slice(0, 10) === day ? ev.start.slice(11, 16) : "…"; + const e = ev.end.slice(0, 10) === day ? ev.end.slice(11, 16) : "…"; + return s + "–" + e; +} + +function validTime(s) { + return /^([01]\d|2[0-3]):[0-5]\d$/.test(s); +} + +// Step a time by minutes, wrapping within the day; an invalid time is left +// for the user to fix rather than guessed at. +function stepTime(s, minutes) { + if (!validTime(s)) return s; + const m = ((+s.slice(0, 2) * 60 + +s.slice(3, 5) + minutes) % 1440 + 1440) % 1440; + return pad(Math.floor(m / 60)) + ":" + pad(m % 60); +} + +// A new event on `day`: the next whole hour, one hour long, alarm 1h (the +// most common alarm in the live calendars). Capped at 22:00 so the end stays +// on the same day. +function newDraft(day, now, calendar) { + const h = Math.min(22, now.getHours() + 1); + return { uid: "", calendar: calendar, title: "", allDay: false, + startDay: day, endDay: day, startTime: pad(h) + ":00", endTime: pad(h + 1) + ":00", + repeat: "none", until: "", alarm: "1h", location: "", notes: "", override: false }; +} + +// calctl's event JSON as an editor draft. +function fromEvent(ev) { + return { uid: ev.uid, calendar: ev.calendar, title: ev.title, allDay: ev.allDay, + startDay: ev.start.slice(0, 10), endDay: ev.end.slice(0, 10), + startTime: ev.allDay ? "09:00" : ev.start.slice(11, 16), + endTime: ev.allDay ? "10:00" : ev.end.slice(11, 16), + repeat: ev.repeat, until: ev.until, alarm: ev.alarm, + location: ev.location, notes: ev.notes, override: ev.override }; +} + +// A click on the grid while editing. Plain: move the event, keeping its +// length in days. Shift: set the end, or the start when before it. +function pick(draft, day, shift) { + const d = Object.assign({}, draft); + if (!shift) { + const len = daysBetween(d.startDay, d.endDay); + d.startDay = day; + d.endDay = addDays(day, len); + } else if (day >= d.startDay) { + d.endDay = day; + } else { + d.startDay = day; + } + return d; +} + +// The draft as calctl's event JSON, or the first thing wrong with it. +function toPayload(d) { + if (!d.title.trim()) return { error: "Title is empty" }; + if (!d.allDay && (!validTime(d.startTime) || !validTime(d.endTime))) + return { error: "Times are HH:MM" }; + const start = d.allDay ? d.startDay : d.startDay + "T" + d.startTime; + const end = d.allDay ? d.endDay : d.endDay + "T" + d.endTime; + if (end < start) return { error: "End is before start" }; + const repeats = d.repeat !== "none" && d.repeat !== "custom"; + if (repeats && d.until && d.until < d.startDay) return { error: "Until is before the start" }; + return { payload: { + calendar: d.calendar, title: d.title.trim(), allDay: d.allDay, start: start, end: end, + repeat: d.repeat, until: repeats ? d.until : "", alarm: d.alarm, + location: d.location, notes: d.notes } }; +} +``` + +- [ ] **Step 4: Run the test** + +Run: `node desktop/island/calendar/test_cal.js` +Expected: `test_cal: ok` + +- [ ] **Step 5: Commit** + +```bash +git add desktop/island/calendar/Cal.js desktop/island/calendar/test_cal.js +git commit -m "feat(calendar): pure date logic for the panel + +Month cells from Monday, which days an event covers, the day's agenda +order, the grid's dots, time stepping, and the editor's draft in both +directions. Days are strings and arithmetic is in UTC, so the DST change +at the end of October never moves a day; the test steps across it. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 5: Calendar.qml + +**Files:** +- Create: `desktop/island/calendar/Calendar.qml` + +- [ ] **Step 1: Write the singleton** + +```qml +// <GPLv2 header> + +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick + +// The calendars, through calctl. Holds the occurrences of the range the +// panel shows and reloads them when a calendar directory changes, whoever +// changed it: the panel, khal, or the cron sync bringing the phone's edits. +Singleton { + id: root + + readonly property string calctl: Qt.resolvedUrl("calctl").toString().replace(/^file:\/\//, "") + + property var calendars: [] // [{name, color, path}] + property string defaultCalendar: "" + property var events: [] // occurrences in [from, to) + property string from: "" + property string to: "" + property string error: "" + + // A list that finishes after a newer one started is dropped. + property int generation: 0 + + readonly property var paths: calendars.map(c => c.path) + + function load(fromDay, toDay) { + root.from = fromDay; + root.to = toDay; + root.refresh(); + } + + function refresh() { + if (!root.from) return; + const g = ++root.generation; + root.run(["list", root.from, root.to], r => { + if (g !== root.generation) return; + if (r.ok) { root.events = r.data; root.error = ""; } + else root.error = r.error; + }); + } + + function get(uid, cb) { root.run(["get", uid], cb); } + function create(event, cb) { root.run(["new", JSON.stringify(event)], cb); } + function update(uid, event, cb) { root.run(["edit", uid, JSON.stringify(event)], cb); } + function remove(uid, cb) { root.run(["delete", uid], cb); } + + // One Process per call, so a list and a save never share one (a running + // Process ignores a second start, AGENTS.md). cb gets calctl's envelope. + function run(args, cb) { + op.createObject(root, { command: [root.calctl].concat(args), cb: cb }).running = true; + } + + Component { + id: op + Process { + id: proc + property var cb: null + stdout: StdioCollector { + onStreamFinished: { + let r; + try { r = JSON.parse(text); } + catch (e) { r = { ok: false, error: "calctl: " + (text.trim() || "no output") }; } + if (proc.cb) proc.cb(r); + proc.destroy(); + } + } + } + } + + Component.onCompleted: root.run(["calendars"], r => { + if (!r.ok) { root.error = r.error; return; } + root.calendars = r.data.calendars; + root.defaultCalendar = r.data.default; + }) + + // Same shape as the mail count's xapian watch: one burst of file events + // (a write is a temp file plus a rename; a sync touches several) becomes + // one reload, and a dead watcher comes back. + Process { + id: watcher + running: root.paths.length > 0 + command: ["inotifywait", "-m", "-q", "-e", "close_write,moved_to,delete", "--format", "x"] + .concat(root.paths) + stdout: SplitParser { onRead: debounce.restart() } + onExited: respawn.start() + } + + Timer { id: debounce; interval: 300; onTriggered: root.refresh() } + Timer { id: respawn; interval: 5000; onTriggered: watcher.running = root.paths.length > 0 } +} +``` + +- [ ] **Step 2: Commit** + +Nothing imports it yet; Task 8 exercises it. + +```bash +git add desktop/island/calendar/Calendar.qml +git commit -m "feat(calendar): the calendar service + +Runs calctl, one Process per call so a reload and a save never collide, +and drops a list that a newer one overtook. Watches the calendar +directories the way the mail count watches xapian, so an edit from khal +or a cron sync repaints the panel too. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 6: EventEditor.qml + +**Files:** +- Create: `desktop/island/calendar/EventEditor.qml` + +- [ ] **Step 1: Write the editor** + +```qml +// <GPLv2 header> + +import QtQuick +import "../.." +import "Cal.js" as Cal + +// The event form. The month grid beside it is its date picker: the panel +// forwards grid clicks to pick(). Text fields are filled imperatively by +// load() and write back on edit, so typing never fights a binding. +Column { + id: ed + + property var draft: Cal.newDraft(Cal.today(new Date()), new Date(), "") + property bool pickUntil: false + property bool confirmDelete: false + property bool busy: false + property string err: "" + + signal done() + + spacing: 8 + + function load(d) { + ed.draft = d; + ed.err = ""; + ed.pickUntil = false; + ed.confirmDelete = false; + ed.busy = false; + title.input.text = d.title; + startTime.input.text = d.startTime; + endTime.input.text = d.endTime; + location.input.text = d.location; + notes.text = d.notes; + title.input.forceActiveFocus(); + } + + function set(key, value) { + const d = Object.assign({}, ed.draft); + d[key] = value; + ed.draft = d; + } + + function pick(day, shift) { + if (ed.pickUntil) { + ed.set("until", day); + ed.pickUntil = false; + } else { + ed.draft = Cal.pick(ed.draft, day, shift); + } + } + + function finish(r) { + ed.busy = false; + if (r.ok) ed.done(); + else ed.err = r.error; + } + + function save() { + const r = Cal.toPayload(ed.draft); + if (r.error) { ed.err = r.error; return; } + ed.busy = true; + if (ed.draft.uid) Calendar.update(ed.draft.uid, r.payload, ed.finish); + else Calendar.create(r.payload, ed.finish); + } + + function remove() { + if (!ed.confirmDelete) { ed.confirmDelete = true; return; } + ed.busy = true; + Calendar.remove(ed.draft.uid, ed.finish); + } + + function dayLabel(day) { + const d = new Date(+day.slice(0, 4), +day.slice(5, 7) - 1, +day.slice(8, 10)); + return Qt.formatDate(d, "ddd d MMM"); + } + + // An unhandled Escape from any field lands here: leave the editor, not + // the panel. + Keys.onEscapePressed: ed.done() + + component Field: Rectangle { + property alias input: ti + property string placeholder: "" + height: 30 + radius: 8 + color: Theme.surface + TextInput { + id: ti + anchors { fill: parent; leftMargin: 8; rightMargin: 8 } + verticalAlignment: TextInput.AlignVCenter + clip: true + selectByMouse: true + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Text { + anchors { fill: parent; leftMargin: 8 } + verticalAlignment: Text.AlignVCenter + visible: ti.text === "" && !ti.activeFocus + text: parent.placeholder + color: Theme.overlay + font: ti.font + } + } + + component Chip: Rectangle { + property string label: "" + property bool on: false + property color tint: Theme.accent + signal clicked() + width: chipText.implicitWidth + 16 + height: 26 + radius: 13 + color: on ? Qt.alpha(tint, 0.35) : Theme.surface + Text { + id: chipText + anchors.centerIn: parent + text: parent.label + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: parent.clicked() } + } + + component Label: Text { + width: 58 + anchors.verticalCenter: parent ? parent.verticalCenter : undefined + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + + Field { + id: title + width: ed.width + placeholder: "Title" + input.onTextEdited: ed.set("title", input.text) + input.onAccepted: ed.save() + } + + // When: the dates come from the grid. + Row { + spacing: 6 + Label { text: "When" } + Text { + anchors.verticalCenter: parent.verticalCenter + text: ed.draft.startDay === ed.draft.endDay ? ed.dayLabel(ed.draft.startDay) + : ed.dayLabel(ed.draft.startDay) + " – " + ed.dayLabel(ed.draft.endDay) + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: "click the month, shift for the end" + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 6 } + } + } + + Row { + spacing: 6 + Label { text: "Time" } + Field { + id: startTime + width: 62 + visible: !ed.draft.allDay + input.onTextEdited: ed.set("startTime", input.text) + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + property real acc: 0 + onWheel: wheel => { + acc += wheel.angleDelta.y; + if (Math.abs(acc) < 120) return; + parent.input.text = Cal.stepTime(parent.input.text, acc > 0 ? 15 : -15); + ed.set("startTime", parent.input.text); + acc = 0; + } + } + } + Text { + anchors.verticalCenter: parent.verticalCenter + visible: !ed.draft.allDay + text: "–" + color: Theme.subtext + } + Field { + id: endTime + width: 62 + visible: !ed.draft.allDay + input.onTextEdited: ed.set("endTime", input.text) + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + property real acc: 0 + onWheel: wheel => { + acc += wheel.angleDelta.y; + if (Math.abs(acc) < 120) return; + parent.input.text = Cal.stepTime(parent.input.text, acc > 0 ? 15 : -15); + ed.set("endTime", parent.input.text); + acc = 0; + } + } + } + Chip { + anchors.verticalCenter: parent.verticalCenter + label: "all day" + on: ed.draft.allDay + onClicked: ed.set("allDay", !ed.draft.allDay) + } + } + + Row { + spacing: 6 + Label { text: "Calendar" } + Repeater { + model: Calendar.calendars + delegate: Chip { + required property var modelData + anchors.verticalCenter: parent.verticalCenter + label: modelData.name + on: ed.draft.calendar === modelData.name + tint: modelData.color.startsWith("#") ? modelData.color : Theme[modelData.color] + onClicked: ed.set("calendar", modelData.name) + } + } + } + + Row { + spacing: 4 + Label { text: "Repeat" } + Repeater { + // custom is shown, never offered: it means a rule this form + // cannot express, kept as it is. + model: ["none", "daily", "weekly", "monthly", "yearly"] + .concat(ed.draft.repeat === "custom" ? ["custom"] : []) + delegate: Chip { + required property string modelData + anchors.verticalCenter: parent.verticalCenter + label: ({ none: "none", daily: "day", weekly: "week", monthly: "month", + yearly: "year", custom: "custom" })[modelData] + on: ed.draft.repeat === modelData + onClicked: if (modelData !== "custom") ed.set("repeat", modelData) + } + } + } + + Row { + spacing: 6 + visible: ed.draft.repeat !== "none" && ed.draft.repeat !== "custom" + Label { text: "Until" } + Chip { + anchors.verticalCenter: parent.verticalCenter + label: ed.pickUntil ? "pick a day…" : (ed.draft.until ? ed.dayLabel(ed.draft.until) : "forever") + on: ed.pickUntil + onClicked: ed.pickUntil = !ed.pickUntil + } + Chip { + anchors.verticalCenter: parent.verticalCenter + visible: ed.draft.until !== "" + label: "×" + onClicked: ed.set("until", "") + } + } + + Row { + spacing: 4 + Label { text: "Alarm" } + Repeater { + model: ["none", "10m", "1h", "1d"].concat(ed.draft.alarm === "custom" ? ["custom"] : []) + delegate: Chip { + required property string modelData + anchors.verticalCenter: parent.verticalCenter + label: modelData + on: ed.draft.alarm === modelData + onClicked: if (modelData !== "custom") ed.set("alarm", modelData) + } + } + } + + Text { + visible: ed.draft.override === true + width: ed.width + wrapMode: Text.WordWrap + text: "cal-notif has its own alarm for this event, and it wins." + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } + } + + Field { + id: location + width: ed.width + placeholder: "Location" + input.onTextEdited: ed.set("location", input.text) + } + + Rectangle { + width: ed.width + height: 56 + radius: 8 + color: Theme.surface + TextEdit { + id: notes + anchors { fill: parent; margins: 8 } + wrapMode: TextEdit.Wrap + selectByMouse: true + clip: true + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + onTextChanged: if (text !== ed.draft.notes) ed.set("notes", text) + } + Text { + anchors { left: parent.left; top: parent.top; margins: 8 } + visible: notes.text === "" && !notes.activeFocus + text: "Notes" + color: Theme.overlay + font: notes.font + } + } + + Text { + visible: ed.err !== "" + width: ed.width + wrapMode: Text.WordWrap + text: ed.err + color: Theme.red + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + + Item { + width: ed.width + height: 28 + + Chip { + visible: ed.draft.uid !== "" + label: ed.confirmDelete ? "Delete, really" : "Delete" + tint: Theme.red + on: ed.confirmDelete + onClicked: if (!ed.busy) ed.remove() + } + Row { + anchors.right: parent.right + spacing: 6 + Chip { label: "Cancel"; onClicked: ed.done() } + Chip { label: ed.busy ? "Saving…" : "Save"; on: true; onClicked: if (!ed.busy) ed.save() } + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add desktop/island/calendar/EventEditor.qml +git commit -m "feat(calendar): the event form + +Dates come from the month grid beside it; times are typed or scrolled in +15-minute steps. Repeat and alarm offer the presets and show custom, +unselectable, for an event carrying something richer, which the save +then leaves alone. A failed save keeps the form open with calctl's line. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 7: CalendarPanel.qml + +**Files:** +- Create: `desktop/island/calendar/CalendarPanel.qml` + +- [ ] **Step 1: Write the panel** + +```qml +// <GPLv2 header> + +import Quickshell +import QtQuick +import "../.." +import "Cal.js" as Cal + +// Month grid on the left, the selected day's agenda on the right. Opening +// an event swaps the agenda for the editor, and the grid becomes its date +// picker. +Item { + id: panel + + signal closeRequested() + + property int viewY: 2026 + property int viewM0: 0 + property string selected: "" + property bool editing: false + property string agendaError: "" + + readonly property var cells: Cal.monthCells(viewY, viewM0) + readonly property real gridWidth: 252 + + implicitHeight: Math.max(left.implicitHeight, right.implicitHeight) + + SystemClock { id: clock; precision: SystemClock.Minutes } + readonly property string todayIso: Cal.today(clock.date) + + function colorOf(c) { + return c.startsWith("#") ? c : Theme[c]; + } + + function show(y, m0) { + panel.viewY = y; + panel.viewM0 = m0; + const cells = Cal.monthCells(y, m0); + Calendar.load(cells[0].iso, Cal.addDays(cells[41].iso, 1)); + } + + // Each time the panel opens: today, its month, no editor. + function reset() { + const now = new Date(); + panel.editing = false; + panel.agendaError = ""; + panel.selected = Cal.today(now); + panel.show(now.getFullYear(), now.getMonth()); + } + + function shift(n) { + const m = Cal.shiftMonth(panel.viewY, panel.viewM0, n); + panel.show(m.y, m.m0); + } + + function clickDay(day, shiftKey) { + if (panel.editing) { + editor.pick(day, shiftKey); + return; + } + panel.selected = day; + if (day.slice(0, 7) !== Cal.monthCells(panel.viewY, panel.viewM0)[10].iso.slice(0, 7)) + panel.show(+day.slice(0, 4), +day.slice(5, 7) - 1); + } + + function openEvent(uid) { + Calendar.get(uid, r => { + if (!r.ok) { panel.agendaError = r.error; return; } + editor.load(Cal.fromEvent(r.data)); + panel.editing = true; + }); + } + + function newEvent() { + editor.load(Cal.newDraft(panel.selected, new Date(), Calendar.defaultCalendar)); + panel.editing = true; + } + + function closeEditor() { + panel.editing = false; + panel.forceActiveFocus(); + } + + // Escape: the editor first, then the panel. + function escape() { + if (panel.editing) { panel.closeEditor(); return true; } + return false; + } + + Keys.onEscapePressed: if (!panel.escape()) panel.closeRequested() + + Component.onCompleted: panel.reset() + + // --- left: the month --------------------------------------------------- + + Column { + id: left + width: panel.gridWidth + spacing: 6 + + Item { + width: parent.width + height: 22 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: Qt.locale().standaloneMonthName(panel.viewM0) + " " + panel.viewY + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + } + Row { + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + spacing: 12 + Repeater { + model: [{ t: "‹", n: -1 }, { t: "›", n: 1 }] + delegate: Text { + required property var modelData + text: modelData.t + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize + 2 } + MouseArea { + anchors { fill: parent; margins: -6 } + cursorShape: Qt.PointingHandCursor + onClicked: panel.shift(parent.modelData.n) + } + } + } + } + } + + Grid { + columns: 7 + spacing: 2 + + Repeater { + model: [0, 1, 2, 3, 4, 5, 6] + delegate: Text { + required property int modelData + width: (panel.gridWidth - 12) / 7 + horizontalAlignment: Text.AlignHCenter + // Monday first: Qt.locale().dayName counts from Sunday. + text: Qt.locale().dayName((modelData + 1) % 7, Locale.NarrowFormat) + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } + } + } + + Repeater { + model: panel.cells + delegate: Rectangle { + id: cell + required property var modelData + readonly property bool inDraft: panel.editing + && modelData.iso >= editor.draft.startDay && modelData.iso <= editor.draft.endDay + + width: (panel.gridWidth - 12) / 7 + height: 32 + radius: 8 + color: inDraft ? Qt.alpha(Theme.accent, 0.35) + : hover.containsMouse ? Qt.alpha(Theme.surface, 0.8) : "transparent" + border.width: !panel.editing && modelData.iso === panel.selected ? 1 : 0 + border.color: Theme.accent + + Text { + anchors { horizontalCenter: parent.horizontalCenter; top: parent.top; topMargin: 5 } + text: cell.modelData.day + color: cell.modelData.iso === panel.todayIso ? Theme.accent + : cell.modelData.inMonth ? Theme.text : Theme.overlay + font { + family: Theme.fontFamily + pixelSize: Theme.fontSize - 4 + bold: cell.modelData.iso === panel.todayIso + } + } + + Row { + anchors { horizontalCenter: parent.horizontalCenter; bottom: parent.bottom; bottomMargin: 4 } + spacing: 2 + Repeater { + model: Cal.dots(Calendar.events, cell.modelData.iso, Calendar.calendars) + delegate: Rectangle { + required property var modelData + width: 4 + height: 4 + radius: 2 + color: panel.colorOf(modelData.color) + } + } + } + + MouseArea { + id: hover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: mouse => panel.clickDay(cell.modelData.iso, + (mouse.modifiers & Qt.ShiftModifier) !== 0) + } + } + } + } + } + + // --- right: agenda or editor ------------------------------------------- + + Item { + id: right + x: panel.gridWidth + 16 + width: panel.width - x + implicitHeight: panel.editing ? editor.implicitHeight : agenda.implicitHeight + + Column { + id: agenda + width: parent.width + spacing: 6 + visible: !panel.editing + + Text { + text: panel.selected === "" ? "" + : Qt.formatDate(new Date(+panel.selected.slice(0, 4), +panel.selected.slice(5, 7) - 1, + +panel.selected.slice(8, 10)), "dddd d MMMM") + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + } + + Text { + visible: text !== "" + width: parent.width + wrapMode: Text.WordWrap + text: panel.agendaError || Calendar.error + color: Theme.red + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } + } + + Repeater { + id: dayList + model: Cal.dayEvents(Calendar.events, panel.selected) + delegate: Rectangle { + id: row + required property var modelData + width: agenda.width + height: 30 + radius: 8 + color: rowHover.containsMouse ? Theme.surfaceAlt : Theme.surface + + Rectangle { + x: 6 + anchors.verticalCenter: parent.verticalCenter + width: 3 + height: parent.height - 12 + radius: 2 + color: panel.colorOf(row.modelData.color) + } + Text { + id: when + x: 16 + width: 82 + anchors.verticalCenter: parent.verticalCenter + text: Cal.timeLabel(row.modelData, panel.selected) + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } + } + Text { + anchors { left: when.right; right: parent.right; rightMargin: 8; verticalCenter: parent.verticalCenter } + elide: Text.ElideRight + text: row.modelData.title + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + MouseArea { + id: rowHover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: panel.openEvent(row.modelData.uid) + } + } + } + + Text { + visible: dayList.count === 0 + text: "Nothing on this day" + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + + Rectangle { + width: agenda.width + height: 30 + radius: 8 + color: "transparent" + border.width: 1 + border.color: Theme.surfaceAlt + Text { + anchors.centerIn: parent + text: "+ New event" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: panel.newEvent() + } + } + } + + EventEditor { + id: editor + width: parent.width + visible: panel.editing + onDone: panel.closeEditor() + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add desktop/island/calendar/CalendarPanel.qml +git commit -m "feat(calendar): month grid beside the day's agenda + +Six weeks from Monday, a dot per calendar with something that day, today +and the selection marked. The agenda lists the selected day, all-day +first; a click opens the event in the editor, and while the editor is +open the grid picks its dates instead of the selection. Escape closes +the editor before the panel. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 8: Wire it into the clock pill and the island + +**Files:** +- Modify: `desktop/island/ClockPill.qml` +- Modify: `desktop/island/Island.qml` + +- [ ] **Step 1: ClockPill** + +Add `import "calendar"` after `import "../modules/vm"`. + +Update the top comment to: `// Date and time at rest, with one dot per VM that is not plainly shut off. During a flick: the workspace's desktop icon, the app's icon and its name. A click opens the calendar panel in this capsule.` (wrapped like the file). + +After `property string flickApp: ""` add: + +```qml + property string panel: "" + + signal openCalendar() + signal closeCalendar() + + expanded: panel === "calendar" + panelWidth: 620 + panelHeight: calendarPanel.implicitHeight + 28 + + // The island calls these: focus moves into the panel when it opens, and + // Escape asks the panel before closing it. + function focusPanel() { calendarPanel.reset(); calendarPanel.forceActiveFocus(); } + function escape() { return calendarPanel.escape(); } +``` + +In `restRow` change `opacity: pill.flicking ? 0 : 1` to `opacity: pill.flicking || pill.expanded ? 0 : 1`. + +In `flickRow` change `opacity: pill.flicking ? 1 : 0` to `opacity: pill.flicking && !pill.expanded ? 1 : 0`. + +At the end of the pill, after `flickRow`, add: + +```qml + // At rest the whole pill is the button; open, the panel takes the clicks. + MouseArea { + anchors.fill: parent + enabled: !pill.expanded + cursorShape: Qt.PointingHandCursor + onClicked: pill.openCalendar() + } + + CalendarPanel { + id: calendarPanel + x: 14 + y: 14 + width: pill.panelWidth - 28 + opacity: pill.expanded ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { NumberAnimation { duration: 160 } } + onCloseRequested: pill.closeCalendar() + } +``` + +- [ ] **Step 2: Island** + +Change the comment `// "", "sound" or "controls".` to `// "", "sound", "controls" or "calendar".` + +Replace the `onPanelChanged` handler with: + +```qml + onPanelChanged: { + if (panel === "calendar") clock.focusPanel(); + else if (panel !== "") keys.forceActiveFocus(); + else if (!hovered) hideTimer.restart(); + } +``` + +In the `keys` item, replace `Keys.onEscapePressed: win.panel = ""` with: + +```qml + Keys.onEscapePressed: if (!(win.panel === "calendar" && clock.escape())) win.panel = "" +``` + +In `ClockPill { ... }` add: + +```qml + panel: win.panel + onOpenCalendar: win.panel = win.panel === "calendar" ? "" : "calendar" + onCloseCalendar: win.panel = "" +``` + +- [ ] **Step 3: Check the running shell** + +Run: `sleep 2; qs log -p ~/Programming/GIT/quickshell/desktop -t 40 2>&1 | grep -v "host portal\|playerctld"` +Expected: a `Reloading configuration...` / `Configuration Loaded` pair after the edit and no `ReferenceError`, `TypeError`, `is not a type`, `Cannot assign` or `unresolvable import` naming `calendar`, `Calendar`, `CalendarPanel`, `EventEditor`, `Cal`, `ClockPill` or `Island`. + +If the log shows `Ignoring unresolvable import` for `calendar`, or the shell did not reload, ask the user to run `./reload.sh desktop` from their terminal (AGENTS.md, Reloading: a new directory may not be indexed by hot reload), then read the log again. Do not start `qs` yourself. + +Then confirm the watcher started: `pgrep -a inotifywait | grep -c calendars` +Expected: `1`. + +- [ ] **Step 4: Commit** + +```bash +git add desktop/island/ClockPill.qml desktop/island/Island.qml +git commit -m "feat(island): the clock pill opens the calendar panel + +A click on the pill opens the panel in its capsule, 620px wide, growing +left from the gap so the status pill does not move. Focus goes into the +panel so its text fields and Escape work; Escape closes the editor +before the panel. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 9: Check with the user + +No code. Ask the user to check on screen, and fix what they report before Task 10. Writes here are the user's own, on their live calendars, by their hand. + +- Click the clock pill: the panel opens on today's month, today marked, dots on days with events in the two calendars' colours; arrows change month; Escape closes. +- A day click lists its events, all-day first, with times. +- "+ New event": title, a time, Save. It appears in the agenda and the grid within a second; `khal list today 7d` shows it. +- Open it again: change the time by scrolling, change the date by clicking the grid and a range by shift-click, move it to the other calendar, set weekly until a date; Save, and check the grid. +- Alarm: set 10m; open an existing birthday (repeat reads year, its alarm as it is). +- Delete: one click arms, the second deletes. +- An edit made with `khal edit` or `ikhal` while the panel is open shows up in it. +- Typing in the title then pressing Escape closes the editor, not the panel; a second Escape closes the panel. + +--- + +### Task 10: Docs + +**Files:** +- Modify: `desktop/island/README.md` +- Modify: `AGENTS.md` + +- [ ] **Step 1: README** + +In `desktop/island/README.md`, in `## Clock pill`, add `Click opens the calendar panel.` to its description, and add a section after the controls panel section: + +``` +## Calendar panel + +The clock pill opens a month grid beside the selected day's agenda. Dots +are one per calendar with something that day, in the khal colours. A click +on an event, or "+ New event", opens the editor; while it is open the grid +picks the date (shift-click for the end). Repeat is none, daily, weekly, +monthly or yearly with an optional until-date; the alarm is none, 10m, 1h or +1d. An event with something richer shows custom and an edit leaves it +alone. Edit and delete act on the whole series. + +Everything goes through `calendar/calctl`, which reads the calendar list +from khal's config, writes the `.ics` files atomically and starts a +`vdirsyncer sync` after each write. The panel watches the calendar +directories, so khal edits and cron syncs show up live. Tests: +`/usr/bin/python3 calendar/test_calctl.py`, `node calendar/test_cal.js`. +``` + +- [ ] **Step 2: AGENTS.md** + +Update the island line in "What this is" to end `controls panel (SUPER alone), calendar`. Add to the per-component notes whatever Tasks 2 to 9 established that is not obvious, at minimum: + +``` +- **khal 0.11 has no JSON output**, which is why the calendar reads the vdir + itself through `calctl` rather than parsing `khal list -f`. khal and + cal-notif read the same files and are unaffected by calctl's writes. +- **Recurrence is expanded in naive local time.** A TZID event keeps its + wall-clock time across DST that way; expanding in UTC moved a weekly + 09:00 to 10:00 after the change. `test_calctl.py` pins it on 26 October. +``` + +Only keep the second note's "expanding in UTC moved" clause if that was actually observed during the build; otherwise state the rule without it. Add the `calctl list` timing from Task 3 Step 5 to the first note. + +- [ ] **Step 3: Commit** + +```bash +git add desktop/island/README.md AGENTS.md +git commit -m "docs: the calendar panel and calctl + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` |
