Add live calendar state to Quickshell
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"); }
|
||||
|
||||
@@ -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 \
|
||||
|
||||
Executable
+39
@@ -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'
|
||||
Reference in New Issue
Block a user