# 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. 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 `// ` or `# `. 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 occurrences in [from, to), local time calctl get the series itself, for the editor calctl new {uid} calctl edit {uid} calctl delete {} ``` - [ ] **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 " ``` --- ### 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 # """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 # # # # 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 " ``` --- ### 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 " ``` --- ### 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 // // 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 // // 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 " ``` --- ### Task 5: Calendar.qml **Files:** - Create: `desktop/island/calendar/Calendar.qml` - [ ] **Step 1: Write the singleton** ```qml // 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 " ``` --- ### Task 6: EventEditor.qml **Files:** - Create: `desktop/island/calendar/EventEditor.qml` - [ ] **Step 1: Write the editor** ```qml // 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 " ``` --- ### Task 7: CalendarPanel.qml **Files:** - Create: `desktop/island/calendar/CalendarPanel.qml` - [ ] **Step 1: Write the panel** ```qml // 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 " ``` --- ### 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 " ``` --- ### 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 " ```