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 property string syncStatus: ""
19
20 property real dayColumnWidth: 120 * Style.uiScaleRatio
21 property real allDaySectionHeight: 0 * Style.uiScaleRatio
22 property var allDayEventsWithLayout: []
23
24 property date weekStart: calculateWeekStart(currentDate, firstDayOfWeek)
25 property date weekEnd: calculateWeekEnd(weekStart)
26 property var weekDates: calculateWeekDates(weekStart)
27 property string monthRangeText: formatMonthRangeText(weekDates)
28
29 // Settings
30 property string panelModeSetting: pluginApi?.pluginSettings?.panelMode || "attached"
31 property string weekStartSetting: pluginApi?.pluginSettings?.weekStart || "1"
32 property string timeFormatSetting: pluginApi?.pluginSettings?.timeFormat || "24h"
33 property string lineColorTypeSetting: pluginApi?.pluginSettings?.lineColorType || "mOutline"
34 property real hourLineOpacitySetting: pluginApi?.pluginSettings?.hourLineOpacity ?? 0.5
35 property real dayLineOpacitySetting: pluginApi?.pluginSettings?.dayLineOpacity ?? 0.9
36
37 readonly property int firstDayOfWeek: weekStartSetting === "0" ? 0 :
38 weekStartSetting === "1" ? 1 :
39 weekStartSetting === "6" ? 6 : I18n.locale.firstDayOfWeek
40
41 readonly property bool use12hourFormat: timeFormatSetting === "12h" ? true :
42 timeFormatSetting === "24h" ? false :
43 Settings.data.location.use12hourFormat
44
45 readonly property color lineColor: lineColorTypeSetting === "mOnSurfaceVariant" ? Color.mOnSurfaceVariant : Color.mOutline
46
47 onWeekStartSettingChanged: if (hasLoadedOnce) Qt.callLater(loadEvents)
48 onTimeFormatSettingChanged: eventsModelChanged()
49 onCurrentDateChanged: Qt.callLater(loadEvents)
50 onLineColorTypeSettingChanged: eventsModelChanged()
51 onHourLineOpacitySettingChanged: eventsModelChanged()
52 onDayLineOpacitySettingChanged: eventsModelChanged()
53
54 // IPC
55 IpcHandler {
56 target: "plugin:weekly-calendar"
57 function togglePanel() { pluginApi?.withCurrentScreen(s => pluginApi.togglePanel(s)) }
58 }
59
60 Component.onCompleted: initializePluginSettings()
61
62 function initializePluginSettings() {
63 if (!pluginApi) return
64 if (!pluginApi.pluginSettings.weekStart) {
65 pluginApi.pluginSettings = {
66 weekStart: "1",
67 timeFormat: "24h",
68 lineColorType: "mOutline",
69 hourLineOpacity: 0.5,
70 dayLineOpacity: 1.0
71 }
72 pluginApi.saveSettings()
73 }
74 }
75
76 function initializePlugin() { loadEvents() }
77
78 // Fetch events
79 function loadEvents() {
80 if (!CalendarService.available || isLoading) return
81
82 isLoading = true
83 syncStatus = pluginApi.tr("panel.loading")
84
85 var now = new Date()
86 var start = new Date(weekStart), end = new Date(weekEnd)
87 start.setDate(start.getDate() - 7)
88 end.setDate(end.getDate() + 14)
89
90 CalendarService.loadEvents(
91 Math.max(0, Math.ceil((end - now) / 86400000)),
92 Math.max(0, Math.ceil((now - start) / 86400000))
93 )
94
95 hasLoadedOnce = true
96 updateEventsFromService()
97 }
98
99 function updateEventsFromService() {
100 clearEventModels()
101
102 if (!CalendarService.available) {
103 syncStatus = pluginApi.tr("panel.no_service")
104 } else if (!CalendarService.events?.length) {
105 syncStatus = pluginApi.tr("panel.no_events")
106 } else {
107 var stats = processCalendarEvents(CalendarService.events)
108 syncStatus = stats.timedCount === 1
109 ? `${stats.timedCount} ${pluginApi.tr("panel.event")}, ${stats.allDayCount} ${pluginApi.tr("panel.allday")}`
110 : `${stats.timedCount} ${pluginApi.tr("panel.events")}, ${stats.allDayCount} ${pluginApi.tr("panel.allday")}`
111 }
112
113 isLoading = false
114 }
115
116 // Events generation & layout
117 function processCalendarEvents(events) {
118 var uniqueEvents = {}, uniqueAllDayEvents = {}
119 var timedCount = 0, allDayCount = 0
120 var newEvents = [], newAllDayEvents = []
121 var weekStartDate = new Date(weekStart), weekEndDate = new Date(weekEnd)
122
123 for (var i = 0; i < events.length; i++) {
124 var event = events[i], eventObj = createEventObject(event, i)
125 var eventStart = new Date(eventObj.startTime), eventEnd = new Date(eventObj.endTime)
126 var overlapsWeek = eventStart < weekEndDate && eventEnd > weekStartDate
127
128 if (overlapsWeek) {
129 var key = event.uid + "-" + event.start + "-" + event.end + i
130 if (eventObj.allDay) {
131 if (!uniqueAllDayEvents[key]) {
132 uniqueAllDayEvents[key] = true
133 allDayCount++
134 newAllDayEvents.push(eventObj)
135 }
136 } else if (!uniqueEvents[key]) {
137 uniqueEvents[key] = true
138 timedCount++
139 processTimedEventIntoArray(eventObj, newEvents)
140 }
141 }
142 }
143
144 eventsModel.clear()
145 allDayEventsModel.clear()
146 newEvents.forEach(e => eventsModel.append(e))
147 newAllDayEvents.forEach(e => allDayEventsModel.append(e))
148
149 calculateAllDayEventLayout()
150 updateOverlappingEvents()
151 eventsModel.layoutChanged()
152 allDayEventsModel.layoutChanged()
153
154 return {timedCount: timedCount, allDayCount: allDayCount}
155 }
156
157 function clearEventModels() { eventsModel.clear(); allDayEventsModel.clear() }
158
159 function processTimedEventIntoArray(eventObj, target) {
160 var start = new Date(eventObj.startTime), end = new Date(eventObj.endTime)
161 var startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate())
162 var endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate())
163
164 if (startDay.getTime() === endDay.getTime()) {
165 if (start < weekEnd && end > weekStart) target.push(createEventPart(eventObj, 0, start, end, startDay, 0, 1))
166 } else {
167 var firstEnd = new Date(startDay); firstEnd.setHours(24, 0, 0, 0)
168 var secondStart = new Date(endDay); secondStart.setHours(0, 0, 0, 0)
169 if (start < weekEnd && firstEnd > weekStart) target.push(createEventPart(eventObj, 0, start, firstEnd, startDay, 0, 2))
170 if (secondStart < weekEnd && end > weekStart) target.push(createEventPart(eventObj, 1, secondStart, end, endDay, 1, 2))
171 }
172 }
173
174 function createEventObject(event, idx) {
175 var start = new Date(event.start * 1000), end = new Date(event.end * 1000)
176 var allDay = isAllDayEvent(event), multiDay = isMultiDayEvent(event)
177 var daySpan = calculateDaySpan(start, end, multiDay || allDay)
178 var endsMidnight = end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0
179 var id = event.uid + "-" + event.start + "-" + event.end + idx
180
181 return {
182 id: id, title: event.summary || "Untitled Event", description: event.description || "",
183 location: event.location || "", startTime: start, endTime: end, allDay: allDay, multiDay: multiDay,
184 daySpan: daySpan, rawStart: event.start, rawEnd: event.end, duration: (event.end - event.start) / 3600,
185 endsAtMidnight: endsMidnight
186 }
187 }
188
189 function createEventPart(event, partIdx, start, end, day, partNum, total) {
190 return {
191 id: event.id + "-part-" + partIdx, title: event.title, description: event.description,
192 location: event.location, startTime: start, endTime: end, allDay: false, multiDay: true,
193 daySpan: 1, fullStartTime: event.startTime, fullEndTime: event.endTime, isPart: true,
194 partDay: new Date(day), partIndex: partNum, totalParts: total
195 }
196 }
197
198 function getDayIndexForDate(date) {
199 if (!date || isNaN(date.getTime())) return -1
200 var diff = Math.floor((date - weekStart) / 86400000)
201 return diff >= 0 && diff < 7 ? diff : -1
202 }
203 function getDisplayDayIndexForDate(date) { return getDayIndexForDate(date) }
204
205 function calculateAllDaySpanForWeek(event) {
206 var start = new Date(event.startTime), end = new Date(event.endTime)
207 var endsMidnight = end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0
208 var adjEnd = endsMidnight ? new Date(end.getTime() - 1) : end
209 var startIdx = Math.max(0, getDayIndexForDate(start))
210 var endIdx = Math.min(6, Math.floor((Math.min(adjEnd, weekEnd) - weekStart) / 86400000))
211 return Math.max(1, endIdx - startIdx + 1)
212 }
213
214 function findAvailableLane(occupied, start, end) {
215 var lane = 0, found = false
216 while (!found) {
217 var conflict = false
218 for (var d = start; d <= end; d++) {
219 if (occupied[d]?.includes(lane)) { conflict = true; break }
220 }
221 if (!conflict) found = true
222 else lane++
223 }
224 return lane
225 }
226
227 function calculateAllDayEventLayout() {
228
229 var occupied = [[], [], [], [], [], [], []]
230 var eventsWithLayout = [], maxLanes = 0
231 var weekStartDate = new Date(weekStart), weekEndDate = new Date(weekEnd)
232
233 for (var i = 0; i < allDayEventsModel.count; i++) {
234 var event = allDayEventsModel.get(i)
235 var start = new Date(event.startTime), end = new Date(event.endTime)
236 var startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate())
237 var endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate())
238
239 if (startDay < weekStartDate && endDay >= weekStartDate) {
240 var span = calculateAllDaySpanForWeek(event)
241 if (span > 0) {
242 var lane = findAvailableLane(occupied, 0, span - 1)
243 for (var d = 0; d < span && d < 7; d++) { if (!occupied[d]) occupied[d] = []; occupied[d].push(lane) }
244 maxLanes = Math.max(maxLanes, lane + 1)
245 eventsWithLayout.push(createLayoutEvent(event, 0, span, lane, true))
246 }
247 } else if (startDay >= weekStartDate && startDay < weekEndDate) {
248 var startIdx = getDayIndexForDate(start)
249 var span = calculateAllDaySpanForWeek(event)
250 if (span > 0) {
251 var lane = findAvailableLane(occupied, startIdx, startIdx + span - 1)
252 for (var d = startIdx; d < startIdx + span && d < 7; d++) { if (!occupied[d]) occupied[d] = []; occupied[d].push(lane) }
253 maxLanes = Math.max(maxLanes, lane + 1)
254 eventsWithLayout.push(createLayoutEvent(event, startIdx, span, lane, false))
255 }
256 }
257 }
258
259 eventsWithLayout.sort((a,b) => a.lane !== b.lane ? a.lane - b.lane : a.startDay - b.startDay)
260 allDayEventsWithLayout = eventsWithLayout
261 allDaySectionHeight = maxLanes === 0 ? 0 : maxLanes === 1 ? 25 : Math.max(30, maxLanes * 25)
262
263 return maxLanes
264 }
265
266 function createLayoutEvent(event, startDay, spanDays, lane, isCont) {
267 return {
268 id: event.id, title: event.title, description: event.description, location: event.location,
269 startTime: event.startTime, endTime: event.endTime, allDay: event.allDay, multiDay: event.multiDay,
270 daySpan: event.daySpan, rawStart: event.rawStart, rawEnd: event.rawEnd, duration: event.duration,
271 endsAtMidnight: event.endsAtMidnight, fullStartTime: event.fullStartTime, fullEndTime: event.fullEndTime,
272 startDay: startDay, spanDays: spanDays, lane: lane, isContinuation: isCont
273 }
274 }
275
276 function updateOverlappingEvents() {
277 var overlapData = {}
278 for (var day = 0; day < 7; day++) processDayEventsWithLanes(day, overlapData)
279 overlappingEventsData = overlapData
280 }
281
282 function processDayEventsWithLanes(day, data) {
283 var events = []
284 for (var i = 0; i < eventsModel.count; i++) {
285 var e = eventsModel.get(i)
286 if (getDisplayDayIndexForDate(e.startTime) === day) {
287 events.push({index: i, start: e.startTime.getTime(), end: e.endTime.getTime()})
288 }
289 }
290 if (events.length === 0) return
291
292 events.sort((a,b) => a.start === b.start ? (b.end - b.start) - (a.end - a.start) : a.start - b.start)
293 var groups = [], current = [], endTime = -1
294
295 events.forEach(e => {
296 if (e.start >= endTime) {
297 if (current.length > 0) groups.push({events: current, endTime: endTime})
298 current = [e]; endTime = e.end
299 } else {
300 current.push(e)
301 if (e.end > endTime) endTime = e.end
302 }
303 })
304 if (current.length > 0) groups.push({events: current, endTime: endTime})
305 groups.forEach(g => assignLanesToGroup(g.events, data))
306 }
307
308 function assignLanesToGroup(group, data) {
309 if (group.length === 0) return
310 var laneEnds = []
311 group.forEach(e => {
312 var placed = false
313 for (var lane = 0; lane < laneEnds.length; lane++) {
314 if (e.start >= laneEnds[lane]) {
315 laneEnds[lane] = e.end
316 e.lane = lane
317 placed = true
318 break
319 }
320 }
321 if (!placed) { e.lane = laneEnds.length; laneEnds.push(e.end) }
322 })
323
324 var total = laneEnds.length
325 group.forEach(e => {
326 data[e.index] = {
327 xOffset: (e.lane / total) * (dayColumnWidth +1),
328 width: (dayColumnWidth+1) / total,
329 lane: e.lane,
330 totalLanes: total
331 }
332 })
333 }
334
335 // Range & formatting of calendar
336 function calculateWeekStart(date, firstDay) {
337 var d = new Date(date)
338 var day = d.getDay()
339 var diff = (day - firstDay + 7) % 7
340 d.setDate(d.getDate() - diff)
341 d.setHours(0, 0, 0, 0)
342 return d
343 }
344
345 function calculateWeekDates(startDate) {
346 var dates = []
347 var start = new Date(startDate)
348
349 for (var i = 0; i < 7; i++) {
350 var d = new Date(start)
351 d.setDate(start.getDate() + i)
352 dates.push(d)
353 }
354
355 return dates
356 }
357
358 function calculateWeekEnd(startDate) {
359 var end = new Date(startDate)
360 end.setDate(end.getDate() + 7)
361 end.setHours(0, 0, 0, 0)
362 return end
363 }
364
365 function isSameDay(date1, date2) {
366 return date1.getDate() === date2.getDate() &&
367 date1.getMonth() === date2.getMonth() &&
368 date1.getFullYear() === date2.getFullYear()
369 }
370
371 function isToday(date) {
372 var today = new Date()
373 return isSameDay(date, today)
374 }
375
376 function isDateInRange(date, startDate, endDate) {
377 return date >= startDate && date < endDate
378 }
379
380 function formatMonthRangeText(dates) {
381 if (!dates || dates.length === 0) return ""
382 var start = dates[0], end = dates[6], locale = I18n.locale
383 return locale.toString(start, "yyyy-MM") === locale.toString(end, "yyyy-MM")
384 ? locale.toString(start, "MMM yyyy")
385 : start.getFullYear() === end.getFullYear()
386 ? locale.toString(start, "MMM") + " – " + locale.toString(end, "MMM") + " " + start.getFullYear()
387 : locale.toString(start, "MMM yyyy") + " – " + locale.toString(end, "MMM yyyy")
388 }
389
390 function isAllDayEvent(event) {
391 var dur = event.end - event.start
392 var start = new Date(event.start * 1000), end = new Date(event.end * 1000)
393 var startsMidnight = start.getHours() === 0 && start.getMinutes() === 0 && start.getSeconds() === 0
394 var endsMidnight = end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0
395 return (dur === 86400 && startsMidnight) || (dur >= 86400 && endsMidnight) || dur >= 86400
396 }
397
398 function isMultiDayEvent(event) {
399 var start = new Date(event.start * 1000), end = new Date(event.end * 1000)
400 var endsMidnight = end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0
401 var startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate())
402 var endDay = endsMidnight ? new Date(end.getFullYear(), end.getMonth(), end.getDate() - 1) :
403 new Date(end.getFullYear(), end.getMonth(), end.getDate())
404 return startDay.getTime() !== endDay.getTime()
405 }
406
407 function calculateDaySpan(start, end, isMultiDay) {
408 if (!isMultiDay) return 1
409 var startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate())
410 var endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate())
411 var diff = Math.floor((endDay - startDay) / 86400000)
412 var endsMidnight = end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0
413 return Math.max(1, endsMidnight ? diff : diff + 1)
414 }
415
416 function formatTime(date) {
417 if (!date || isNaN(date.getTime())) return ""
418 return use12hourFormat ? I18n.locale.toString(date, "h:mm AP") : I18n.locale.toString(date, "HH:mm")
419 }
420
421 function formatDateTime(date) {
422 if (!date || isNaN(date.getTime())) return ""
423 return I18n.locale.monthName(date.getMonth(), Locale.ShortFormat) + ' ' +
424 date.getDate() + ', ' + date.getFullYear() + ' ' + formatTime(date)
425 }
426
427 function formatTimeRangeForDisplay(event) {
428 var start = event.fullStartTime || event.startTime
429 var end = event.fullEndTime || event.endTime
430 return formatTime(start) + " - " + formatTime(end)
431 }
432
433 // Interaction functions
434 function getEventTooltip(event) {
435 var start = event.fullStartTime || event.startTime
436 var end = event.fullEndTime || event.endTime
437 var tip = event.title + "\n" + formatDateTime(start) + " - " + formatDateTime(end)
438 if (event.location) tip += "\n⚲ " + event.location
439 if (event.description) tip += "\n🛈 " + event.description
440 return tip
441 }
442
443 function navigateWeek(days) {
444 var d = new Date(currentDate)
445 d.setDate(d.getDate() + days)
446 currentDate = d
447 }
448
449 function handleEventClick(event) {
450 const date = event.startTime || new Date();
451 const month = date.getMonth() + 1;
452 const day = date.getDate();
453 const year = date.getFullYear();
454 const dateWithSlashes = `${month.toString().padStart(2, '0')}/${day.toString().padStart(2, '0')}/${year.toString().substring(2)}`;
455 if (ProgramCheckerService.gnomeCalendarAvailable) {
456 Quickshell.execDetached(["gnome-calendar", "--date", dateWithSlashes]);
457 }
458 }
459
460 function goToToday() { currentDate = new Date() }
461}