Rotatory anlegen: Morning-Meeting-Moderation auslosen und nach Nextcloud schreiben

Express-App mit JSON-Speicher unter VCH_DATA_DIR, Monatskalender mit Fotos,
Personen mit Rollen und Teilzeit-Freitagen, Abwesenheiten, Feiertage je
Bundesland, faire Zufallsauslosung mit Fixieren und Neu-Auslosen.

Nextcloud: CalDAV-Discovery, Zielkalender wählbar, Termine je Werktag
schreiben und aktualisieren, Abwesenheiten aus Kalender importieren,
Personen samt Fotos aus dem Systemadressbuch übernehmen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ferdi2goandClaude Fable 5.1 committed 2026-09-16 06:39:37 +00:00
commit 95756778d8
26 files changed
+4697

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { unescapeText, unfold, parseLine, parseEvents, parseVCards, parseAbsenceSummary, matchPerson, mergeAbsences, toLocalDate } from '../server/ical.js';
test('unfold und parseLine', () => {
const lines = unfold('SUMMARY:Ein sehr\r\n langer Titel\r\nDTSTART;TZID=Europe/Berlin:20261005T093000\r\n');
assert.deepEqual(lines, ['SUMMARY:Ein sehr langer Titel', 'DTSTART;TZID=Europe/Berlin:20261005T093000']);
const p = parseLine('ATTENDEE;CN="Max, Mustermann";ROLE=REQ-PARTICIPANT:mailto:max@example.de');
assert.equal(p.name, 'ATTENDEE');
assert.equal(p.params.CN, 'Max, Mustermann');
assert.equal(p.value, 'mailto:max@example.de');
});
test('unescapeText löst \\; \\, \\n und \\\\ auf', () => {
assert.equal(unescapeText('a\\;b\\,c\\nd\\\\e'), 'a;b,c\nd\\e');
assert.equal(unescapeText('x\\\\ny'), 'x\\ny', 'maskierter Backslash gefolgt von n ist kein Zeilenumbruch');
});
test('toLocalDate: Datum, UTC nach Berlin, Wandzeit', () => {
assert.deepEqual(toLocalDate('20261005', { VALUE: 'DATE' }).date, '2026-10-05');
assert.equal(toLocalDate('20261005T220000Z').date, '2026-10-06'); // 22:00 UTC = 00:00 MESZ am Folgetag
assert.equal(toLocalDate('20261205T230000Z').date, '2026-12-06'); // Winterzeit
assert.equal(toLocalDate('20261005T093000', { TZID: 'Europe/Berlin' }).date, '2026-10-05');
assert.equal(toLocalDate('kaputt'), null);
});
const ICS = (body) => `BEGIN:VCALENDAR\r\nVERSION:2.0\r\n${body}\r\nEND:VCALENDAR\r\n`;
test('ganztägige Urlaubswoche: DTEND exklusiv', () => {
const [ev] = parseEvents(ICS('BEGIN:VEVENT\r\nUID:a\r\nSUMMARY:Stefan Frühmorgen (Urlaub)\r\nDTSTART;VALUE=DATE:20260914\r\nDTEND;VALUE=DATE:20260919\r\nEND:VEVENT'));
assert.equal(ev.from, '2026-09-14');
assert.equal(ev.to, '2026-09-18');
assert.equal(ev.allDay, true);
assert.equal(ev.summary, 'Stefan Frühmorgen (Urlaub)');
});
test('Termin mit Uhrzeit, Termin bis Mitternacht, DURATION, mehrere VEVENTs', () => {
const evs = parseEvents(ICS([
'BEGIN:VEVENT\r\nUID:b\r\nSUMMARY:Libor Herudek (Urlaub)\r\nDTSTART;TZID=Europe/Berlin:20260918T120000\r\nDTEND;TZID=Europe/Berlin:20260918T160000\r\nEND:VEVENT',
'BEGIN:VEVENT\r\nUID:c\r\nSUMMARY:X (krank)\r\nDTSTART:20260917T220000Z\r\nDTEND:20260918T220000Z\r\nEND:VEVENT',
'BEGIN:VEVENT\r\nUID:d\r\nSUMMARY:Y (frei)\r\nDTSTART;VALUE=DATE:20260921\r\nDURATION:P2D\r\nEND:VEVENT',
'BEGIN:VEVENT\r\nUID:e\r\nSUMMARY:Z\r\nDTSTART;VALUE=DATE:20260922\r\nEND:VEVENT',
].join('\r\n')));
assert.equal(evs.length, 4);
assert.deepEqual([evs[0].from, evs[0].to], ['2026-09-18', '2026-09-18']);
assert.deepEqual([evs[1].from, evs[1].to], ['2026-09-18', '2026-09-18']); // 00:00–24:00 MESZ
assert.deepEqual([evs[2].from, evs[2].to], ['2026-09-21', '2026-09-22']);
assert.deepEqual([evs[3].from, evs[3].to], ['2026-09-22', '2026-09-22']);
});
test('RRULE und RECURRENCE-ID werden durchgereicht, Alarme stören nicht', () => {
const [ev] = parseEvents(ICS('BEGIN:VEVENT\r\nUID:r\r\nSUMMARY:A (frei)\r\nDTSTART;VALUE=DATE:20260914\r\nRRULE:FREQ=WEEKLY;BYDAY=MO\r\nBEGIN:VALARM\r\nTRIGGER:-PT15M\r\nACTION:DISPLAY\r\nDESCRIPTION:x\r\nEND:VALARM\r\nEND:VEVENT'));
assert.equal(ev.rrule, 'FREQ=WEEKLY;BYDAY=MO');
assert.equal(ev.description, '');
});
test('vCard 3.0 mit Base64-Foto und vCard 4.0 mit data-URI', () => {
const v3 = 'BEGIN:VCARD\r\nVERSION:3.0\r\nUID:ferdinand\r\nFN:Ferdinand Urban\r\nN:Urban;Ferdinand;;;\r\nEMAIL;TYPE=INTERNET:urban@team-tradeo.de\r\nPHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQ\r\n SkZJRg==\r\nCLOUD:ferdinand@cld2.tradeo.de\r\nEND:VCARD\r\n';
const [c] = parseVCards(v3);
assert.equal(c.uid, 'ferdinand');
assert.equal(c.firstName, 'Ferdinand');
assert.equal(c.lastName, 'Urban');
assert.equal(c.email, 'urban@team-tradeo.de');
assert.deepEqual(c.photo, { kind: 'inline', mime: 'image/jpeg', base64: '/9j/4AAQSkZJRg==' });
const v4 = 'BEGIN:VCARD\r\nVERSION:4.0\r\nUID:x\r\nFN:Anna Maria Ciesla\r\nPHOTO:data:image/png;base64,iVBORw0KGgo=\r\nEND:VCARD\r\n';
const [d] = parseVCards(v4);
assert.equal(d.firstName, 'Anna Maria');
assert.equal(d.lastName, 'Ciesla');
assert.equal(d.photo.mime, 'image/png');
assert.equal(d.photo.base64, 'iVBORw0KGgo=');
const v4url = 'BEGIN:VCARD\r\nVERSION:4.0\r\nUID:y\r\nFN:Solo\r\nPHOTO;VALUE=URI:https://cloud.example.de/avatar.png\r\nEND:VCARD\r\n';
const [e] = parseVCards(v4url);
assert.equal(e.lastName, 'Solo');
assert.equal(e.firstName, '');
assert.deepEqual(e.photo, { kind: 'url', url: 'https://cloud.example.de/avatar.png' });
});
test('Abwesenheitstitel deuten', () => {
assert.deepEqual(parseAbsenceSummary('Stefan Frühmorgen (Urlaub)'), { name: 'Stefan Frühmorgen', kind: 'urlaub', raw: 'Urlaub' });
assert.equal(parseAbsenceSummary('Carmine Mastromarino (krank)').kind, 'krankheit');
assert.equal(parseAbsenceSummary('Alex Koop (frei)').kind, 'frei');
assert.equal(parseAbsenceSummary('Hans Holzmann [Home Office]').kind, 'frei');
assert.deepEqual(parseAbsenceSummary('Nur Name'), { name: 'Nur Name', kind: 'sonstiges', raw: '' });
});
test('Namen zuordnen: Vor+Nach, Nach+Vor, Umlaute, eindeutiger Nachname', () => {
const persons = [
{ id: '1', firstName: 'Stefan', lastName: 'Frühmorgen' },
{ id: '2', firstName: 'Michael', lastName: 'Lotter' },
{ id: '3', firstName: 'Michael', lastName: 'Schuster' },
];
assert.equal(matchPerson('Stefan Frühmorgen', persons).id, '1');
assert.equal(matchPerson('Fruehmorgen, Stefan', persons).id, '1');
assert.equal(matchPerson('FRÜHMORGEN STEFAN', persons).id, '1');
assert.equal(matchPerson('Stefan Fru\u0308hmorgen', persons).id, '1', 'NFD-Umlaut (u + Trema)');
assert.equal(matchPerson('Lotter', persons).id, '2');
assert.equal(matchPerson('Michael', persons), null);
assert.equal(matchPerson('Unbekannt Person', persons), null);
assert.equal(matchPerson('', persons), null);
});
test('Abwesenheiten verschmelzen: angrenzend und überlappend, getrennt nach Art', () => {
const merged = mergeAbsences([
{ personId: 'a', type: 'urlaub', from: '2026-10-05', to: '2026-10-06' },
{ personId: 'a', type: 'urlaub', from: '2026-10-07', to: '2026-10-09' },
{ personId: 'a', type: 'urlaub', from: '2026-10-08', to: '2026-10-08' },
{ personId: 'a', type: 'frei', from: '2026-10-05', to: '2026-10-05' },
{ personId: 'a', type: 'urlaub', from: '2026-10-19', to: '2026-10-19' },
{ personId: 'b', type: 'urlaub', from: '2026-10-05', to: '2026-10-05' },
]);
assert.deepEqual(merged.map((m) => `${m.personId}/${m.type}/${m.from}/${m.to}`), [
'a/urlaub/2026-10-05/2026-10-09', 'a/frei/2026-10-05/2026-10-05', 'b/urlaub/2026-10-05/2026-10-05', 'a/urlaub/2026-10-19/2026-10-19',
]);
});