diff --git a/README.md b/README.md index bb3b0b1..441f2ee 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/public/app.js b/public/app.js index eb53600..19c6ed3 100644 --- a/public/app.js +++ b/public/app.js @@ -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() { diff --git a/public/index.html b/public/index.html index a156b3e..aa47d94 100644 --- a/public/index.html +++ b/public/index.html @@ -288,8 +288,9 @@

- - + + +

Mit Person: ihr tauscht die Tage. Ohne Person: du übernimmst am Wunschtag einen noch offenen Platz, deiner wird neu besetzt.

diff --git a/public/style.css b/public/style.css index e1e68ab..45019f0 100644 --- a/public/style.css +++ b/public/style.css @@ -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; } diff --git a/server/index.js b/server/index.js index 9956352..c219d28 100644 --- a/server/index.js +++ b/server/index.js @@ -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); } diff --git a/server/requests.js b/server/requests.js index a3db11d..71c5922 100644 --- a/server/requests.js +++ b/server/requests.js @@ -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 }; } diff --git a/test/requests.test.js b/test/requests.test.js index 64673b9..9a13261 100644 --- a/test/requests.test.js +++ b/test/requests.test.js @@ -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/);