Compare commits

...
1 Commits
Author SHA1 Message Date
ferdi2goandClaude Fable 5.1 42d495c54e Tauschanfragen gegen offene Plätze erlauben und Lücken nachbesetzen
Ein Wunschtag ohne Tauschpartner bedeutet jetzt: die Person übernimmt
dort einen offenen Platz; ihr alter Platz wird beim Ausführen sofort
fair nachbesetzt, ohne sie selbst. Rollen werden bei allen Tauschformen
geprüft. Bearbeiter können Person und Wunschtag einer Anfrage nachtragen,
etwa aus der Notiz. Der Anfragedialog zeigt, was am Wunschtag los ist.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 11:55:53 +00:00
7 changed files with 196 additions and 30 deletions

No files matched your search

+10 -6
View File
@@ -82,12 +82,16 @@ für eine einzelne Person.
## Tauschanfragen
Wer an seinem Tag nicht kann, klickt den Tag im Kalender an, wählt seinen Namen, optional die
Person und den Tag für den Tausch, und schreibt eine Notiz. Dafür ist keine Anmeldung nötig;
pro Adresse sind zehn Anfragen je Stunde möglich. Der Tag bekommt im Kalender eine Markierung.
Bearbeiter sehen alle Anfragen unter „Anfragen" mit Zähler in der Navigation und entscheiden:
„Ausführen" tauscht wie gewünscht (über Kreuz oder Übernahme des Tages), „Tag öffnen" lässt
manuell wählen, „Erledigt" und „Ablehnen" schließen die Anfrage ohne Änderung. Nach einem
Wer an seinem Tag nicht kann, klickt den Tag im Kalender an, wählt seinen Namen und schreibt
eine Notiz. Optional dazu: eine Person, mit der getauscht werden soll, und ein Wunschtag.
Ohne Person bedeutet der Wunschtag, dass am Wunschtag ein noch offener Platz übernommen wird.
Dafür ist keine Anmeldung nötig; pro Adresse sind zehn Anfragen je Stunde möglich. Der Tag
bekommt im Kalender eine Markierung. Bearbeiter sehen alle Anfragen unter „Anfragen" mit Zähler
in der Navigation und entscheiden: „Ausführen" tauscht wie gewünscht (über Kreuz, Übernahme
des Tages oder Wechsel auf den offenen Platz; der frei werdende Platz wird sofort fair
nachbesetzt), „Wunsch anpassen" trägt Person oder Wunschtag nach, etwa aus der Notiz, „Tag
öffnen" lässt manuell wählen, „Erledigt" und „Ablehnen" schließen die Anfrage ohne Änderung.
Rollen werden beachtet: Wer nur Hauptmoderation macht, bekommt keinen Nebenplatz. Nach einem
Tausch den Zeitraum erneut nach Nextcloud schreiben.
## Bugs melden
+55 -5
View File
@@ -334,11 +334,26 @@ function fillRequestPersons(date) {
? `${fmtLong(date)}: ${assigned.map((p, i) => `${i === 0 && a.main === p.id ? 'Haupt' : 'Neben'} ${fullName(p)}`).join(', ')}`
: `${fmtLong(date)}: kein Meeting geplant`;
}
/** Was ist am Wunschtag los? Offene Plätze sichtbar machen, damit der Wunsch passt. */
function describeWishDay() {
const f = requestForm.elements;
const info = $('#request-wish-info');
const base = 'Mit Person: ihr tauscht die Tage. Ohne Person: du übernimmst am Wunschtag einen noch offenen Platz, deiner wird neu besetzt.';
const date = f.partnerDate.value;
if (!date) { info.textContent = base; return; }
const a = state.data.assignments[date];
if (!a || a.off || holidayFor(date)) { info.textContent = `${fmtDate(date)}: kein Meeting geplant.`; return; }
const slot = (role, id) => (id ? `${role} ${fullName(personById(id))}` : `${role} offen`);
info.textContent = `${fmtDate(date)}: ${slot('Haupt', a.main)}, ${slot('Neben', a.co)}.${!a.main || !a.co ? ' Ein offener Platz kann ohne Tauschpartner übernommen werden.' : ' Beide Plätze sind besetzt, nenne die Person zum Tauschen.'}`;
}
requestForm.elements.partnerDate.addEventListener('change', describeWishDay);
function openRequestDialog(date) {
const f = requestForm.elements;
requestForm.reset();
f.date.value = date || '';
fillRequestPersons(f.date.value);
describeWishDay();
$('#request-result').hidden = true;
$('#request-submit').disabled = false;
requestDialog.showModal();
@@ -382,7 +397,8 @@ function requestRow(r) {
const partner = r.partnerId ? personById(r.partnerId) : null;
const parts = [];
if (partner) parts.push(`Wunsch: Tausch mit ${fullName(partner)}${r.partnerDate ? ` am ${fmtDate(r.partnerDate)}` : ' (übernimmt den Tag)'}`);
else parts.push('Kein Wunschpartner genannt');
else if (r.partnerDate) parts.push(`Wunsch: offenen Platz am ${fmtDate(r.partnerDate)} übernehmen`);
else parts.push('Kein Wunsch genannt – aus der Notiz nachtragen oder den Tag öffnen');
parts.push(`gestellt am ${new Date(r.createdAt).toLocaleDateString('de-DE')}`);
if (r.status !== 'offen') parts.push(r.status === 'erledigt' ? 'erledigt' : 'abgelehnt');
meta.textContent = parts.join(' · ');
@@ -393,10 +409,20 @@ function requestRow(r) {
actions.className = 'request-actions';
const btn = (label, cls, fn) => { const b = document.createElement('button'); b.type = 'button'; b.className = cls; b.textContent = label; b.addEventListener('click', fn); actions.appendChild(b); };
if (r.status === 'offen') {
if (r.partnerId) btn('Ausführen', 'primary', () => run(async () => {
const res = await api(`api/requests/${r.id}/execute`, { method: 'POST' });
return res;
}, 'Tausch ausgeführt. Nextcloud danach neu schreiben, damit die Termine stimmen.'));
if (r.partnerId || r.partnerDate) btn('Ausführen', 'primary', async () => {
try {
const res = await api(`api/requests/${r.id}/execute`, { method: 'POST' });
await load();
let msg = 'Tausch ausgeführt.';
if (res.filled) msg += res.filled.personId ? ` Lücke am ${fmtDate(res.filled.date)} mit ${fullName(personById(res.filled.personId))} besetzt.` : ` Lücke am ${fmtDate(res.filled.date)} konnte nicht besetzt werden, bitte im Kalender nachsehen.`;
toast(`${msg} Nextcloud danach neu schreiben.`);
} catch (err) { toast(err.message, true); }
});
btn('Wunsch anpassen', '', () => {
const form = li.querySelector('.wish-form');
form.hidden = !form.hidden;
if (!form.hidden) form.querySelector('input[type=date]').focus();
});
btn('Tag öffnen', '', () => { location.hash = '#kalender'; state.month = new Date(r.date + 'T12:00:00'); renderCalendar(); openDayDialog(r.date); });
btn('Erledigt', 'ghost', () => run(() => api(`api/requests/${r.id}`, { method: 'PUT', body: { status: 'erledigt' } }), 'Als erledigt markiert.'));
btn('Ablehnen', 'ghost', () => run(() => api(`api/requests/${r.id}`, { method: 'PUT', body: { status: 'abgelehnt' } }), 'Anfrage abgelehnt.'));
@@ -405,6 +431,30 @@ function requestRow(r) {
btn('Löschen', 'ghost', () => run(() => api(`api/requests/${r.id}`, { method: 'DELETE' })));
}
li.appendChild(actions);
if (r.status === 'offen') {
// Wunsch nachtragen: Person und/oder Wunschtag, z. B. aus der Notiz
const form = document.createElement('form');
form.className = 'wish-form';
form.hidden = true;
const sel = document.createElement('select');
sel.appendChild(new Option('– niemand Bestimmtes –', ''));
for (const p of state.data.persons.filter((x) => x.active !== false && x.id !== r.personId && (x.canMain || x.canCo)).sort((x, y) => fullName(x).localeCompare(fullName(y)))) sel.appendChild(new Option(fullName(p), p.id));
sel.value = r.partnerId || '';
const dateInput = document.createElement('input');
dateInput.type = 'date';
dateInput.value = r.partnerDate || '';
const save = document.createElement('button');
save.type = 'submit';
save.textContent = 'Wunsch speichern';
const l1 = document.createElement('label'); l1.textContent = 'Tauschen mit'; l1.appendChild(sel);
const l2 = document.createElement('label'); l2.textContent = 'Wunschtag'; l2.appendChild(dateInput);
form.append(l1, l2, save);
form.addEventListener('submit', (e) => {
e.preventDefault();
run(() => api(`api/requests/${r.id}`, { method: 'PUT', body: { partnerId: sel.value || null, partnerDate: dateInput.value || null } }), 'Wunsch gespeichert.');
});
li.appendChild(form);
}
return li;
}
function renderRequests() {
+3 -2
View File
@@ -288,8 +288,9 @@
<p id="request-info" class="hint"></p>
<label>Ich bin <select name="personId" required></select></label>
<label>Tag, an dem ich nicht kann <input type="date" name="date" required></label>
<label>Ich würde gern tauschen mit (optional) <select name="partnerId"><option value="">– noch offen –</option></select></label>
<label>Wunschtag für den Tausch (optional) <input type="date" name="partnerDate"></label>
<label>Ich würde gern tauschen mit (optional) <select name="partnerId"><option value="">– niemand Bestimmtes –</option></select></label>
<label>Wunschtag (optional) <input type="date" name="partnerDate"></label>
<p class="hint" id="request-wish-info">Mit Person: ihr tauscht die Tage. Ohne Person: du übernimmst am Wunschtag einen noch offenen Platz, deiner wird neu besetzt.</p>
<label>Notiz (optional) <input name="note" maxlength="300" placeholder="z. B. Arzttermin, bin erst ab 10 Uhr da"></label>
<p id="request-result" class="result" hidden></p>
<div class="form-actions">
+3
View File
@@ -74,6 +74,9 @@ svg.icon { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-w
.request-meta { color: var(--text-2); font-size: 13px; }
.request-note { margin-top: 4px; font-size: 14px; }
.request-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.wish-form { flex-basis: 100%; display: flex; gap: 12px 16px; flex-wrap: wrap; align-items: end; padding: 12px 16px; margin-top: 4px; background: var(--tint-1); border-radius: var(--radius); }
.wish-form label { flex: 1 1 180px; }
.wish-form button { min-height: 40px; }
.viewer .day.requestable { cursor: pointer; }
.viewer .day.requestable:hover:not(.outside) { background: var(--tint-1); }
.topbar-action { margin-left: auto; background: transparent; color: var(--white); border-color: rgba(255, 255, 255, .4); min-height: 34px; padding: 0 12px; font-size: 13px; }
+38 -6
View File
@@ -520,10 +520,27 @@ app.put('/api/requests/:id', (req, res) => {
const st = S();
const r = st.requests.find((x) => x.id === req.params.id);
if (!r) return res.status(404).json({ error: 'Anfrage nicht gefunden.' });
const status = String(req.body?.status || '');
if (!REQUEST_STATUS.includes(status)) return bad(res, 'Unbekannter Status.');
r.status = status;
r.resolvedAt = status === 'offen' ? null : new Date().toISOString();
const b = req.body || {};
if (b.status !== undefined) {
const status = String(b.status || '');
if (!REQUEST_STATUS.includes(status)) return bad(res, 'Unbekannter Status.');
r.status = status;
r.resolvedAt = status === 'offen' ? null : new Date().toISOString();
}
// Bearbeiter dürfen den Wunsch nachtragen, z. B. aus dem Notizfeld
if (b.partnerId !== undefined || b.partnerDate !== undefined) {
const v = validateRequest({
personId: r.personId,
date: r.date,
partnerId: b.partnerId !== undefined ? b.partnerId : r.partnerId,
partnerDate: b.partnerDate !== undefined ? b.partnerDate : r.partnerDate,
note: r.note,
}, st.persons, st.assignments);
if (v.error) return bad(res, v.error);
r.partnerId = v.request.partnerId;
r.partnerDate = v.request.partnerDate;
r.role = v.request.role;
}
store.save();
res.json(r);
});
@@ -534,11 +551,26 @@ app.post('/api/requests/:id/execute', (req, res) => {
if (!r) return res.status(404).json({ error: 'Anfrage nicht gefunden.' });
if (r.status !== 'offen') return bad(res, 'Diese Anfrage ist schon abgeschlossen.');
try {
const { changes } = applySwap(st.assignments, r);
const { changes, vacated } = applySwap(st.assignments, r, personsForScheduling());
let filled = null;
if (vacated) {
// Die frei gewordene Stelle gleich nachbesetzen – fair, ohne die anfragende Person
const existing = { [vacated.date]: { ...st.assignments[vacated.date], fill: true } };
const absences = [...st.absences, { personId: r.personId, from: vacated.date, to: vacated.date }];
const history = { ...st.assignments };
delete history[vacated.date];
const generated = generateSchedule({ dates: [vacated.date], persons: personsForScheduling(), absences, existing, history, blocked: blockedDays(), historyWindowDays: st.settings.historyWindowDays });
const picked = generated[vacated.date]?.[vacated.role] || null;
if (picked) {
st.assignments[vacated.date][vacated.role] = picked;
changes.push({ date: vacated.date, role: vacated.role, from: null, to: picked });
}
filled = { date: vacated.date, role: vacated.role, personId: picked };
}
r.status = 'erledigt';
r.resolvedAt = new Date().toISOString();
store.save();
res.json({ request: r, changes, assignments: st.assignments });
res.json({ request: r, changes, filled, assignments: st.assignments });
} catch (err) {
bad(res, err.message);
}
+49 -7
View File
@@ -11,6 +11,21 @@ export function roleOn(assignment, personId) {
return null;
}
/** Darf die Person diese Rolle übernehmen? */
export function canTake(person, role) {
if (!person || person.active === false) return false;
return role === 'main' ? person.canMain !== false : person.canCo !== false;
}
/** Offener Platz an einem Tag, den die Person übernehmen könnte: 'main', 'co' oder null. */
export function openSlotFor(assignment, person) {
if (!assignment || assignment.off || !person) return null;
if (assignment.main === person.id || assignment.co === person.id) return null;
if (!assignment.main && canTake(person, 'main')) return 'main';
if (!assignment.co && canTake(person, 'co')) return 'co';
return null;
}
/**
* Eingabe einer Anfrage prüfen. persons: [{id, active}], assignments: { date: {main, co, off} }.
* Liefert { error } oder { request } (ohne id/status/createdAt).
@@ -29,37 +44,64 @@ export function validateRequest(body, persons, assignments) {
if (partnerId === personId) return { error: 'Mit dir selbst kannst du nicht tauschen.' };
const partnerDate = b.partnerDate ? String(b.partnerDate) : null;
if (partnerDate && !isValidDate(partnerDate)) return { error: 'Ungültiger Wunschtag.' };
if (partnerDate && !partnerId) return { error: 'Zum Wunschtag bitte auch die Person angeben, mit der du tauschen möchtest.' };
if (partnerDate === date) return { error: 'Der Wunschtag ist derselbe Tag.' };
if (partnerDate && !partnerId) {
// Ohne Tauschpartner heißt der Wunsch: einen noch offenen Platz am Wunschtag übernehmen
const me = active(personId);
const a = assignments[partnerDate];
if (!a || a.off) return { error: 'Am Wunschtag ist kein Meeting geplant.' };
if (a.main === personId || a.co === personId) return { error: 'Am Wunschtag bist du schon eingeteilt.' };
if (!openSlotFor(a, me)) return { error: 'Am Wunschtag ist kein Platz frei, den du übernehmen könntest. Nenne eine Person zum Tauschen.' };
}
const note = String(b.note || '').trim().slice(0, 300);
return { request: { personId, date, role, partnerId, partnerDate, note } };
}
/**
* Tausch ausführen. Liefert { changes: [{date, role, from, to}] } und verändert assignments direkt.
* Tausch ausführen. Liefert { changes: [{date, role, from, to}], vacated } und verändert assignments direkt.
* Fälle:
* - Partner + Wunschtag: über Kreuz – Partner übernimmt den Tag des Anfragenden, dieser den Slot des Partners am Wunschtag.
* - Nur Partner: Partner übernimmt den Slot des Anfragenden.
* - Ohne Partner: nicht automatisch ausführbar.
* - Nur Wunschtag: die Person übernimmt dort einen offenen Platz; ihr alter Platz wird frei (vacated) und
* sollte vom Aufrufer nachbesetzt werden.
* - Weder noch: nicht automatisch ausführbar.
* persons: [{ id, canMain, canCo, active }] für die Rollenprüfung.
*/
export function applySwap(assignments, req) {
export function applySwap(assignments, req, persons = []) {
const find = (id) => persons.find((p) => p.id === id) || null;
const a = assignments[req.date];
const role = roleOn(a, req.personId);
if (!role) throw new Error('Die Person ist an diesem Tag nicht mehr eingeteilt. Bitte den Tag von Hand anpassen.');
if (!req.partnerId) throw new Error('Ohne Wunschpartner bitte den Tag öffnen und selbst jemanden auswählen.');
const me = find(req.personId);
const changes = [];
if (!req.partnerId) {
if (!req.partnerDate) throw new Error('Ohne Wunschpartner oder Wunschtag bitte den Tag öffnen und selbst jemanden auswählen.');
const b = assignments[req.partnerDate];
const slot = openSlotFor(b, me || { id: req.personId, canMain: true, canCo: true });
if (!slot) throw new Error('Am Wunschtag ist kein passender Platz mehr frei. Bitte den Tag von Hand anpassen.');
b[slot] = req.personId;
changes.push({ date: req.partnerDate, role: slot, from: null, to: req.personId });
a[role] = null;
changes.push({ date: req.date, role, from: req.personId, to: null });
return { changes, vacated: { date: req.date, role } };
}
const partner = find(req.partnerId);
const otherRole = role === 'main' ? 'co' : 'main';
if (a[otherRole] === req.partnerId) throw new Error('Der Wunschpartner ist an diesem Tag schon die andere Moderation. Bitte den Tag von Hand anpassen.');
const changes = [];
if (partner && !canTake(partner, role)) throw new Error(`Der Wunschpartner darf keine ${role === 'main' ? 'Haupt' : 'Neben'}moderation übernehmen. Bitte den Tag von Hand anpassen.`);
if (req.partnerDate) {
const b = assignments[req.partnerDate];
const partnerRole = roleOn(b, req.partnerId);
if (!partnerRole) throw new Error('Der Wunschpartner ist am Wunschtag nicht eingeteilt. Bitte den Tag von Hand anpassen.');
const otherB = partnerRole === 'main' ? 'co' : 'main';
if (b[otherB] === req.personId) throw new Error('Die anfragende Person ist am Wunschtag schon die andere Moderation. Bitte den Tag von Hand anpassen.');
if (me && !canTake(me, partnerRole)) throw new Error(`Die anfragende Person darf keine ${partnerRole === 'main' ? 'Haupt' : 'Neben'}moderation übernehmen. Bitte den Tag von Hand anpassen.`);
b[partnerRole] = req.personId;
changes.push({ date: req.partnerDate, role: partnerRole, from: req.partnerId, to: req.personId });
}
a[role] = req.partnerId;
changes.push({ date: req.date, role, from: req.personId, to: req.partnerId });
return { changes };
return { changes, vacated: null };
}
+38 -4
View File
@@ -1,13 +1,15 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { validateRequest, applySwap, roleOn } from '../server/requests.js';
import { validateRequest, applySwap, roleOn, openSlotFor, canTake } from '../server/requests.js';
const persons = [{ id: 'a', active: true }, { id: 'b', active: true }, { id: 'c', active: true }, { id: 'x', active: false }];
const persons = [{ id: 'a', active: true }, { id: 'b', active: true }, { id: 'c', active: true }, { id: 'x', active: false }, { id: 'm', active: true, canMain: true, canCo: false }];
const plan = () => ({
'2026-10-05': { main: 'a', co: 'b', locked: false },
'2026-10-06': { main: 'c', co: 'a', locked: false },
'2026-10-07': { main: 'b', co: 'c', locked: false },
'2026-10-08': { off: true, main: null, co: null },
'2026-10-09': { main: null, co: 'c', locked: false },
'2026-10-12': { main: 'b', co: null, locked: false },
});
test('roleOn', () => {
@@ -26,7 +28,11 @@ test('validateRequest prüft Person, Tag, Einteilung und Partner', () => {
assert.match(validateRequest({ personId: 'a', date: 'gestern' }, persons, p).error, /Ungültiger Tag/);
assert.match(validateRequest({ personId: 'c', date: '2026-10-05' }, persons, p).error, /nicht eingeteilt/);
assert.match(validateRequest({ personId: 'a', date: '2026-10-05', partnerId: 'a' }, persons, p).error, /selbst/);
assert.match(validateRequest({ personId: 'a', date: '2026-10-05', partnerDate: '2026-10-07' }, persons, p).error, /Person angeben/);
assert.match(validateRequest({ personId: 'a', date: '2026-10-05', partnerDate: '2026-10-07' }, persons, p).error, /kein Platz frei/);
assert.match(validateRequest({ personId: 'a', date: '2026-10-05', partnerDate: '2026-10-08' }, persons, p).error, /kein Meeting/);
assert.match(validateRequest({ personId: 'a', date: '2026-10-05', partnerDate: '2026-10-06' }, persons, p).error, /schon eingeteilt/);
const open = validateRequest({ personId: 'a', date: '2026-10-05', partnerDate: '2026-10-09' }, persons, p);
assert.deepEqual(open.request, { personId: 'a', date: '2026-10-05', role: 'main', partnerId: null, partnerDate: '2026-10-09', note: '' });
assert.match(validateRequest({ personId: 'a', date: '2026-10-05', partnerId: 'b', partnerDate: '2026-10-05' }, persons, p).error, /derselbe/);
const ok = validateRequest({ personId: 'a', date: '2026-10-05', partnerId: 'c', partnerDate: '2026-10-07', note: ' Zahnarzt ' }, persons, p);
assert.deepEqual(ok.request, { personId: 'a', date: '2026-10-05', role: 'main', partnerId: 'c', partnerDate: '2026-10-07', note: 'Zahnarzt' });
@@ -48,8 +54,36 @@ test('applySwap: Partner übernimmt den Tag', () => {
assert.deepEqual([p['2026-10-05'].main, p['2026-10-05'].co], ['a', 'c']);
});
test('openSlotFor und canTake beachten Rollen', () => {
const p = plan();
assert.equal(openSlotFor(p['2026-10-09'], { id: 'a', canMain: true, canCo: true }), 'main');
assert.equal(openSlotFor(p['2026-10-12'], { id: 'a', canMain: true, canCo: true }), 'co');
assert.equal(openSlotFor(p['2026-10-12'], { id: 'm', canMain: true, canCo: false }), null, 'Nur-Haupt bekommt keinen Neben-Platz');
assert.equal(openSlotFor(p['2026-10-09'], { id: 'c', canMain: true, canCo: true }), null, 'schon eingeteilt');
assert.equal(openSlotFor(p['2026-10-05'], { id: 'c', canMain: true, canCo: true }), null, 'voll');
assert.ok(canTake({ id: 'm', canMain: true, canCo: false }, 'main') && !canTake({ id: 'm', canMain: true, canCo: false }, 'co'));
assert.ok(!canTake({ id: 'x', active: false }, 'main'));
});
test('applySwap: offenen Platz übernehmen, alter Platz wird frei gemeldet', () => {
const p = plan();
const { changes, vacated } = applySwap(p, { personId: 'a', date: '2026-10-05', partnerId: null, partnerDate: '2026-10-09' }, persons);
assert.equal(p['2026-10-09'].main, 'a');
assert.equal(p['2026-10-05'].main, null);
assert.deepEqual(vacated, { date: '2026-10-05', role: 'main' });
assert.equal(changes.length, 2);
assert.throws(() => applySwap(plan(), { personId: 'a', date: '2026-10-05', partnerId: null, partnerDate: '2026-10-07' }, persons), /kein passender Platz/);
});
test('applySwap prüft Rollen der Beteiligten', () => {
const p = plan();
// m darf keine Nebenmoderation: Übernahme von b's Neben-Platz am 05.10. scheitert
assert.throws(() => applySwap(p, { personId: 'b', date: '2026-10-05', partnerId: 'm', partnerDate: null }, persons), /keine Nebenmoderation/);
assert.equal(p['2026-10-05'].co, 'b', 'nichts verändert');
});
test('applySwap lehnt unmögliche Fälle verständlich ab', () => {
assert.throws(() => applySwap(plan(), { personId: 'a', date: '2026-10-05', partnerId: null }), /Wunschpartner/);
assert.throws(() => applySwap(plan(), { personId: 'a', date: '2026-10-05', partnerId: null }), /Wunschpartner oder Wunschtag/);
assert.throws(() => applySwap(plan(), { personId: 'c', date: '2026-10-05', partnerId: 'b' }), /nicht mehr eingeteilt/);
assert.throws(() => applySwap(plan(), { personId: 'a', date: '2026-10-05', partnerId: 'b' }), /andere Moderation/);
assert.throws(() => applySwap(plan(), { personId: 'a', date: '2026-10-05', partnerId: 'c', partnerDate: '2026-10-08' }), /am Wunschtag nicht eingeteilt/);