Add live calendar state to Quickshell

This commit is contained in:
Gabriel Brown
2026-08-17 11:31:15 -04:00
parent d37d4a87de
commit fb27c5b309
4 changed files with 338 additions and 6 deletions
@@ -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());
}
}
+22
View File
@@ -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"); }