Files
Panama/config/dot/quickshell/services/CalendarAgenda.qml
T
Gabriel Brown 8b59b78d9f Settle process-signal races across the services layer
A Process's exited and streamFinished signals aren't guaranteed to
fire in order, and several services decided an outcome on whichever
fired first: KdeConnect could report a successful file transfer as
failed if exited landed before the real stdout payload; Clipboard
could present a failed history query as an empty-but-healthy one;
Brightness could strand the last queued write of a drag; SoundFeedback
and SystemLocale could drop or misapply a rapid second toggle/click
because re-arming an already-running Process is a no-op. All five now
wait for both signals and let the authoritative one decide, matching
the pattern HomeAssistantConfig.qml already used correctly.

Health's "copy report" never enabled stdin, so it copied nothing
while claiming success. Capture announced every recording as saved
regardless of the recorder's actual exit code. Connectivity never
restarted Bluetooth discovery when the adapter was enabled from an
already-open page. CalendarAgenda left the UI in "loading" forever if
its helper died at startup, and the helper itself could crash
unguarded instead of reporting unavailable. Geocoding silently
dropped a query typed while the previous one was still in flight.
Notifs leaked tracked-but-undisplayed notifications under Do Not
Disturb, and dismissAll() skipped them.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 21:23:07 -04:00

323 lines
13 KiB
QML

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
// Counts watch attempts that exit without ever emitting a snapshot, so a
// helper that is missing or crashes on launch (e.g. no evolution-data-server)
// is reported instead of retried forever in silence.
property int consecutiveWatchFailures: 0
property bool watchProducedSnapshot: false
readonly property int maxConsecutiveWatchFailures: 3
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;
// Once the helper has been declared unavailable, don't flash back to
// "loading" on every retry -- only a real snapshot should clear it.
if (root.phase !== "unavailable")
root.phase = root.events.length > 0 ? root.phase : "loading";
root.watchWanted = false;
root.watchProducedSnapshot = false;
startTimer.restart();
}
function consumeSnapshot(data: string): void {
if (root.fixtureMode || data.trim() === "")
return;
root.watchProducedSnapshot = true;
root.consecutiveWatchFailures = 0;
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.consecutiveWatchFailures = 0;
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.watchProducedSnapshot) {
root.consecutiveWatchFailures += 1;
// The helper died before ever emitting a snapshot, repeatedly --
// stop pretending this is still loading and surface the same
// "unavailable" state probe()/collect_snapshot() report.
if (root.consecutiveWatchFailures >= root.maxConsecutiveWatchFailures) {
root.phase = "unavailable";
root.errors = [{ code: "eds-unavailable" }];
}
}
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());
}
}