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; // GNOME Calendar passes this value to g_date_time_new_from_iso8601(), // which requires a time and timezone rather than a bare YYYY-MM-DD. Quickshell.execDetached(["gnome-calendar", "--date", target.toISOString()]); } 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" && name !== "upcoming" && name !== "outside-window") return; root.watchWanted = false; root.fixtureMode = true; root.fixtureNow = Math.floor(new Date(2026, 7, 17, 11, 6).getTime() / 1000); if (name === "upcoming" || name === "outside-window") { const offset = name === "upcoming" ? 12 * 60 : 20 * 60; root.sources = [{ id: "fixture-work", name: "Fixture Work", color: "#82aaff" }]; root.events = [ { id: "fixture-work:timed:1", sourceId: "fixture-work", uid: "fixture-timed", summary: "Fixture appointment", start: root.fixtureNow + offset, end: root.fixtureNow + offset + 30 * 60, allDay: false, location: "", joinUrl: "" }, { id: "fixture-work:all-day:1", sourceId: "fixture-work", uid: "fixture-all-day", summary: "Fixture all day", start: Math.floor(new Date(2026, 7, 17).getTime() / 1000), end: Math.floor(new Date(2026, 7, 18).getTime() / 1000), allDay: true, location: "", joinUrl: "" } ]; root.errors = []; root.generatedAt = root.fixtureNow; root.phase = "ready"; root.selectIsoDate("2026-08-17"); return; } 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()); } }