import Clutter from 'gi://Clutter'; import GLib from 'gi://GLib'; import GObject from 'gi://GObject'; import Pango from 'gi://Pango'; import St from 'gi://St'; import * as D from './dates.js'; import {readJson, writeJson} from './store.js'; import {editTodo, todoClick, todoRow, todoTitle} from './todos.js'; import {button, contextMenu, dropdown, openDatePicker, openTimePicker, Popover, scrollBox, textButton} from './widgets.js'; // Rote Linie für die aktuelle Uhrzeit wandert einmal pro Minute const NOW_LINE_SECONDS = 60; const MONTH_CELL_ENTRIES = 2; const ALLDAY_LANE_HEIGHT = 20; function pad(n) { return String(n).padStart(2, '0'); } function endDateOf(event) { return event.endDate ?? event.date; } // Ohne gesetztes Ende gilt eine Stunde. function oneHourLater(start) { return D.minutesToTime(Math.min(D.timeToMinutes(start) + 60, 23 * 60 + 59)); } function endTimeOf(event) { return event.end ?? oneHourLater(event.start); } // 'ganztags', '09:00–10:30', bei mehrtägigen Terminen mit Enddatum. function timeRange(event) { const multiDay = endDateOf(event) !== event.date; if (!event.start) return multiDay ? `ganztags bis ${D.formatDayMonth(endDateOf(event))}` : 'ganztags'; if (multiDay) return `${event.start} – ${D.formatDayMonth(endDateOf(event))} ${endTimeOf(event)}`; return `${event.start}–${endTimeOf(event)}`; } // Anteil eines Termins an einem Tag in Minuten: [von, bis). function minuteSpan(event, day) { const from = day === event.date ? D.timeToMinutes(event.start) : 0; const to = day === endDateOf(event) ? D.timeToMinutes(endTimeOf(event)) : 24 * 60; return [from, Math.max(to, from + 30)]; } // Überlappende Termine bekommen Spalten nebeneinander. function assignColumns(items) { items.sort((a, b) => a.from - b.from || b.to - a.to); const overlaps = (a, b) => a.from < b.to && b.from < a.to; for (const item of items) { const used = new Set(items.filter(o => o.col !== undefined && overlaps(o, item)).map(o => o.col)); item.col = 0; while (used.has(item.col)) item.col++; } for (const item of items) item.cols = Math.max(...items.filter(o => overlaps(o, item)).map(o => o.col)) + 1; } // Ganztägige Termine bekommen Zeilen untereinander, wenn sich ihre Tage überschneiden. function assignLanes(items) { items.sort((a, b) => a.from - b.from || b.to - a.to); const laneEnds = []; for (const item of items) { let lane = laneEnds.findIndex(end => end < item.from); if (lane === -1) lane = laneEnds.length; laneEnds[lane] = item.to; item.lane = lane; } return laneEnds.length; } // Menü an der Mausposition: Termin oder Todo für Tag und optional Uhrzeit anlegen. function createMenu(store, day, time = null) { contextMenu([ {label: 'Termin erstellen', onSelect: () => editEvent(store, {date: day, start: time})}, {label: 'Todo erstellen', onSelect: () => editTodo(store, {date: day, time})}, ]); } // Popover am Mauszeiger mit Titel sowie Von und Bis (Datum und Uhrzeit). Enter speichert. export function editEvent(store, event) { const popover = new Popover(); popover.addLabel(event.id ? 'Termin bearbeiten' : 'Neuer Termin', 'popover-title'); const title = popover.addEntry(event.title ?? '', 'Titel'); let date = event.date; let start = event.start ?? null; let endDate = event.endDate ?? null; let end = event.end ?? null; const focus = title.clutter_text; const startDate = dropdown('', b => openDatePicker(b, date, picked => { if (picked) date = picked; update(); }), focus); const startTime = dropdown('', b => openTimePicker(b, start, picked => { start = picked; update(); }), focus); const endDateButton = dropdown('', b => openDatePicker(b, endDate ?? date, picked => { endDate = picked; update(); }), focus); const endTime = dropdown('', b => openTimePicker(b, end, picked => { end = picked; update(); }), focus); const update = () => { startDate.setLabel(D.formatDate(date)); startTime.setLabel(start ?? 'Keine Uhrzeit'); endDateButton.setLabel(D.formatDate(endDate ?? date)); endTime.setLabel(end ?? 'Keine Uhrzeit'); }; update(); popover.addRow('Von', startDate, startTime); popover.addRow('Bis', endDateButton, endTime); popover.setSave(() => { const text = title.get_text().trim(); if (!text) return 'Titel fehlt'; const saved = {...event, title: text, date, start: null, end: null, endDate: null}; if (endDate && endDate !== date) { if (endDate < date) return 'Ende liegt vor dem Anfang'; saved.endDate = endDate; } if (start) { saved.start = start; saved.end = end ?? oneHourLater(start); if (!saved.endDate && D.timeToMinutes(saved.end) <= D.timeToMinutes(start)) return 'Ende liegt vor dem Anfang'; } store.saveEvent(saved); }); popover.open(title.clutter_text); return popover; } export function eventMenu(store, event) { contextMenu([{label: 'Löschen', onSelect: () => store.removeEvent(event.id)}]); } // Gilt überall, wo ein Termin steht: links bearbeiten, rechts Menü. function eventClick(store, event, mouseButton) { if (mouseButton === 3) eventMenu(store, event); else editEvent(store, event); } export const CalendarPanel = GObject.registerClass( class CalendarPanel extends St.BoxLayout { _init(store) { super._init({vertical: true, style_class: 'desktop-panel calendar', reactive: true, x_expand: true, y_expand: false}); this._store = store; // Die zuletzt gewählte Ansicht bleibt über Neustarts erhalten this._settingsPath = GLib.build_filenamev([store.dir, 'settings.json']); this._view = readJson(this._settingsPath, {}).calendarView ?? 'month'; this._cursor = D.today(); this._drag = null; this._slots = []; this._weekScroll = null; this._nowLineId = null; this._buildHeader(); this._body = new St.Bin({x_expand: true, y_expand: true, x_align: Clutter.ActorAlign.FILL, y_align: Clutter.ActorAlign.FILL}); this.add_child(this._body); this.connect('destroy', () => this._stopNowLine()); store.onChange(() => this._render()); this._render(); } _buildHeader() { const header = new St.BoxLayout({style_class: 'panel-header'}); header.add_child(button('‹', () => this._shift(-1), 'desktop-button nav-button')); this._title = new St.Label({style_class: 'panel-title', y_align: Clutter.ActorAlign.CENTER}); header.add_child(this._title); header.add_child(button('›', () => this._shift(1), 'desktop-button nav-button')); header.add_child(new Clutter.Actor({x_expand: true})); this._viewButtons = {}; for (const [view, text] of [['day', 'Tag'], ['week', 'Woche'], ['month', 'Monat']]) { const b = button(text, () => this._go(this._cursor, view), 'desktop-button view-button'); this._viewButtons[view] = b; header.add_child(b); } this.add_child(header); } _go(date, view = this._view) { this._cursor = date; if (view !== this._view) writeJson(this._settingsPath, {...readJson(this._settingsPath, {}), calendarView: view}); this._view = view; this._render(); } _shift(n) { if (this._view === 'month') { const date = D.fromDateString(this._cursor); date.setDate(1); date.setMonth(date.getMonth() + n); this._go(D.toDateString(date)); } else if (this._view === 'week') { this._go(D.addDays(this._cursor, 7 * n)); } else { this._go(D.addDays(this._cursor, n)); } } _render() { for (const [view, b] of Object.entries(this._viewButtons)) b.checked = view === this._view; // Scrollposition der Woche über den Neuaufbau retten, sonst springt die Ansicht nach oben const keepScroll = this._view === 'week' && this._weekScroll ? this._weekScroll.vadjustment.value : null; this._stopNowLine(); this._weekScroll = null; this._slots = []; this._alldaySlots = []; this._alldayBlocks = []; const date = D.fromDateString(this._cursor); let content; if (this._view === 'month') { this._title.text = D.formatMonth(date.getFullYear(), date.getMonth()); content = this._monthView(date.getFullYear(), date.getMonth()); } else if (this._view === 'week') { const days = D.weekDays(this._cursor); this._title.text = `${D.formatDayMonth(days[0])} – ${D.formatDateInput(days[6])}`; content = this._weekView(days, keepScroll); } else { this._title.text = D.formatDate(this._cursor); content = this._dayView(this._cursor); } this._body.get_child()?.destroy(); this._body.set_child(content); } // Kurze Zeile für Termin oder Todo in Monats- und Wochenzellen. _eventEntry(event, styleClass) { const singleDay = endDateOf(event) === event.date; const text = event.start && singleDay ? `${event.start} ${event.title}` : event.title; return textButton(text, (_b, mouseButton) => eventClick(this._store, event, mouseButton), styleClass); } _todoEntry(todo, styleClass) { const text = `${todo.done ? '☑' : '☐'} ${todo.time ? `${todo.time} ` : ''}${todoTitle(todo)}`; return textButton(text, (_b, mouseButton) => todoClick(this._store, todo, mouseButton), `${styleClass} todo`); } // ---- Monat ---- _monthView(year, month) { const box = new St.BoxLayout({vertical: true, x_expand: true, y_expand: true}); const weekdays = new St.BoxLayout({style_class: 'weekday-row'}); weekdays.layout_manager.homogeneous = true; for (const name of D.WEEKDAYS) weekdays.add_child(new St.Label({text: name, style_class: 'weekday', x_expand: true})); box.add_child(weekdays); const layout = new Clutter.GridLayout({column_homogeneous: true, row_homogeneous: true, column_spacing: 2, row_spacing: 2}); const grid = new St.Widget({layout_manager: layout, x_expand: true, y_expand: true}); box.add_child(grid); const today = D.today(); D.monthGrid(year, month).forEach((day, i) => { const cell = new St.Button({ style_class: 'month-day', can_focus: true, x_expand: true, y_expand: true, button_mask: St.ButtonMask.ONE | St.ButtonMask.THREE, }); const inner = new St.BoxLayout({vertical: true, x_expand: true}); cell.set_child(inner); inner.add_child(new St.Label({text: String(Number(day.slice(8))), style_class: 'month-day-number'})); const entries = [ ...this._store.eventsOn(day).map(e => this._eventEntry(e, 'month-entry')), ...this._store.todosOn(day).map(t => this._todoEntry(t, 'month-entry')), ]; entries.slice(0, MONTH_CELL_ENTRIES).forEach(e => inner.add_child(e)); if (entries.length > MONTH_CELL_ENTRIES) inner.add_child(new St.Label({text: `+${entries.length - MONTH_CELL_ENTRIES}`, style_class: 'month-more'})); if (D.fromDateString(day).getMonth() !== month) cell.add_style_class_name('other-month'); if (day === today) cell.add_style_class_name('today'); cell.connect('clicked', (_b, mouseButton) => { if (mouseButton === 3) createMenu(this._store, day); else this._go(day, 'day'); }); layout.attach(cell, i % 7, Math.floor(i / 7), 1, 1); }); return box; } // ---- Woche ---- _weekView(days, keepScroll) { const box = new St.BoxLayout({vertical: true, x_expand: true, y_expand: true}); const today = D.today(); this._weekDays = days; this._slots = days.map(() => []); this._alldaySlots = []; box.add_child(this._weekRow(`KW ${D.isoWeek(days[0])}`, days.map(day => { const label = new St.Label({text: D.formatDate(day).slice(0, 10), style_class: 'week-day-head', x_expand: true}); if (day === today) label.add_style_class_name('today'); return label; }), 'head')); // Ganztägige Termine als Blöcke über ihre Tage, darunter Todos ohne Uhrzeit. // Drücken und Ziehen in der Zeile wählt die Tage. const alldayEvents = [...new Set(days.flatMap(day => this._store.eventsOn(day).filter(e => !e.start)))]; this._alldayBlocks = alldayEvents.map(event => ({ event, from: Math.max(0, days.indexOf(event.date) === -1 ? 0 : days.indexOf(event.date)), to: days.indexOf(endDateOf(event)) === -1 ? 6 : days.indexOf(endDateOf(event)), block: this._alldayBlock(event), })); const lanes = assignLanes(this._alldayBlocks); const alldayRow = new St.BoxLayout({style_class: 'week-row allday'}); alldayRow.add_child(new St.Label({text: 'ganz', style_class: 'week-hour'})); const alldayContainer = new St.Widget({layout_manager: new Clutter.BinLayout(), x_expand: true}); const alldayDays = new St.BoxLayout({style_class: 'week-days', x_expand: true}); alldayDays.layout_manager.homogeneous = true; days.forEach((day, i) => { const slot = new St.BoxLayout({vertical: true, style_class: 'week-slot allday', reactive: true, track_hover: true, x_expand: true}); slot.set_style(`padding-top: ${2 + lanes * ALLDAY_LANE_HEIGHT}px`); for (const todo of this._store.todosOn(day).filter(t => !t.time)) slot.add_child(this._todoEntry(todo, 'week-entry')); this._connectDrag(slot, i, null, day); this._alldaySlots[i] = slot; alldayDays.add_child(slot); }); const alldayOverlay = new St.Widget({x_expand: true, y_expand: true, reactive: false}); for (const {block} of this._alldayBlocks) alldayOverlay.add_child(block); alldayContainer.add_child(alldayDays); alldayContainer.add_child(alldayOverlay); alldayRow.add_child(alldayContainer); alldayDays.connect('notify::allocation', () => this._placeAlldayBlocks(alldayDays)); box.add_child(alldayRow); const [scroll, wrap] = scrollBox('week-scroll'); // Scrollbalken über dem Inhalt, sonst sind die Stundenzellen schmaler als Kopf- und Ganztags-Zeile scroll.overlay_scrollbars = true; // Stundenraster und darüber eine Ebene mit den Terminblöcken const container = new St.Widget({layout_manager: new Clutter.BinLayout(), x_expand: true}); const rows = new St.BoxLayout({vertical: true, x_expand: true}); const overlay = new St.Widget({x_expand: true, y_expand: true, reactive: false}); container.add_child(rows); container.add_child(overlay); wrap.add_child(container); this._weekRows = rows; this._weekScroll = scroll; this._nowLine = new St.Widget({style_class: 'week-now-line', visible: false}); overlay.add_child(this._nowLine); this._todayIndex = days.indexOf(D.today()); for (let hour = 0; hour < 24; hour++) rows.add_child(this._weekRow(`${pad(hour)}:00`, days.map((day, i) => this._slot(i, day, hour)))); this._blocks = []; days.forEach((day, dayIndex) => { const items = this._store.eventsOn(day).filter(e => e.start).map(event => { const [from, to] = minuteSpan(event, day); return {event, dayIndex, from, to, block: this._block(event, day)}; }); assignColumns(items); for (const item of items) { overlay.add_child(item.block); this._blocks.push(item); } }); // Die rote Linie liegt über den Blöcken overlay.set_child_above_sibling(this._nowLine, null); rows.connect('notify::allocation', () => { this._placeBlocks(); this._placeNowLine(); }); box.add_child(scroll); this._startNowLine(); // Nach dem ersten Layout: alte Position wiederherstellen, sonst die aktuelle Uhrzeit in die Mitte const adjustment = scroll.vadjustment; const id = adjustment.connect('changed', () => { if (adjustment.upper <= adjustment.page_size) return; const now = new Date(); const nowY = (now.getHours() * 60 + now.getMinutes()) / 60 * this._rowPitch(); const target = keepScroll ?? nowY - adjustment.page_size / 2; adjustment.value = Math.max(0, Math.min(target, adjustment.upper - adjustment.page_size)); adjustment.disconnect(id); }); return box; } // Rote Linie an der aktuellen Uhrzeit in der Spalte von heute. _placeNowLine() { if (!this._nowLine || this._todayIndex < 0) { this._nowLine?.hide(); return; } const cells = this._weekRows.get_child_at_index(0).get_child_at_index(1); const cell = cells.get_child_at_index(this._todayIndex); const now = new Date(); const y = (now.getHours() * 60 + now.getMinutes()) / 60 * this._rowPitch(); this._nowLine.set_position(Math.round(cells.x + cell.x), Math.round(y) - 1); this._nowLine.set_size(cell.width, 2); this._nowLine.show(); } _startNowLine() { this._nowLineId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, NOW_LINE_SECONDS, () => { this._placeNowLine(); return GLib.SOURCE_CONTINUE; }); } _stopNowLine() { if (this._nowLineId) { GLib.source_remove(this._nowLineId); this._nowLineId = null; } } // Ein Terminblock: Zeit und Titel, Klick bearbeitet, Rechtsklick öffnet das Menü. _block(event, day) { const inner = new St.BoxLayout({vertical: true, x_expand: true, y_expand: true, y_align: Clutter.ActorAlign.START}); inner.add_child(new St.Label({text: day === event.date ? event.start : '…', style_class: 'week-block-time'})); // Titel läuft über mehrere Zeilen, was nicht in den Block passt, wird unten abgeschnitten const title = new St.Label({text: event.title, style_class: 'week-block-title', x_expand: true}); title.clutter_text.line_wrap = true; title.clutter_text.line_wrap_mode = Pango.WrapMode.WORD_CHAR; title.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; inner.add_child(title); const block = new St.Button({ style_class: 'week-block', child: inner, can_focus: true, clip_to_allocation: true, button_mask: St.ButtonMask.ONE | St.ButtonMask.THREE, }); block.connect('clicked', (_b, mouseButton) => eventClick(this._store, event, mouseButton)); return block; } // Ganztägiger Block: Titel, Klick bearbeitet, Rechtsklick öffnet das Menü. _alldayBlock(event) { const label = new St.Label({text: event.title, x_expand: true, y_align: Clutter.ActorAlign.CENTER}); label.clutter_text.ellipsize = Pango.EllipsizeMode.END; const block = new St.Button({ style_class: 'week-allday-block', child: label, can_focus: true, button_mask: St.ButtonMask.ONE | St.ButtonMask.THREE, }); block.connect('clicked', (_b, mouseButton) => eventClick(this._store, event, mouseButton)); return block; } // Ganztags-Blöcke bündig über ihre Tagesspalten legen. _placeAlldayBlocks(cells) { const cellWidth = cells.get_child_at_index(0).width; const cellStep = cells.get_child_at_index(1).x - cells.get_child_at_index(0).x; for (const {block, from, to, lane} of this._alldayBlocks) { block.set_position(Math.round(cells.x + from * cellStep), lane * ALLDAY_LANE_HEIGHT); block.set_size(Math.round((to - from) * cellStep + cellWidth), ALLDAY_LANE_HEIGHT - 2); } } // Blöcke nach Tag, Uhrzeit und Spalte über das Raster legen. _placeBlocks() { const rows = this._weekRows; const pitch = this._rowPitch(); const dayCells = rows.get_child_at_index(0).get_child_at_index(1); const cellWidth = dayCells.get_child_at_index(0).width; const cellStep = dayCells.get_child_at_index(1).x - dayCells.get_child_at_index(0).x; for (const {block, dayIndex, from, to, col, cols} of this._blocks) { const width = cellWidth / cols; block.set_position(Math.round(dayCells.x + dayIndex * cellStep + col * width), Math.round(from / 60 * pitch)); block.set_size(Math.round(width), Math.round((to - from) / 60 * pitch) - 2); } } // Zeile mit Stundenlabel links und sieben gleich breiten Tageszellen. _weekRow(text, cells, extraClass = '') { const row = new St.BoxLayout({style_class: `week-row ${extraClass}`}); row.add_child(new St.Label({text, style_class: 'week-hour'})); const days = new St.BoxLayout({style_class: 'week-days', x_expand: true}); days.layout_manager.homogeneous = true; for (const cell of cells) days.add_child(cell); row.add_child(days); return row; } // Stundenzelle. Drücken und Ziehen wählt den Zeitraum für einen neuen Termin. _slot(dayIndex, day, hour) { const slot = new St.BoxLayout({vertical: true, style_class: 'week-slot', reactive: true, track_hover: true, x_expand: true}); for (const todo of this._store.todosOn(day).filter(t => t.time && D.hourOf(t.time) === hour)) slot.add_child(this._todoEntry(todo, 'week-entry')); this._connectDrag(slot, dayIndex, hour, day); this._slots[dayIndex][hour] = slot; return slot; } // Links drücken startet die Auswahl, rechts öffnet das Menü. hour null = Ganztags-Zeile. _connectDrag(slot, dayIndex, hour, day) { slot.connect('button-press-event', (_actor, event) => { if (event.get_button() === Clutter.BUTTON_SECONDARY) createMenu(this._store, day, hour === null ? null : `${pad(hour)}:00`); else if (event.get_button() === Clutter.BUTTON_PRIMARY) this._startDrag(dayIndex, hour); else return Clutter.EVENT_PROPAGATE; return Clutter.EVENT_STOP; }); } _rowPitch() { const rows = this._weekRows; return rows.get_child_at_index(1).y - rows.get_child_at_index(0).y; } _hourAt(y) { const [, top] = this._weekRows.get_transformed_position(); return Math.max(0, Math.min(23, Math.floor((y - top) / this._rowPitch()))); } _dayAt(x) { const cells = this._weekRows.get_child_at_index(0).get_child_at_index(1); const [left] = cells.get_transformed_position(); const step = cells.get_child_at_index(1).x - cells.get_child_at_index(0).x; return Math.max(0, Math.min(6, Math.floor((x - left) / step))); } // Auswahl läuft von einer Zelle bis zur anderen, auch über Tage hinweg. hour null = ganztägig. _startDrag(dayIndex, hour) { this._drag = {allDay: hour === null, from: {day: dayIndex, hour}, to: {day: dayIndex, hour}}; this._highlightDrag(); this._dragId = global.stage.connect('captured-event', (_stage, event) => { const type = event.type(); if (type === Clutter.EventType.MOTION) { const [x, y] = event.get_coords(); this._drag.to = {day: this._dayAt(x), hour: this._drag.allDay ? null : this._hourAt(y)}; this._highlightDrag(); } else if (type === Clutter.EventType.BUTTON_RELEASE) { this._finishDrag(); return Clutter.EVENT_STOP; } return Clutter.EVENT_PROPAGATE; }); } // Anfang und Ende der Auswahl in zeitlicher Reihenfolge. _dragRange() { const {from, to} = this._drag; const key = p => p.day * 24 + (p.hour ?? 0); return key(from) <= key(to) ? [from, to] : [to, from]; } _highlightDrag() { const [first, last] = this._dragRange(); const {allDay} = this._drag; const mark = (slot, on) => slot[on ? 'add_style_class_name' : 'remove_style_class_name']('selecting'); this._alldaySlots.forEach((slot, day) => mark(slot, allDay && day >= first.day && day <= last.day)); this._slots.forEach((hours, day) => hours.forEach((slot, hour) => { const startHour = day === first.day ? first.hour : 0; const endHour = day === last.day ? last.hour : 23; mark(slot, !allDay && day >= first.day && day <= last.day && hour >= startHour && hour <= endHour); })); } _clearHighlight() { for (const slot of [...this._alldaySlots, ...this._slots.flat()]) slot.remove_style_class_name('selecting'); } _finishDrag() { global.stage.disconnect(this._dragId); const [first, last] = this._dragRange(); const {allDay} = this._drag; this._drag = null; const preset = {date: this._weekDays[first.day]}; if (last.day !== first.day) preset.endDate = this._weekDays[last.day]; if (!allDay) { preset.start = `${pad(first.hour)}:00`; preset.end = last.hour === 23 ? '23:59' : `${pad(last.hour + 1)}:00`; } const popover = editEvent(this._store, preset); popover.onClose = () => this._clearHighlight(); } // ---- Tag ---- _dayView(day) { const [scroll, list] = scrollBox('day-scroll'); scroll.connect('button-press-event', (_actor, event) => { if (event.get_button() !== Clutter.BUTTON_SECONDARY) return Clutter.EVENT_PROPAGATE; createMenu(this._store, day); return Clutter.EVENT_STOP; }); const events = this._store.eventsOn(day); list.add_child(new St.Label({text: 'Termine', style_class: 'section-title'})); if (!events.length) list.add_child(new St.Label({text: 'Keine Termine', style_class: 'empty-hint'})); for (const event of events) { const row = new St.BoxLayout({style_class: 'day-entry-box', x_expand: true}); row.add_child(new St.Label({text: timeRange(event), style_class: 'day-entry-time', y_align: Clutter.ActorAlign.START})); row.add_child(new St.Label({text: event.title, style_class: 'day-entry-title', x_expand: true})); const b = new St.Button({ style_class: 'day-entry', child: row, can_focus: true, x_expand: true, button_mask: St.ButtonMask.ONE | St.ButtonMask.THREE, }); b.connect('clicked', (_b, mouseButton) => eventClick(this._store, event, mouseButton)); list.add_child(b); } const todos = this._store.todosOn(day); list.add_child(new St.Label({text: 'Todos', style_class: 'section-title'})); if (!todos.length) list.add_child(new St.Label({text: 'Keine Todos', style_class: 'empty-hint'})); for (const todo of todos) list.add_child(todoRow(this._store, todo)); return scroll; } });