272 lines
10 KiB
QML
272 lines
10 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
|
|
|
|
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());
|
|
}
|
|
}
|