Personal noctalia plugins collection
1import QtQuick
2import Quickshell
3import qs.Commons
4import qs.Services.Location
5import qs.Services.UI
6import Quickshell.Io
7import qs.Services.System
8
9Item {
10 id: root
11 property var pluginApi: null
12 property date currentDate: new Date()
13 property ListModel eventsModel: ListModel {}
14 property ListModel allDayEventsModel: ListModel {}
15 property var overlappingEventsData: ({})
16 property bool isLoading: false
17 property bool hasLoadedOnce: false
18 // Defer UI refresh until CalendarService finishes loading to avoid flicker
19 property bool pendingEventsUpdate: false
20 property string syncStatus: ""
21 property int lastKnownEventCount: 0
22
23 // Todo support
24 property ListModel todosModel: ListModel {}
25 property var taskLists: []
26 property bool todosLoading: false
27 property bool showCompletedTodos: false
28 property string todoSyncStatus: ""
29
30 property real dayColumnWidth: 120 * Style.uiScaleRatio
31 property real allDaySectionHeight: 0 * Style.uiScaleRatio
32 property var allDayEventsWithLayout: []
33
34 property date weekStart: calculateWeekStart(currentDate, firstDayOfWeek)
35 property date weekEnd: calculateWeekEnd(weekStart)
36 property var weekDates: calculateWeekDates(weekStart)
37 property string monthRangeText: formatMonthRangeText(weekDates)
38
39 // Settings
40 property string panelModeSetting: pluginApi?.pluginSettings?.panelMode || "attached"
41 property string weekStartSetting: pluginApi?.pluginSettings?.weekStart || "1"
42 property string timeFormatSetting: pluginApi?.pluginSettings?.timeFormat || "24h"
43 property string lineColorTypeSetting: pluginApi?.pluginSettings?.lineColorType || "mOutline"
44 property real hourLineOpacitySetting: pluginApi?.pluginSettings?.hourLineOpacity ?? 0.5
45 property real dayLineOpacitySetting: pluginApi?.pluginSettings?.dayLineOpacity ?? 0.9
46
47 readonly property int firstDayOfWeek: weekStartSetting === "0" ? 0 :
48 weekStartSetting === "1" ? 1 :
49 weekStartSetting === "6" ? 6 : I18n.locale.firstDayOfWeek
50
51 readonly property bool use12hourFormat: timeFormatSetting === "12h" ? true :
52 timeFormatSetting === "24h" ? false :
53 Settings.data.location.use12hourFormat
54
55 readonly property color lineColor: lineColorTypeSetting === "mOnSurfaceVariant" ? Color.mOnSurfaceVariant : Color.mOutline
56
57 onWeekStartSettingChanged: if (hasLoadedOnce) Qt.callLater(refreshView)
58 onTimeFormatSettingChanged: eventsModelChanged()
59 onCurrentDateChanged: Qt.callLater(refreshView)
60 onLineColorTypeSettingChanged: eventsModelChanged()
61 onHourLineOpacitySettingChanged: eventsModelChanged()
62 onDayLineOpacitySettingChanged: eventsModelChanged()
63
64 // React to CalendarService signals (async event delivery)
65 Connections {
66 target: CalendarService
67 function onAvailableChanged() {
68 if (CalendarService.available) {
69 Qt.callLater(loadEvents)
70 Qt.callLater(loadTaskLists)
71 Qt.callLater(loadTodos)
72 } else {
73 isLoading = false
74 if (pluginApi) syncStatus = pluginApi.tr("panel.no_service")
75 }
76 }
77 function onEventsChanged() {
78 var count = CalendarService.events ? CalendarService.events.length : 0
79 // If auto-refresh dropped event count significantly, re-request wide range
80 if (hasLoadedOnce && !isLoading && lastKnownEventCount > 10 && count < lastKnownEventCount * 0.5) {
81 console.log("[weekly-calendar] Auto-refresh narrowed events (" + count + " vs " + lastKnownEventCount + "), re-requesting wide range")
82 Qt.callLater(loadEvents)
83 return
84 }
85 lastKnownEventCount = Math.max(lastKnownEventCount, count)
86 if (CalendarService.loading) {
87 pendingEventsUpdate = true
88 } else {
89 Qt.callLater(updateEventsFromService)
90 }
91 }
92 function onLoadingChanged() {
93 if (!CalendarService.loading && isLoading) {
94 pendingEventsUpdate = false
95 Qt.callLater(updateEventsFromService)
96 } else if (!CalendarService.loading && pendingEventsUpdate) {
97 pendingEventsUpdate = false
98 Qt.callLater(updateEventsFromService)
99 }
100 }
101 }
102
103 // Safety timeout: if CalendarService never signals back, stop spinning
104 Timer {
105 id: loadingTimeout
106 interval: 15000
107 repeat: false
108 onTriggered: {
109 if (isLoading) {
110 console.warn("[weekly-calendar] loading timeout, forcing update")
111 updateEventsFromService()
112 }
113 }
114 }
115
116 // IPC
117 IpcHandler {
118 target: "plugin:weekly-calendar"
119 function togglePanel() { pluginApi?.withCurrentScreen(s => pluginApi.togglePanel(s)) }
120 }
121
122 Component.onCompleted: {
123 initializePluginSettings()
124 // Process any cached events immediately
125 if (CalendarService.events && CalendarService.events.length > 0) {
126 Qt.callLater(updateEventsFromService)
127 }
128 if (CalendarService.available) {
129 Qt.callLater(loadEvents)
130 Qt.callLater(loadTaskLists)
131 Qt.callLater(loadTodos)
132 }
133 }
134
135 onPluginApiChanged: {
136 initializePluginSettings()
137 if (CalendarService.events && CalendarService.events.length > 0) {
138 Qt.callLater(updateEventsFromService)
139 }
140 if (CalendarService.available) {
141 Qt.callLater(loadEvents)
142 Qt.callLater(loadTaskLists)
143 Qt.callLater(loadTodos)
144 }
145 }
146
147 function initializePluginSettings() {
148 if (!pluginApi) return
149 if (!pluginApi.pluginSettings.weekStart) {
150 pluginApi.pluginSettings = {
151 weekStart: "1",
152 timeFormat: "24h",
153 lineColorType: "mOutline",
154 hourLineOpacity: 0.5,
155 dayLineOpacity: 1.0
156 }
157 pluginApi.saveSettings()
158 }
159 }
160
161 function initializePlugin() {
162 console.log("[weekly-calendar] initializePlugin called, CalendarService.available=" + CalendarService.available)
163 if (!hasLoadedOnce && CalendarService.available) {
164 loadEvents()
165 } else {
166 refreshView()
167 }
168 }
169
170 // Re-filter existing events for the current week view (no new fetch)
171 function refreshView() {
172 if (!pluginApi) return
173 if (CalendarService.events && CalendarService.events.length > 0) {
174 updateEventsFromService()
175 } else if (hasLoadedOnce) {
176 clearEventModels()
177 syncStatus = pluginApi.tr("panel.no_events")
178 }
179 }
180
181 // Fetch events from EDS - requests a wide date range to cover past/future navigation
182 function loadEvents() {
183 if (!pluginApi) return
184 if (!CalendarService.available) {
185 syncStatus = pluginApi.tr("panel.no_service")
186 console.log("[weekly-calendar] loadEvents: service not available")
187 return
188 }
189
190 isLoading = true
191 pendingEventsUpdate = false
192 syncStatus = pluginApi.tr("panel.loading")
193
194 // Request a wider range: 365 days behind, 365 days ahead
195 // Covers roughly a full year in both directions so future months stay populated
196 var daysAhead = 365
197 var daysBehind = 365
198
199 CalendarService.loadEvents(daysAhead, daysBehind)
200
201 hasLoadedOnce = true
202 loadingTimeout.restart()
203
204 // If CalendarService already has events (cached), display them now
205 if (CalendarService.events && CalendarService.events.length > 0) {
206 Qt.callLater(updateEventsFromService)
207 }
208 }
209
210 function updateEventsFromService() {
211 if (!pluginApi) return
212 loadingTimeout.stop()
213 clearEventModels()
214
215 if (!CalendarService.available) {
216 syncStatus = pluginApi.tr("panel.no_service")
217 } else if (!CalendarService.events?.length) {
218 var todoStats = processTodosForWeek()
219 if (todoStats.count > 0) {
220 syncStatus = pluginApi.tr("panel.no_events") + ", " +
221 todoStats.count + " " + (todoStats.count === 1 ? pluginApi.tr("panel.task") : pluginApi.tr("panel.tasks"))
222 } else {
223 syncStatus = pluginApi.tr("panel.no_events")
224 }
225 } else {
226 var stats = processCalendarEvents(CalendarService.events)
227 var todoStats = processTodosForWeek()
228 var parts = []
229 parts.push(stats.timedCount === 1
230 ? `${stats.timedCount} ${pluginApi.tr("panel.event")}`
231 : `${stats.timedCount} ${pluginApi.tr("panel.events")}`)
232 parts.push(`${stats.allDayCount} ${pluginApi.tr("panel.allday")}`)
233 if (todoStats.count > 0)
234 parts.push(todoStats.count + " " + (todoStats.count === 1 ? pluginApi.tr("panel.task") : pluginApi.tr("panel.tasks")))
235 syncStatus = parts.join(", ")
236 }
237
238 isLoading = false
239 }
240
241 // Events generation & layout
242 function processCalendarEvents(events) {
243 var uniqueEvents = {}, uniqueAllDayEvents = {}
244 var timedCount = 0, allDayCount = 0
245 var newEvents = [], newAllDayEvents = []
246 var weekStartDate = new Date(weekStart), weekEndDate = new Date(weekEnd)
247
248 for (var i = 0; i < events.length; i++) {
249 var event = events[i], eventObj = createEventObject(event, i)
250 var eventStart = new Date(eventObj.startTime), eventEnd = new Date(eventObj.endTime)
251 var overlapsWeek = eventStart < weekEndDate && eventEnd > weekStartDate
252
253 if (overlapsWeek) {
254 var key = event.uid + "-" + event.start + "-" + event.end
255 if (eventObj.allDay) {
256 if (!uniqueAllDayEvents[key]) {
257 uniqueAllDayEvents[key] = true
258 allDayCount++
259 newAllDayEvents.push(eventObj)
260 }
261 } else if (!uniqueEvents[key]) {
262 uniqueEvents[key] = true
263 timedCount++
264 processTimedEventIntoArray(eventObj, newEvents)
265 }
266 }
267 }
268
269 eventsModel.clear()
270 allDayEventsModel.clear()
271 newEvents.forEach(e => eventsModel.append(e))
272 newAllDayEvents.forEach(e => allDayEventsModel.append(e))
273
274 calculateAllDayEventLayout()
275 updateOverlappingEvents()
276 eventsModel.layoutChanged()
277 allDayEventsModel.layoutChanged()
278
279 return {timedCount: timedCount, allDayCount: allDayCount}
280 }
281
282 function processTodosForWeek() {
283 var weekStartDate = new Date(weekStart)
284 var weekEndDate = new Date(weekEnd)
285 var count = 0
286
287 for (var i = 0; i < todosModel.count; i++) {
288 var todo = todosModel.get(i)
289 if (!todo.due) continue
290
291 var dueDate = new Date(todo.due)
292 if (isNaN(dueDate.getTime())) continue
293 if (dueDate < weekStartDate || dueDate >= weekEndDate) continue
294 if (!showCompletedTodos && todo.status === "COMPLETED") continue
295
296 var isDueAllDay = (dueDate.getHours() === 0 && dueDate.getMinutes() === 0)
297 // Render timed todos as horizontal line markers (keep a short span for ordering/click hitbox)
298 var endDate = isDueAllDay ? new Date(dueDate.getTime() + 86400000)
299 : new Date(dueDate.getTime() + 30 * 60000)
300
301 var todoEvent = {
302 id: "todo-" + todo.uid,
303 title: todo.summary,
304 description: todo.description || "",
305 location: "",
306 startTime: dueDate,
307 endTime: endDate,
308 allDay: isDueAllDay,
309 multiDay: false,
310 daySpan: 1,
311 isTodo: true,
312 todoUid: todo.uid,
313 calendarUid: todo.calendarUid,
314 todoStatus: todo.status,
315 todoPriority: todo.priority,
316 // Helper flags for compact rendering in Panel.qml
317 isDeadlineMarker: !isDueAllDay
318 }
319
320 if (isDueAllDay) {
321 allDayEventsModel.append(todoEvent)
322 } else {
323 eventsModel.append(todoEvent)
324 }
325 count++
326 }
327
328 // Recalculate layouts after adding todos
329 if (count > 0) {
330 calculateAllDayEventLayout()
331 updateOverlappingEvents()
332 eventsModel.layoutChanged()
333 allDayEventsModel.layoutChanged()
334 }
335
336 return { count: count }
337 }
338
339 function clearEventModels() { eventsModel.clear(); allDayEventsModel.clear() }
340
341 function processTimedEventIntoArray(eventObj, target) {
342 var start = new Date(eventObj.startTime), end = new Date(eventObj.endTime)
343 var startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate())
344 var endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate())
345
346 if (startDay.getTime() === endDay.getTime()) {
347 if (start < weekEnd && end > weekStart) target.push(createEventPart(eventObj, 0, start, end, startDay, 0, 1))
348 } else {
349 var firstEnd = new Date(startDay); firstEnd.setHours(24, 0, 0, 0)
350 var secondStart = new Date(endDay); secondStart.setHours(0, 0, 0, 0)
351 if (start < weekEnd && firstEnd > weekStart) target.push(createEventPart(eventObj, 0, start, firstEnd, startDay, 0, 2))
352 if (secondStart < weekEnd && end > weekStart) target.push(createEventPart(eventObj, 1, secondStart, end, endDay, 1, 2))
353 }
354 }
355
356 function createEventObject(event, idx) {
357 var start = new Date(event.start * 1000), end = new Date(event.end * 1000)
358 var allDay = isAllDayEvent(event), multiDay = isMultiDayEvent(event)
359 var daySpan = calculateDaySpan(start, end, multiDay || allDay)
360 var endsMidnight = end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0
361 var id = event.uid + "-" + event.start + "-" + event.end + idx
362
363 return {
364 id: id, title: event.summary || "Untitled Event", description: event.description || "",
365 location: event.location || "", startTime: start, endTime: end, allDay: allDay, multiDay: multiDay,
366 daySpan: daySpan, rawStart: event.start, rawEnd: event.end, duration: (event.end - event.start) / 3600,
367 endsAtMidnight: endsMidnight, isTodo: false, todoUid: "", calendarUid: event.calendar_uid || "",
368 eventUid: event.uid || "", todoStatus: "", todoPriority: 0
369 }
370 }
371
372 function createEventPart(event, partIdx, start, end, day, partNum, total) {
373 return {
374 id: event.id + "-part-" + partIdx, title: event.title, description: event.description,
375 location: event.location, startTime: start, endTime: end, allDay: false, multiDay: true,
376 daySpan: 1, fullStartTime: event.startTime, fullEndTime: event.endTime, isPart: true,
377 partDay: new Date(day), partIndex: partNum, totalParts: total,
378 isTodo: false, todoUid: "", calendarUid: event.calendarUid || "", eventUid: event.eventUid || "",
379 todoStatus: "", todoPriority: 0
380 }
381 }
382
383 function getDayIndexForDate(date) {
384 if (!date || isNaN(date.getTime())) return -1
385 var diff = Math.floor((date - weekStart) / 86400000)
386 return diff >= 0 && diff < 7 ? diff : -1
387 }
388 function getDisplayDayIndexForDate(date) { return getDayIndexForDate(date) }
389
390 function calculateAllDaySpanForWeek(event) {
391 var start = new Date(event.startTime), end = new Date(event.endTime)
392 var endsMidnight = end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0
393 var adjEnd = endsMidnight ? new Date(end.getTime() - 1) : end
394 var startIdx = Math.max(0, getDayIndexForDate(start))
395 var endIdx = Math.min(6, Math.floor((Math.min(adjEnd, weekEnd) - weekStart) / 86400000))
396 return Math.max(1, endIdx - startIdx + 1)
397 }
398
399 function findAvailableLane(occupied, start, end) {
400 var lane = 0, found = false
401 while (!found) {
402 var conflict = false
403 for (var d = start; d <= end; d++) {
404 if (occupied[d]?.includes(lane)) { conflict = true; break }
405 }
406 if (!conflict) found = true
407 else lane++
408 }
409 return lane
410 }
411
412 function calculateAllDayEventLayout() {
413
414 var occupied = [[], [], [], [], [], [], []]
415 var eventsWithLayout = [], maxLanes = 0
416 var weekStartDate = new Date(weekStart), weekEndDate = new Date(weekEnd)
417
418 for (var i = 0; i < allDayEventsModel.count; i++) {
419 var event = allDayEventsModel.get(i)
420 var start = new Date(event.startTime), end = new Date(event.endTime)
421 var startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate())
422 var endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate())
423
424 if (startDay < weekStartDate && endDay >= weekStartDate) {
425 var span = calculateAllDaySpanForWeek(event)
426 if (span > 0) {
427 var lane = findAvailableLane(occupied, 0, span - 1)
428 for (var d = 0; d < span && d < 7; d++) { if (!occupied[d]) occupied[d] = []; occupied[d].push(lane) }
429 maxLanes = Math.max(maxLanes, lane + 1)
430 eventsWithLayout.push(createLayoutEvent(event, 0, span, lane, true))
431 }
432 } else if (startDay >= weekStartDate && startDay < weekEndDate) {
433 var startIdx = getDayIndexForDate(start)
434 var span = calculateAllDaySpanForWeek(event)
435 if (span > 0) {
436 var lane = findAvailableLane(occupied, startIdx, startIdx + span - 1)
437 for (var d = startIdx; d < startIdx + span && d < 7; d++) { if (!occupied[d]) occupied[d] = []; occupied[d].push(lane) }
438 maxLanes = Math.max(maxLanes, lane + 1)
439 eventsWithLayout.push(createLayoutEvent(event, startIdx, span, lane, false))
440 }
441 }
442 }
443
444 eventsWithLayout.sort((a,b) => a.lane !== b.lane ? a.lane - b.lane : a.startDay - b.startDay)
445 allDayEventsWithLayout = eventsWithLayout
446 allDaySectionHeight = maxLanes === 0 ? 0 : maxLanes === 1 ? 25 : Math.max(30, maxLanes * 25)
447
448 return maxLanes
449 }
450
451 function createLayoutEvent(event, startDay, spanDays, lane, isCont) {
452 return {
453 id: event.id, title: event.title, description: event.description, location: event.location,
454 startTime: event.startTime, endTime: event.endTime, allDay: event.allDay, multiDay: event.multiDay,
455 daySpan: event.daySpan, rawStart: event.rawStart, rawEnd: event.rawEnd, duration: event.duration,
456 endsAtMidnight: event.endsAtMidnight, fullStartTime: event.fullStartTime, fullEndTime: event.fullEndTime,
457 startDay: startDay, spanDays: spanDays, lane: lane, isContinuation: isCont,
458 isTodo: event.isTodo || false, todoUid: event.todoUid || "", calendarUid: event.calendarUid || "",
459 eventUid: event.eventUid || "", todoStatus: event.todoStatus || "", todoPriority: event.todoPriority || 0
460 }
461 }
462
463 function updateOverlappingEvents() {
464 var overlapData = {}
465 for (var day = 0; day < 7; day++) processDayEventsWithLanes(day, overlapData)
466 overlappingEventsData = overlapData
467 }
468
469 function processDayEventsWithLanes(day, data) {
470 var events = []
471 for (var i = 0; i < eventsModel.count; i++) {
472 var e = eventsModel.get(i)
473 if (e.isTodo) continue // Timed todos render as overlay lines; don't occupy lanes
474 if (getDisplayDayIndexForDate(e.startTime) === day) {
475 events.push({index: i, start: e.startTime.getTime(), end: e.endTime.getTime()})
476 }
477 }
478 if (events.length === 0) return
479
480 events.sort((a,b) => a.start === b.start ? (b.end - b.start) - (a.end - a.start) : a.start - b.start)
481 var groups = [], current = [], endTime = -1
482
483 events.forEach(e => {
484 if (e.start >= endTime) {
485 if (current.length > 0) groups.push({events: current, endTime: endTime})
486 current = [e]; endTime = e.end
487 } else {
488 current.push(e)
489 if (e.end > endTime) endTime = e.end
490 }
491 })
492 if (current.length > 0) groups.push({events: current, endTime: endTime})
493 groups.forEach(g => assignLanesToGroup(g.events, data))
494 }
495
496 function assignLanesToGroup(group, data) {
497 if (group.length === 0) return
498 var laneEnds = []
499 group.forEach(e => {
500 var placed = false
501 for (var lane = 0; lane < laneEnds.length; lane++) {
502 if (e.start >= laneEnds[lane]) {
503 laneEnds[lane] = e.end
504 e.lane = lane
505 placed = true
506 break
507 }
508 }
509 if (!placed) { e.lane = laneEnds.length; laneEnds.push(e.end) }
510 })
511
512 var total = laneEnds.length
513 group.forEach(e => {
514 data[e.index] = {
515 xOffset: (e.lane / total) * (dayColumnWidth +1),
516 width: (dayColumnWidth+1) / total,
517 lane: e.lane,
518 totalLanes: total
519 }
520 })
521 }
522
523 // Range & formatting of calendar
524 function calculateWeekStart(date, firstDay) {
525 var d = new Date(date)
526 var day = d.getDay()
527 var diff = (day - firstDay + 7) % 7
528 d.setDate(d.getDate() - diff)
529 d.setHours(0, 0, 0, 0)
530 return d
531 }
532
533 function calculateWeekDates(startDate) {
534 var dates = []
535 var start = new Date(startDate)
536
537 for (var i = 0; i < 7; i++) {
538 var d = new Date(start)
539 d.setDate(start.getDate() + i)
540 dates.push(d)
541 }
542
543 return dates
544 }
545
546 function calculateWeekEnd(startDate) {
547 var end = new Date(startDate)
548 end.setDate(end.getDate() + 7)
549 end.setHours(0, 0, 0, 0)
550 return end
551 }
552
553 function isSameDay(date1, date2) {
554 return date1.getDate() === date2.getDate() &&
555 date1.getMonth() === date2.getMonth() &&
556 date1.getFullYear() === date2.getFullYear()
557 }
558
559 function isToday(date) {
560 var today = new Date()
561 return isSameDay(date, today)
562 }
563
564 function isDateInRange(date, startDate, endDate) {
565 return date >= startDate && date < endDate
566 }
567
568 function formatMonthRangeText(dates) {
569 if (!dates || dates.length === 0) return ""
570 var start = dates[0], end = dates[6], locale = I18n.locale
571 return locale.toString(start, "yyyy-MM") === locale.toString(end, "yyyy-MM")
572 ? locale.toString(start, "MMM yyyy")
573 : start.getFullYear() === end.getFullYear()
574 ? locale.toString(start, "MMM") + " – " + locale.toString(end, "MMM") + " " + start.getFullYear()
575 : locale.toString(start, "MMM yyyy") + " – " + locale.toString(end, "MMM yyyy")
576 }
577
578 function isAllDayEvent(event) {
579 var dur = event.end - event.start
580 var start = new Date(event.start * 1000), end = new Date(event.end * 1000)
581 var startsMidnight = start.getHours() === 0 && start.getMinutes() === 0 && start.getSeconds() === 0
582 var endsMidnight = end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0
583 return (dur === 86400 && startsMidnight) || (dur >= 86400 && endsMidnight) || dur >= 86400
584 }
585
586 function isMultiDayEvent(event) {
587 var start = new Date(event.start * 1000), end = new Date(event.end * 1000)
588 var endsMidnight = end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0
589 var startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate())
590 var endDay = endsMidnight ? new Date(end.getFullYear(), end.getMonth(), end.getDate() - 1) :
591 new Date(end.getFullYear(), end.getMonth(), end.getDate())
592 return startDay.getTime() !== endDay.getTime()
593 }
594
595 function calculateDaySpan(start, end, isMultiDay) {
596 if (!isMultiDay) return 1
597 var startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate())
598 var endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate())
599 var diff = Math.floor((endDay - startDay) / 86400000)
600 var endsMidnight = end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0
601 return Math.max(1, endsMidnight ? diff : diff + 1)
602 }
603
604 function formatTime(date) {
605 if (!date || isNaN(date.getTime())) return ""
606 return use12hourFormat ? I18n.locale.toString(date, "h:mm AP") : I18n.locale.toString(date, "HH:mm")
607 }
608
609 function formatDateTime(date) {
610 if (!date || isNaN(date.getTime())) return ""
611 return I18n.locale.monthName(date.getMonth(), Locale.ShortFormat) + ' ' +
612 date.getDate() + ', ' + date.getFullYear() + ' ' + formatTime(date)
613 }
614
615 function formatTimeRangeForDisplay(event) {
616 var start = event.fullStartTime || event.startTime
617 var end = event.fullEndTime || event.endTime
618 return formatTime(start) + " - " + formatTime(end)
619 }
620
621 // Interaction functions
622 function getEventTooltip(event) {
623 var start = event.fullStartTime || event.startTime
624 var end = event.fullEndTime || event.endTime
625 var tip = event.title + "\n" + formatDateTime(start) + " - " + formatDateTime(end)
626 if (event.location) tip += "\n⚲ " + event.location
627 if (event.description) tip += "\n🛈 " + event.description
628 return tip
629 }
630
631 function navigateWeek(days) {
632 var d = new Date(currentDate)
633 d.setDate(d.getDate() + days)
634 currentDate = d
635 }
636
637 // Event detail popup state
638 property var selectedEvent: null
639 property bool showEventDetail: false
640
641 // Todo detail popup state
642 property var selectedTodo: null
643 property bool showTodoDetail: false
644
645 function handleEventClick(event) {
646 selectedEvent = {
647 title: event.title || "",
648 description: event.description || "",
649 location: event.location || "",
650 startTime: event.fullStartTime || event.startTime,
651 endTime: event.fullEndTime || event.endTime,
652 calendarUid: event.calendarUid || "",
653 eventUid: event.eventUid || "",
654 rawStart: event.rawStart || 0,
655 rawEnd: event.rawEnd || 0
656 }
657 showEventDetail = true
658 }
659
660 function deleteEvent(calendarUid, eventUid) {
661 if (!pluginApi) return
662 var scriptPath = pluginApi.pluginDir + "/scripts/update-event.py"
663 updateEventProcess.command = ["python3", scriptPath,
664 "--calendar", calendarUid, "--uid", eventUid, "--action", "delete"]
665 updateEventProcess.running = true
666 }
667
668 function updateEvent(calendarUid, eventUid, summary, location, description, startTs, endTs) {
669 if (!pluginApi) return
670 var scriptPath = pluginApi.pluginDir + "/scripts/update-event.py"
671 var args = ["python3", scriptPath,
672 "--calendar", calendarUid, "--uid", eventUid, "--action", "update"]
673 if (summary !== undefined && summary !== null) { args.push("--summary"); args.push(summary) }
674 if (location !== undefined && location !== null) { args.push("--location"); args.push(location) }
675 if (description !== undefined && description !== null) { args.push("--description"); args.push(description) }
676 if (startTs > 0) { args.push("--start"); args.push(String(startTs)) }
677 if (endTs > 0) { args.push("--end"); args.push(String(endTs)) }
678 updateEventProcess.command = args
679 updateEventProcess.running = true
680 }
681
682 function handleTodoClick(todoData) {
683 selectedTodo = {
684 summary: todoData.title || todoData.summary || "",
685 description: todoData.description || "",
686 todoUid: todoData.todoUid || "",
687 calendarUid: todoData.calendarUid || "",
688 status: todoData.todoStatus || "",
689 priority: todoData.todoPriority || 0,
690 due: todoData.startTime || null
691 }
692 showTodoDetail = true
693 }
694
695 function updateTodoFields(taskListUid, todoUid, summary, description, dueTs, priority) {
696 if (!pluginApi) return
697 var scriptPath = pluginApi.pluginDir + "/scripts/update-todo.py"
698 var args = ["python3", scriptPath,
699 "--task-list", taskListUid, "--uid", todoUid, "--action", "update"]
700 if (summary !== undefined && summary !== null) { args.push("--summary"); args.push(summary) }
701 if (description !== undefined && description !== null) { args.push("--description"); args.push(description) }
702 if (dueTs > 0) { args.push("--due"); args.push(String(dueTs)) }
703 if (priority >= 0) { args.push("--priority"); args.push(String(priority)) }
704 updateTodoProcess.command = args
705 updateTodoProcess.running = true
706 }
707
708 function goToToday() { currentDate = new Date() }
709
710 // Event creation via EDS Python script
711 property string createEventStdout: ""
712 property string createEventStderr: ""
713
714 Process {
715 id: createEventProcess
716 onExited: function(exitCode, exitStatus) {
717 if (exitCode === 0) {
718 try {
719 var result = JSON.parse(createEventStdout)
720 if (result.success) {
721 console.log("Event created: " + result.uid)
722 Qt.callLater(loadEvents)
723 }
724 } catch(e) {
725 console.error("Failed to parse create-event output: " + createEventStdout)
726 }
727 } else {
728 console.error("create-event.py failed: " + createEventStderr)
729 }
730 createEventStdout = ""
731 createEventStderr = ""
732 }
733 stdout: SplitParser {
734 onRead: data => createEventStdout += data
735 }
736 stderr: SplitParser {
737 onRead: data => createEventStderr += data
738 }
739 }
740
741 // Event update/delete process
742 property string updateEventStdout: ""
743 property string updateEventStderr: ""
744
745 Process {
746 id: updateEventProcess
747 onExited: function(exitCode, exitStatus) {
748 if (exitCode === 0) {
749 try {
750 var result = JSON.parse(updateEventStdout)
751 if (result.success) {
752 console.log("[weekly-calendar] Event updated/deleted")
753 Qt.callLater(loadEvents)
754 }
755 } catch(e) {
756 console.error("[weekly-calendar] Failed to parse update-event output: " + updateEventStdout)
757 }
758 } else {
759 console.error("[weekly-calendar] update-event.py failed: " + updateEventStderr)
760 }
761 updateEventStdout = ""
762 updateEventStderr = ""
763 }
764 stdout: SplitParser { onRead: data => updateEventStdout += data }
765 stderr: SplitParser { onRead: data => updateEventStderr += data }
766 }
767
768 function createEvent(calendarUid, summary, startTimestamp, endTimestamp, location, description) {
769 var scriptPath = pluginApi.pluginDir + "/scripts/create-event.py"
770 var args = ["python3", scriptPath,
771 "--calendar", calendarUid,
772 "--summary", summary,
773 "--start", String(startTimestamp),
774 "--end", String(endTimestamp)]
775 if (location) { args.push("--location"); args.push(location) }
776 if (description) { args.push("--description"); args.push(description) }
777 createEventProcess.command = args
778 createEventProcess.running = true
779 }
780
781 // === Todo support ===
782
783 property string listTaskListsStdout: ""
784 property string listTaskListsStderr: ""
785
786 Process {
787 id: listTaskListsProcess
788 onExited: function(exitCode, exitStatus) {
789 if (exitCode === 0) {
790 try {
791 var result = JSON.parse(listTaskListsStdout)
792 if (Array.isArray(result)) {
793 taskLists = result.filter(function(tl) { return tl.enabled })
794 }
795 } catch(e) {
796 console.error("[weekly-calendar] Failed to parse task lists: " + listTaskListsStdout)
797 }
798 } else {
799 console.error("[weekly-calendar] list-task-lists.py failed: " + listTaskListsStderr)
800 }
801 listTaskListsStdout = ""
802 listTaskListsStderr = ""
803 }
804 stdout: SplitParser { onRead: data => listTaskListsStdout += data }
805 stderr: SplitParser { onRead: data => listTaskListsStderr += data }
806 }
807
808 property string listTodosStdout: ""
809 property string listTodosStderr: ""
810
811 Process {
812 id: listTodosProcess
813 onExited: function(exitCode, exitStatus) {
814 todosLoading = false
815 if (exitCode === 0) {
816 try {
817 var result = JSON.parse(listTodosStdout)
818 if (Array.isArray(result)) {
819 todosModel.clear()
820 for (var i = 0; i < result.length; i++) {
821 todosModel.append(result[i])
822 }
823 // Re-process events to include updated todos on the calendar
824 Qt.callLater(updateEventsFromService)
825 }
826 } catch(e) {
827 console.error("[weekly-calendar] Failed to parse todos: " + listTodosStdout)
828 }
829 } else {
830 console.error("[weekly-calendar] list-todos.py failed: " + listTodosStderr)
831 }
832 listTodosStdout = ""
833 listTodosStderr = ""
834 }
835 stdout: SplitParser { onRead: data => listTodosStdout += data }
836 stderr: SplitParser { onRead: data => listTodosStderr += data }
837 }
838
839 property string createTodoStdout: ""
840 property string createTodoStderr: ""
841
842 Process {
843 id: createTodoProcess
844 onExited: function(exitCode, exitStatus) {
845 if (exitCode === 0) {
846 try {
847 var result = JSON.parse(createTodoStdout)
848 if (result.success) {
849 console.log("[weekly-calendar] Todo created: " + result.uid)
850 Qt.callLater(loadTodos)
851 }
852 } catch(e) {
853 console.error("[weekly-calendar] Failed to parse create-todo output: " + createTodoStdout)
854 }
855 } else {
856 console.error("[weekly-calendar] create-todo.py failed: " + createTodoStderr)
857 }
858 createTodoStdout = ""
859 createTodoStderr = ""
860 }
861 stdout: SplitParser { onRead: data => createTodoStdout += data }
862 stderr: SplitParser { onRead: data => createTodoStderr += data }
863 }
864
865 property string updateTodoStdout: ""
866 property string updateTodoStderr: ""
867
868 Process {
869 id: updateTodoProcess
870 onExited: function(exitCode, exitStatus) {
871 if (exitCode === 0) {
872 try {
873 var result = JSON.parse(updateTodoStdout)
874 if (result.success) {
875 console.log("[weekly-calendar] Todo updated")
876 Qt.callLater(loadTodos)
877 }
878 } catch(e) {
879 console.error("[weekly-calendar] Failed to parse update-todo output: " + updateTodoStdout)
880 }
881 } else {
882 console.error("[weekly-calendar] update-todo.py failed: " + updateTodoStderr)
883 }
884 updateTodoStdout = ""
885 updateTodoStderr = ""
886 }
887 stdout: SplitParser { onRead: data => updateTodoStdout += data }
888 stderr: SplitParser { onRead: data => updateTodoStderr += data }
889 }
890
891 function loadTaskLists() {
892 if (!pluginApi) return
893 var scriptPath = pluginApi.pluginDir + "/scripts/list-task-lists.py"
894 listTaskListsProcess.command = ["python3", scriptPath]
895 listTaskListsProcess.running = true
896 }
897
898 function loadTodos() {
899 if (!pluginApi) return
900 todosLoading = true
901 todoSyncStatus = pluginApi.tr("panel.loading")
902 var scriptPath = pluginApi.pluginDir + "/scripts/list-todos.py"
903 var args = ["python3", scriptPath]
904 if (showCompletedTodos) args.push("--include-completed")
905 listTodosProcess.command = args
906 listTodosProcess.running = true
907 }
908
909 function createTodo(taskListUid, summary, due, priority, description) {
910 if (!pluginApi) return
911 var scriptPath = pluginApi.pluginDir + "/scripts/create-todo.py"
912 var args = ["python3", scriptPath,
913 "--task-list", taskListUid,
914 "--summary", summary]
915 if (due > 0) { args.push("--due"); args.push(String(due)) }
916 if (priority > 0) { args.push("--priority"); args.push(String(priority)) }
917 if (description) { args.push("--description"); args.push(description) }
918 createTodoProcess.command = args
919 createTodoProcess.running = true
920 }
921
922 function completeTodo(taskListUid, todoUid) {
923 if (!pluginApi) return
924 var scriptPath = pluginApi.pluginDir + "/scripts/update-todo.py"
925 updateTodoProcess.command = ["python3", scriptPath,
926 "--task-list", taskListUid, "--uid", todoUid, "--action", "complete"]
927 updateTodoProcess.running = true
928 }
929
930 function uncompleteTodo(taskListUid, todoUid) {
931 if (!pluginApi) return
932 var scriptPath = pluginApi.pluginDir + "/scripts/update-todo.py"
933 updateTodoProcess.command = ["python3", scriptPath,
934 "--task-list", taskListUid, "--uid", todoUid, "--action", "uncomplete"]
935 updateTodoProcess.running = true
936 }
937
938 function deleteTodo(taskListUid, todoUid) {
939 if (!pluginApi) return
940 var scriptPath = pluginApi.pluginDir + "/scripts/update-todo.py"
941 updateTodoProcess.command = ["python3", scriptPath,
942 "--task-list", taskListUid, "--uid", todoUid, "--action", "delete"]
943 updateTodoProcess.running = true
944 }
945}