aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-13 10:22:47 +0200
committerDanilo M. <danix@danix.xyz>2026-10-13 10:22:47 +0200
commitf2678193b8d0224d51a761f5bddf45c7cce5217e (patch)
tree3f0f2655a52cfc93cb13d6b12dd54e00a91beb08 /docs/superpowers/plans
parent32bebbcda16d6b20a42e102d2ec6d756e906808e (diff)
downloadquickshell-f2678193b8d0224d51a761f5bddf45c7cce5217e.tar.gz
quickshell-f2678193b8d0224d51a761f5bddf45c7cce5217e.zip
docs(plan): calendar panel implementation plan
Ten tasks: protocol corrections to the spec, calctl in two halves with their CLI tests, the pure Cal.js with its node test, the service, the editor, the panel, the island wiring, a pass with the user and docs. Both test suites were run against the plan's own code before committing, and a read-only list against the live calendars measured 0.11s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'docs/superpowers/plans')
-rw-r--r--docs/superpowers/plans/2026-10-13-calendar.md2202
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>"
+```