Files
desktop-planer/desktop@marha.de/calendar.js
2026-09-18 22:02:53 +02:00

610 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Clutter from 'gi://Clutter';
import GObject from 'gi://GObject';
import Pango from 'gi://Pango';
import St from 'gi://St';
import * as D from './dates.js';
import {editTodo, todoMenu, todoRow, todoTitle} from './todos.js';
import {button, contextMenu, dropdown, openDatePicker, openTimePicker, Popover, scrollBox, textButton} from './widgets.js';
const FIRST_VISIBLE_HOUR = 7;
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) {
const [x, y] = global.get_pointer();
contextMenu([
{label: 'Termin erstellen', onSelect: () => editEvent(store, {date: day, start: time}, {x, y})},
{label: 'Todo erstellen', onSelect: () => editTodo(store, {date: day, time}, {x, y})},
]);
}
// Popover mit Titel sowie Von und Bis (Datum und Uhrzeit). Enter speichert.
export function editEvent(store, event, anchor) {
const popover = new Popover(anchor);
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)}]);
}
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;
this._view = 'month';
this._cursor = D.today();
this._drag = null;
this._slots = [];
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);
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 [['month', 'Monat'], ['week', 'Woche'], ['day', 'Tag']]) {
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;
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;
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);
} 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) => {
if (mouseButton === 3)
eventMenu(this._store, event);
else
editEvent(this._store, event, b);
}, styleClass);
}
_todoEntry(todo, styleClass) {
const text = `${todo.done ? '☑' : '☐'} ${todo.time ? `${todo.time} ` : ''}${todoTitle(todo)}`;
return textButton(text, (b, mouseButton) => {
if (mouseButton === 3)
todoMenu(this._store, todo);
else
editTodo(this._store, todo, b);
}, `${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) {
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;
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);
}
});
rows.connect('notify::allocation', () => this._placeBlocks());
box.add_child(scroll);
// Nach dem ersten Layout auf den Morgen scrollen.
const adjustment = scroll.vadjustment;
const id = adjustment.connect('changed', () => {
if (adjustment.upper <= adjustment.page_size)
return;
adjustment.value = FIRST_VISIBLE_HOUR * this._rowPitch();
adjustment.disconnect(id);
});
return box;
}
// 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'}));
const title = new St.Label({text: event.title, style_class: 'week-block-title', x_expand: true});
title.clutter_text.ellipsize = Pango.EllipsizeMode.END;
inner.add_child(title);
const block = new St.Button({
style_class: 'week-block',
child: inner,
can_focus: true,
button_mask: St.ButtonMask.ONE | St.ButtonMask.THREE,
});
block.connect('clicked', (_b, mouseButton) => {
if (mouseButton === 3)
eventMenu(this._store, event);
else
editEvent(this._store, event, block);
});
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) => {
if (mouseButton === 3)
eventMenu(this._store, event);
else
editEvent(this._store, event, block);
});
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];
let anchor = this._alldaySlots[last.day];
if (!allDay) {
preset.start = `${pad(first.hour)}:00`;
preset.end = last.hour === 23 ? '23:59' : `${pad(last.hour + 1)}:00`;
anchor = this._slots[last.day][last.hour];
}
const popover = editEvent(this._store, preset, anchor);
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) => {
if (mouseButton === 3)
eventMenu(this._store, event);
else
editEvent(this._store, event, b);
});
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;
}
});