From fb27c5b3091f1fc43a4501e517b050051d4b397e Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Mon, 17 Aug 2026 11:31:15 -0400 Subject: [PATCH] Add live calendar state to Quickshell --- .../quickshell/services/CalendarAgenda.qml | 271 ++++++++++++++++++ config/dot/quickshell/shell.qml | 22 ++ .../plans/2026-08-17-calendar-agenda.md | 12 +- tests/quickshell/calendar-agenda-contract.sh | 39 +++ 4 files changed, 338 insertions(+), 6 deletions(-) create mode 100644 config/dot/quickshell/services/CalendarAgenda.qml create mode 100755 tests/quickshell/calendar-agenda-contract.sh diff --git a/config/dot/quickshell/services/CalendarAgenda.qml b/config/dot/quickshell/services/CalendarAgenda.qml new file mode 100644 index 0000000..a5cc555 --- /dev/null +++ b/config/dot/quickshell/services/CalendarAgenda.qml @@ -0,0 +1,271 @@ +pragma Singleton + +// Calendar state shared by the Daybook and the quiet bar capsule. GNOME's +// Evolution Data Server owns sync and credentials; the helper only presents a +// small, local JSON view of enabled event sources. + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property string helperPath: Quickshell.shellDir + "/scripts/calendar-agenda" + + // "loading" | "ready" | "degraded" | "unavailable" + property string phase: "loading" + readonly property bool available: (phase === "ready" || phase === "degraded") && sources.length > 0 + property var sources: [] + property var events: [] + property var errors: [] + property int generatedAt: 0 + + property date selectedDate: new Date() + readonly property string selectedDateKey: root.dateKey(selectedDate) + readonly property var selectedEvents: root.eventsForDate(selectedDate) + readonly property var nextEventForSelectedDate: root._nextForDate(selectedDate) + + property int rangeStart: 0 + property int rangeEnd: 0 + property bool fixtureMode: false + property int fixtureNow: 0 + property bool watchWanted: false + + readonly property int nowEpoch: fixtureNow > 0 ? fixtureNow : Math.floor(clock.date.getTime() / 1000) + readonly property var nextEvent: { + const candidates = root.events.filter(event => !event.allDay && Number(event.end) > root.nowEpoch); + candidates.sort((a, b) => Number(a.start) - Number(b.start)); + return candidates.length > 0 ? candidates[0] : null; + } + readonly property int nextEventOffset: nextEvent ? Number(nextEvent.start) - nowEpoch : 999999 + readonly property bool capsuleVisible: nextEvent !== null && nextEventOffset <= 900 && nextEventOffset >= -300 + readonly property string capsuleText: { + if (!root.capsuleVisible) + return ""; + if (root.nextEventOffset <= 0) + return "Now"; + return Math.ceil(root.nextEventOffset / 60) + "m"; + } + + SystemClock { + id: clock + precision: SystemClock.Minutes + } + + function dateKey(date: date): string { + return Qt.formatDate(date, "yyyy-MM-dd"); + } + + function dateFromKey(value: string): date { + const parts = value.split("-").map(Number); + if (parts.length !== 3 || parts.some(part => !Number.isFinite(part))) + return new Date(NaN); + return new Date(parts[0], parts[1] - 1, parts[2]); + } + + function selectDate(date: date): void { + if (!date || Number.isNaN(date.getTime())) + return; + root.selectedDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + } + + function selectIsoDate(value: string): void { + root.selectDate(root.dateFromKey(value)); + } + + function sourceForId(sourceId: string): var { + return root.sources.find(source => source.id === sourceId) ?? null; + } + + function eventsForDate(date: date): var { + if (!date || Number.isNaN(date.getTime())) + return []; + const start = Math.floor(new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 1000); + const end = Math.floor(new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1).getTime() / 1000); + const matches = root.events.filter(event => Number(event.start) < end && Number(event.end) > start); + matches.sort((a, b) => { + if (!!a.allDay !== !!b.allDay) + return a.allDay ? -1 : 1; + if (Number(a.start) !== Number(b.start)) + return Number(a.start) - Number(b.start); + return String(a.id).localeCompare(String(b.id)); + }); + return matches; + } + + function markersForDate(date: date): var { + const seen = ({}); + const markers = []; + for (const event of root.eventsForDate(date)) { + if (seen[event.sourceId]) + continue; + seen[event.sourceId] = true; + const source = root.sourceForId(event.sourceId); + markers.push({ sourceId: event.sourceId, color: source?.color ?? "#82aaff" }); + } + return markers; + } + + function _nextForDate(date: date): var { + const isToday = root.dateKey(date) === root.dateKey(clock.date); + const candidates = root.eventsForDate(date).filter(event => { + if (event.allDay) + return false; + return !isToday || Number(event.end) > root.nowEpoch; + }); + return candidates.length > 0 ? candidates[0] : null; + } + + function setVisibleMonth(year: int, month: int): void { + const requestedStart = Math.floor(new Date(year, month, -6).getTime() / 1000); + const requestedEnd = Math.floor(new Date(year, month, 43).getTime() / 1000); + if (requestedStart >= root.rangeStart && requestedEnd <= root.rangeEnd) + return; + root.rangeStart = requestedStart; + root.rangeEnd = requestedEnd; + root._restartWatch(); + } + + function refresh(): void { + if (!root.fixtureMode) + root._restartWatch(); + } + + function _restartWatch(): void { + if (root.fixtureMode || root.rangeStart <= 0 || root.rangeEnd <= root.rangeStart) + return; + root.phase = root.events.length > 0 ? root.phase : "loading"; + root.watchWanted = false; + startTimer.restart(); + } + + function consumeSnapshot(data: string): void { + if (root.fixtureMode || data.trim() === "") + return; + try { + const snapshot = JSON.parse(data); + if (snapshot.ok !== true) { + root.phase = "unavailable"; + root.errors = snapshot.errors ?? []; + return; + } + root.sources = Array.isArray(snapshot.sources) ? snapshot.sources : []; + root.events = Array.isArray(snapshot.events) ? snapshot.events : []; + root.errors = Array.isArray(snapshot.errors) ? snapshot.errors : []; + root.generatedAt = Number(snapshot.generatedAt) || 0; + root.phase = root.errors.length > 0 ? "degraded" : "ready"; + } catch (error) { + root.phase = "unavailable"; + root.errors = [{ code: "invalid-snapshot" }]; + } + } + + function openEvent(event: var): void { + if (!event?.uid) + return; + Quickshell.execDetached(["gnome-calendar", "--uuid", String(event.uid)]); + } + + function openCalendar(date: date): void { + const target = date && !Number.isNaN(date.getTime()) ? date : root.selectedDate; + Quickshell.execDetached(["gnome-calendar", "--date", root.dateKey(target)]); + } + + function join(event: var): void { + const url = String(event?.joinUrl ?? ""); + if (url.startsWith("https://")) + Quickshell.execDetached(["xdg-open", url]); + } + + function applyFixture(name: string): void { + if (name !== "daybook") + return; + + root.watchWanted = false; + root.fixtureMode = true; + root.fixtureNow = Math.floor(new Date(2026, 7, 17, 11, 6).getTime() / 1000); + root.sources = [ + { id: "fixture-work", name: "Fixture Work", color: "#82aaff" }, + { id: "fixture-cloud", name: "Fixture Cloud", color: "#c3e88d" }, + { id: "fixture-personal", name: "Fixture Personal", color: "#fca7ea" } + ]; + root.events = [ + { + id: "fixture-work:standup:1", sourceId: "fixture-work", uid: "fixture-standup", + summary: "Fixture standup", start: Math.floor(new Date(2026, 7, 17, 11, 30).getTime() / 1000), + end: Math.floor(new Date(2026, 7, 17, 12, 0).getTime() / 1000), allDay: false, + location: "Fixture Meet", joinUrl: "https://meet.google.com/abc-defg-hij" + }, + { + id: "fixture-work:planning:1", sourceId: "fixture-work", uid: "fixture-planning", + summary: "Fixture planning", start: Math.floor(new Date(2026, 7, 17, 13, 0).getTime() / 1000), + end: Math.floor(new Date(2026, 7, 17, 13, 45).getTime() / 1000), allDay: false, + location: "", joinUrl: "" + }, + { + id: "fixture-cloud:focus:1", sourceId: "fixture-cloud", uid: "fixture-focus", + summary: "Fixture focus", start: Math.floor(new Date(2026, 7, 17, 14, 30).getTime() / 1000), + end: Math.floor(new Date(2026, 7, 17, 16, 0).getTime() / 1000), allDay: false, + location: "", joinUrl: "" + }, + { + id: "fixture-personal:dinner:1", sourceId: "fixture-personal", uid: "fixture-dinner", + summary: "Fixture dinner", start: Math.floor(new Date(2026, 7, 17, 18, 30).getTime() / 1000), + end: Math.floor(new Date(2026, 7, 17, 20, 0).getTime() / 1000), allDay: false, + location: "Fixture place", joinUrl: "" + }, + { + id: "fixture-cloud:tomorrow:1", sourceId: "fixture-cloud", uid: "fixture-tomorrow", + summary: "Fixture tomorrow", start: Math.floor(new Date(2026, 7, 18, 9, 0).getTime() / 1000), + end: Math.floor(new Date(2026, 7, 18, 10, 0).getTime() / 1000), allDay: false, + location: "", joinUrl: "" + } + ]; + root.errors = []; + root.generatedAt = root.fixtureNow; + root.phase = "ready"; + root.selectIsoDate("2026-08-17"); + } + + function clearFixture(): void { + root.fixtureMode = false; + root.fixtureNow = 0; + root.sources = []; + root.events = []; + root.errors = []; + root.selectedDate = new Date(); + root.phase = "loading"; + root.setVisibleMonth(root.selectedDate.getFullYear(), root.selectedDate.getMonth()); + root._restartWatch(); + } + + Process { + id: watchProc + command: [root.helperPath, "watch", String(root.rangeStart), String(root.rangeEnd)] + running: root.watchWanted && !root.fixtureMode && root.rangeStart > 0 + stdout: SplitParser { + onRead: data => root.consumeSnapshot(data) + } + onExited: (code, status) => { + if (root.watchWanted && !root.fixtureMode) + restartTimer.restart(); + } + } + + Timer { + id: startTimer + interval: 40 + onTriggered: root.watchWanted = !root.fixtureMode + } + + Timer { + id: restartTimer + interval: 10000 + onTriggered: root._restartWatch() + } + + Component.onCompleted: { + root.setVisibleMonth(root.selectedDate.getFullYear(), root.selectedDate.getMonth()); + } +} diff --git a/config/dot/quickshell/shell.qml b/config/dot/quickshell/shell.qml index cb5881b..42757db 100644 --- a/config/dot/quickshell/shell.qml +++ b/config/dot/quickshell/shell.qml @@ -261,6 +261,28 @@ ShellRoot { } } + IpcHandler { + target: "calendar-agenda" + function fixture(name: string): void { CalendarAgenda.applyFixture(name); } + function reset(): void { CalendarAgenda.clearFixture(); } + function refresh(): void { CalendarAgenda.refresh(); } + function open(): void { ShellState.open("notifications"); } + function close(): void { ShellState.close(); } + function select(date: string): void { CalendarAgenda.selectIsoDate(date); } + function status(): string { + return JSON.stringify({ + phase: CalendarAgenda.phase, + fixture: CalendarAgenda.fixtureMode, + sourceCount: CalendarAgenda.sources.length, + eventCount: CalendarAgenda.events.length, + selectedDate: CalendarAgenda.selectedDateKey, + selectedEventCount: CalendarAgenda.selectedEvents.length, + capsuleVisible: CalendarAgenda.capsuleVisible, + capsuleText: CalendarAgenda.capsuleText + }); + } + } + IpcHandler { target: "clipboard" function toggle(): void { ShellState.toggle("clipboard"); } diff --git a/docs/superpowers/plans/2026-08-17-calendar-agenda.md b/docs/superpowers/plans/2026-08-17-calendar-agenda.md index e8f45e0..591cbc7 100644 --- a/docs/superpowers/plans/2026-08-17-calendar-agenda.md +++ b/docs/superpowers/plans/2026-08-17-calendar-agenda.md @@ -284,7 +284,7 @@ git commit -m "Add the EDS calendar bridge" - Produces: functions `selectDate(date)`, `selectIsoDate(dateString)`, `setVisibleMonth(year, month)`, `eventsForDate(date)`, `markersForDate(date)`, `openEvent(event)`, `openCalendar(date)`, `join(event)`, `refresh()`, `applyFixture(name)`, and `clearFixture()`. - Produces IPC target `calendar-agenda` with `fixture`, `reset`, `open`, `select`, `refresh`, and `status`. -- [ ] **Step 1: Write the failing live service contract** +- [x] **Step 1: Write the failing live service contract** Create `tests/quickshell/calendar-agenda-contract.sh` that first asserts the IPC target, loads fixture `daybook`, and validates only synthetic state: @@ -315,13 +315,13 @@ jq -e '.fixture == false' <<<"$(qs ipc call calendar-agenda status)" >/dev/null printf 'calendar agenda contract: PASS\n' ``` -- [ ] **Step 2: Run the contract and verify RED** +- [x] **Step 2: Run the contract and verify RED** Run `tests/quickshell/calendar-agenda-contract.sh`. Expected: FAIL with `calendar IPC target is missing`. -- [ ] **Step 3: Implement `CalendarAgenda.qml` stream supervision** +- [x] **Step 3: Implement `CalendarAgenda.qml` stream supervision** Create the singleton with `pragma Singleton`, `Quickshell`, `Quickshell.Io`, and `QtQuick` imports. Use a streaming parser, not `StdioCollector`: @@ -350,7 +350,7 @@ Implement date keys in local time with `Qt.formatDate(new Date(epoch * 1000), "y `applyFixture("daybook")` must install three synthetic sources and five synthetic events spanning August 17–18, 2026. Use clearly synthetic labels (`Fixture standup`, `Fixture planning`, `Fixture focus`, `Fixture dinner`, `Fixture tomorrow`) and no real account or event data. -- [ ] **Step 4: Add calendar IPC without exposing live content** +- [x] **Step 4: Add calendar IPC without exposing live content** Add to `shell.qml`: @@ -380,7 +380,7 @@ IpcHandler { Do not include titles, source names, locations, identifiers, or URLs in status output. -- [ ] **Step 5: Run service contracts and verify GREEN** +- [x] **Step 5: Run service contracts and verify GREEN** Allow Quickshell to hot reload, then run: @@ -392,7 +392,7 @@ qs log -n 80 | rg -i 'calendar|error|warn' Expected: both contracts PASS; no calendar QML error or bridge restart loop. -- [ ] **Step 6: Commit the service** +- [x] **Step 6: Commit the service** ```bash git add config/dot/quickshell/services/CalendarAgenda.qml \ diff --git a/tests/quickshell/calendar-agenda-contract.sh b/tests/quickshell/calendar-agenda-contract.sh new file mode 100755 index 0000000..ceef10f --- /dev/null +++ b/tests/quickshell/calendar-agenda-contract.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +set -euo pipefail + +fail() { + printf 'calendar agenda contract: %s\n' "$1" >&2 + exit 1 +} + +cleanup() { + qs ipc call calendar-agenda reset >/dev/null 2>&1 || true + qs ipc call calendar-agenda close >/dev/null 2>&1 || true +} +trap cleanup EXIT + +qs ipc show | rg -q '^target calendar-agenda$' \ + || fail 'calendar IPC target is missing' + +qs ipc call calendar-agenda fixture daybook >/dev/null +status="$(qs ipc call calendar-agenda status)" +jq -e ' + .fixture == true and + .sourceCount == 3 and + .eventCount == 5 and + .selectedDate == "2026-08-17" +' <<<"$status" >/dev/null || fail 'daybook fixture state is malformed' + +qs ipc call calendar-agenda select 2026-08-18 >/dev/null +jq -e '.selectedDate == "2026-08-18" and .selectedEventCount == 1' \ + <<<"$(qs ipc call calendar-agenda status)" >/dev/null \ + || fail 'date selection did not update events' + +qs ipc call calendar-agenda reset >/dev/null +jq -e '.fixture == false' <<<"$(qs ipc call calendar-agenda status)" >/dev/null \ + || fail 'fixture reset did not restore live mode' + +trap - EXIT +cleanup +printf 'calendar agenda contract: PASS\n'