# Multiple Moderators Per Day Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Mehrere Moderatoren pro Tag zuweisen, Excel-Export joint Namen mit `" & "`, globale Einstellung `moderatorsPerDay` (1–5). **Architecture:** Flaches Datenmodell — mehrere `{date, employeeId, manual}`-Einträge pro Datum erlaubt. Algorithmus pickt Top-N nach unverändertem Score, Tag wird übersprungen wenn weniger Kandidaten verfügbar als `slotsPerDay`. UI rendert N Dropdowns pro Werktag, Export gruppiert nach Datum und joint Namen alphabetisch. **Tech Stack:** Vanilla JS (ES Modules), Node assert für Tests, XLSX (CDN) für Export, localStorage für Persistenz. **Spec:** `docs/superpowers/specs/2026-04-28-multiple-moderators-per-day-design.md` --- ## Reihenfolge & Strategie Bottom-up: Pure-Function-Schichten zuerst (algorithm, data, excel) mit TDD; dann Persistenz (app.js); dann UI (calendar, history, css). Jeder Task ist ein eigener Commit. Tests laufen via `npm test`. Aktuell gibt es nur `tests/algorithm.test.mjs` — neue Test-Dateien werden in package.json registriert. --- ### Task 1: Algorithmus — Rückgabewert auf `{assignments, skippedDates}` umstellen (Refactor) Reines Refactoring: kein neues Verhalten, aber Rückgabewert ändert sich. Ein einziger Aufrufer (`calendar.js`) wird mit umgestellt, alle bestehenden Tests werden angepasst. **Files:** - Modify: `js/algorithm.js` (`generatePlan`) - Modify: `js/calendar.js` (`genBtn` Click-Handler, Zeilen 92–105) - Modify: `tests/algorithm.test.mjs` (alle Aufrufe von `generatePlan`) - [ ] **Step 1: Tests anpassen — Rückgabe als Objekt mit `.assignments` lesen** In `tests/algorithm.test.mjs` jeden `generatePlan(...)`-Aufruf so umstellen, dass `result.assignments` statt `result` gelesen wird. Konkret: ```javascript // Vorher: const result = generatePlan(['2026-04-01'], [emp('alice'), emp('bob')], [], fixed); assert.equal(result.length, 1, 'One workday → one assignment'); assert.ok(['alice','bob'].includes(result[0].employeeId), 'Assigned to alice or bob'); // Nachher: const result = generatePlan(['2026-04-01'], [emp('alice'), emp('bob')], [], fixed); assert.equal(result.assignments.length, 1, 'One workday → one assignment'); assert.ok(['alice','bob'].includes(result.assignments[0].employeeId), 'Assigned to alice or bob'); ``` Diese Anpassung an **allen 9 Test-Blöcken** vornehmen, die `generatePlan` aufrufen (Zeilen 36, 44, 50, 60, 70, 78, 88, 95, 110, 119 — Suchmuster `result.` bzw. `result[0]`). Auch `assert.equal(result.length, 0, 'No candidate → day skipped')` wird zu `assert.equal(result.assignments.length, 0, 'No candidate → day skipped')` (skippedDates wird in Task 3 separat geprüft). - [ ] **Step 2: Tests laufen lassen — sie müssen jetzt fehlschlagen** ```bash npm test ``` Erwartung: Fehlermeldung wie `Cannot read properties of undefined (reading 'length')` oder ähnlich, weil `generatePlan` noch ein Array zurückgibt und `.assignments` darauf nicht existiert. - [ ] **Step 3: `generatePlan` umbauen** In `js/algorithm.js` die Funktion so ändern (kein neues Verhalten, nur Rückgabewert): ```javascript export function generatePlan(workdays, employees, history, randomFn = Math.random) { const monthAssignments = []; const skippedDates = []; for (const date of workdays) { const candidates = employees.filter(e => isEligible(e, date, monthAssignments, history) ); if (candidates.length === 0) continue; const scored = candidates .map(e => ({ employee: e, score: scoreCandidate(e, date, history, randomFn) })) .sort((a, b) => b.score - a.score); monthAssignments.push({ date, employeeId: scored[0].employee.id, manual: false }); } return { assignments: monthAssignments, skippedDates }; } ``` - [ ] **Step 4: `calendar.js` an neue Rückgabe anpassen** In `js/calendar.js`, im `genBtn`-Click-Handler (Zeile ~101) die Zeile ```javascript currentPlan = generatePlan(workdays, state.employees, historyWithoutMonth); addHistoryEntries(state, currentPlan); ``` ersetzen durch ```javascript const result = generatePlan(workdays, state.employees, historyWithoutMonth); currentPlan = result.assignments; addHistoryEntries(state, currentPlan); ``` - [ ] **Step 5: Tests laufen lassen — alle grün** ```bash npm test ``` Erwartung: `All algorithm tests passed.` - [ ] **Step 6: Commit** ```bash git add js/algorithm.js js/calendar.js tests/algorithm.test.mjs git commit -m "refactor: generatePlan returns {assignments, skippedDates}" ``` --- ### Task 2: Algorithmus — `slotsPerDay`-Parameter und Top-N-Auswahl Neuer Parameter `slotsPerDay` (Default 1, Bestandsverhalten unverändert). Pro Tag werden N Personen gewählt — alle aus derselben Kandidatenliste, daher automatisch verschieden. **Files:** - Modify: `js/algorithm.js` (`generatePlan`) - Modify: `tests/algorithm.test.mjs` (neue Tests am Ende, vor `console.log`) - [ ] **Step 1: Failing Tests schreiben** Am Ende von `tests/algorithm.test.mjs`, **vor** der `console.log('All algorithm tests passed.');`-Zeile, einfügen: ```javascript // slotsPerDay=2 → 2 different employees per day { const employees = [emp('alice'), emp('bob'), emp('clara')]; const result = generatePlan(['2026-04-01'], employees, [], fixed, 2); assert.equal(result.assignments.length, 2, 'slotsPerDay=2 → 2 entries'); const ids = result.assignments.map(a => a.employeeId); assert.notEqual(ids[0], ids[1], 'two different employees on same day'); } // slotsPerDay=2 across multiple days, maxPerMonth still respected { const days = ['2026-04-01','2026-04-02','2026-04-03']; const employees = [ emp('alice', { maxPerMonth: 1 }), emp('bob'), emp('clara'), emp('dave') ]; const result = generatePlan(days, employees, [], fixed, 2); const aliceCount = result.assignments.filter(a => a.employeeId === 'alice').length; assert.ok(aliceCount <= 1, 'maxPerMonth=1 respected with slotsPerDay=2'); } // slotsPerDay=2: per day, no employee picked twice { const employees = [emp('alice'), emp('bob'), emp('clara')]; const result = generatePlan(['2026-04-01','2026-04-02'], employees, [], fixed, 2); for (const date of ['2026-04-01','2026-04-02']) { const ids = result.assignments.filter(a => a.date === date).map(a => a.employeeId); assert.equal(new Set(ids).size, ids.length, 'no employee picked twice on ' + date); } } ``` - [ ] **Step 2: Tests laufen lassen — sie müssen fehlschlagen** ```bash npm test ``` Erwartung: Erster neuer Test schlägt fehl, da `slotsPerDay`-Parameter ignoriert wird, nur 1 Eintrag pro Tag erzeugt wird. - [ ] **Step 3: `generatePlan` um `slotsPerDay` erweitern** In `js/algorithm.js`: ```javascript export function generatePlan(workdays, employees, history, randomFn = Math.random, slotsPerDay = 1) { const monthAssignments = []; const skippedDates = []; for (const date of workdays) { const candidates = employees.filter(e => isEligible(e, date, monthAssignments, history) ); if (candidates.length === 0) continue; const scored = candidates .map(e => ({ employee: e, score: scoreCandidate(e, date, history, randomFn) })) .sort((a, b) => b.score - a.score); const take = Math.min(slotsPerDay, scored.length); for (let i = 0; i < take; i++) { monthAssignments.push({ date, employeeId: scored[i].employee.id, manual: false }); } } return { assignments: monthAssignments, skippedDates }; } ``` Der Skip-Fall (`candidates < slotsPerDay`) kommt erst in Task 3. Hier nehmen wir noch ungeschützt `Math.min(slotsPerDay, scored.length)` — heisst: wenn nur 1 Kandidat verfügbar ist und `slotsPerDay=2`, wird trotzdem 1 zugewiesen. Das wird in Task 3 strenger. - [ ] **Step 4: Tests laufen lassen — alle grün** ```bash npm test ``` - [ ] **Step 5: Commit** ```bash git add js/algorithm.js tests/algorithm.test.mjs git commit -m "feat(algo): slotsPerDay parameter for top-N selection" ``` --- ### Task 3: Algorithmus — Skip-Day wenn weniger Kandidaten als Slots Wenn `candidates.length < slotsPerDay`, Tag in `skippedDates` aufnehmen, **keine** partielle Zuweisung. **Files:** - Modify: `js/algorithm.js` (`generatePlan`) - Modify: `tests/algorithm.test.mjs` - [ ] **Step 1: Failing Tests schreiben** Am Ende von `tests/algorithm.test.mjs`, vor der `console.log`-Zeile, einfügen: ```javascript // slotsPerDay=2 with only 1 eligible employee → day skipped, listed in skippedDates { const employees = [emp('alice')]; const result = generatePlan(['2026-04-01'], employees, [], fixed, 2); assert.equal(result.assignments.length, 0, 'no assignments when candidates < slotsPerDay'); assert.deepEqual(result.skippedDates, ['2026-04-01'], 'date listed in skippedDates'); } // slotsPerDay=1 with 0 candidates → still skipped, listed { // 2026-04-01 = Wednesday (3), only employee blocks Wednesday const employees = [emp('alice', { neverDays: [3] })]; const result = generatePlan(['2026-04-01'], employees, [], fixed, 1); assert.equal(result.assignments.length, 0, 'no assignments when 0 candidates'); assert.deepEqual(result.skippedDates, ['2026-04-01'], 'date listed in skippedDates'); } // Mixed: some days fully assigned, some skipped { // 2026-04-01 = Wed, 2026-04-02 = Thu // alice can only Wed, bob can always work const employees = [ emp('alice', { onlyDays: [3] }), emp('bob') ]; const result = generatePlan(['2026-04-01','2026-04-02'], employees, [], fixed, 2); // 2026-04-01: 2 candidates → 2 entries assert.equal(result.assignments.filter(a => a.date === '2026-04-01').length, 2, 'Wed has 2 entries'); // 2026-04-02: only bob → skipped assert.equal(result.assignments.filter(a => a.date === '2026-04-02').length, 0, 'Thu skipped'); assert.deepEqual(result.skippedDates, ['2026-04-02'], 'Thu in skippedDates'); } ``` - [ ] **Step 2: Tests laufen lassen — sie müssen fehlschlagen** ```bash npm test ``` Erwartung: Erster neuer Test bekommt `assignments.length === 1` (statt 0) und leeres `skippedDates`. - [ ] **Step 3: Skip-Logik einbauen** In `js/algorithm.js`, die innere Tagesschleife so anpassen: ```javascript for (const date of workdays) { const candidates = employees.filter(e => isEligible(e, date, monthAssignments, history) ); if (candidates.length < slotsPerDay) { skippedDates.push(date); continue; } const scored = candidates .map(e => ({ employee: e, score: scoreCandidate(e, date, history, randomFn) })) .sort((a, b) => b.score - a.score); for (let i = 0; i < slotsPerDay; i++) { monthAssignments.push({ date, employeeId: scored[i].employee.id, manual: false }); } } ``` (Die `Math.min`-Sicherung von Task 2 entfällt, weil wir jetzt frühzeitig prüfen.) - [ ] **Step 4: Tests laufen lassen — alle grün** ```bash npm test ``` - [ ] **Step 5: Commit** ```bash git add js/algorithm.js tests/algorithm.test.mjs git commit -m "feat(algo): skip day when candidates < slotsPerDay" ``` --- ### Task 4: data.js — `addHistoryEntries` dedupliziert nach `(date, employeeId)` Bisher entfernte `addHistoryEntries` alle History-Einträge mit den enthaltenen Daten. Künftig nur diejenigen mit derselben `(date, employeeId)`-Kombination — damit mehrere Personen pro Tag koexistieren. **Files:** - Create: `tests/data.test.mjs` - Modify: `js/data.js` (`addHistoryEntries`) - Modify: `package.json` (`scripts.test`) - [ ] **Step 1: package.json — Test-Skript erweitern** `package.json` `scripts.test` ändern auf: ```json "test": "node tests/algorithm.test.mjs && node tests/data.test.mjs" ``` - [ ] **Step 2: Test-Datei `tests/data.test.mjs` anlegen** Datei mit Inhalt: ```javascript import assert from 'node:assert/strict'; import { addHistoryEntries, removeHistoryEntry } from '../js/data.js'; function makeState(history = []) { return { employees: [], calendar: { holidays: [], companyClosures: [] }, history: [...history] }; } // addHistoryEntries: two entries with same date but different employees both kept { const state = makeState(); addHistoryEntries(state, [ { date: '2026-04-01', employeeId: 'alice', manual: false }, { date: '2026-04-01', employeeId: 'bob', manual: false } ]); assert.equal(state.history.length, 2, 'two distinct entries kept on same date'); const ids = state.history.map(h => h.employeeId).sort(); assert.deepEqual(ids, ['alice', 'bob'], 'both employees present'); } // addHistoryEntries: same (date, employeeId) replaced, others on same date untouched { const state = makeState([ { date: '2026-04-01', employeeId: 'alice', manual: false }, { date: '2026-04-01', employeeId: 'bob', manual: false } ]); addHistoryEntries(state, [ { date: '2026-04-01', employeeId: 'alice', manual: true } ]); assert.equal(state.history.length, 2, 'still two entries'); const alice = state.history.find(h => h.employeeId === 'alice'); assert.equal(alice.manual, true, 'alice replaced with manual=true'); const bob = state.history.find(h => h.employeeId === 'bob'); assert.equal(bob.manual, false, 'bob untouched'); } // addHistoryEntries: empty input → no change { const state = makeState([{ date: '2026-04-01', employeeId: 'alice', manual: false }]); addHistoryEntries(state, []); assert.equal(state.history.length, 1, 'empty entries → no change'); } console.log('All data tests passed.'); ``` - [ ] **Step 3: Tests laufen lassen — sie müssen fehlschlagen** ```bash npm test ``` Erwartung: Im ersten Datentest `state.history.length === 1` (statt 2), weil das aktuelle `addHistoryEntries` alle Einträge des Datums entfernt und dann nur die neuen einfügt — wenn die zwei neuen aber dasselbe Datum haben, würde das eigentlich beide hinzufügen. Tatsächlich: das aktuelle Verhalten fügt beide hinzu (`state.history.push(...entries)`). Der Fehler kommt erst beim zweiten Test (Mix aus alter+neuer Einträge), weil dort der bestehende `bob`-Eintrag zusammen mit `alice` gelöscht wird. - [ ] **Step 4: `addHistoryEntries` umbauen** In `js/data.js`: ```javascript export function addHistoryEntries(state, entries) { if (!entries?.length) return; const keys = new Set(entries.map(e => e.date + '|' + e.employeeId)); state.history = state.history.filter(h => !keys.has(h.date + '|' + h.employeeId)); state.history.push(...entries); state.history.sort((a, b) => a.date.localeCompare(b.date)); } ``` - [ ] **Step 5: Tests laufen lassen — alle grün** ```bash npm test ``` - [ ] **Step 6: Commit** ```bash git add js/data.js tests/data.test.mjs package.json git commit -m "feat(data): dedupe history by (date, employeeId)" ``` --- ### Task 5: data.js — `removeHistoryEntry` mit optionalem `employeeId` Erweiterte Signatur: `removeHistoryEntry(state, isoDate, employeeId?)`. Ohne `employeeId` Verhalten unverändert (alle Einträge des Tages); mit `employeeId` nur der eine Eintrag. **Files:** - Modify: `js/data.js` (`removeHistoryEntry`) - Modify: `tests/data.test.mjs` - [ ] **Step 1: Failing Tests am Ende von `tests/data.test.mjs` einfügen** Vor der `console.log`-Zeile: ```javascript // removeHistoryEntry without employeeId: removes all entries on that date { const state = makeState([ { date: '2026-04-01', employeeId: 'alice', manual: false }, { date: '2026-04-01', employeeId: 'bob', manual: false }, { date: '2026-04-02', employeeId: 'alice', manual: false } ]); removeHistoryEntry(state, '2026-04-01'); assert.equal(state.history.length, 1, 'only 2026-04-02 alice remains'); assert.equal(state.history[0].date, '2026-04-02', 'remaining entry is 2026-04-02'); } // removeHistoryEntry with employeeId: removes only that one entry { const state = makeState([ { date: '2026-04-01', employeeId: 'alice', manual: false }, { date: '2026-04-01', employeeId: 'bob', manual: false } ]); removeHistoryEntry(state, '2026-04-01', 'alice'); assert.equal(state.history.length, 1, 'one entry remains'); assert.equal(state.history[0].employeeId, 'bob', 'bob still there'); } // removeHistoryEntry with employeeId that does not exist: no-op { const state = makeState([ { date: '2026-04-01', employeeId: 'alice', manual: false } ]); removeHistoryEntry(state, '2026-04-01', 'nonexistent'); assert.equal(state.history.length, 1, 'no change when employeeId not found'); } ``` - [ ] **Step 2: Tests laufen lassen — sie müssen fehlschlagen** ```bash npm test ``` Erwartung: Der zweite Test schlägt fehl (`state.history.length === 0`), weil aktuell der gesamte Tag entfernt wird, unabhängig vom 3. Argument. - [ ] **Step 3: Implementierung anpassen** In `js/data.js`: ```javascript export function removeHistoryEntry(state, isoDate, employeeId) { if (employeeId === undefined) { state.history = state.history.filter(h => h.date !== isoDate); } else { state.history = state.history.filter(h => !(h.date === isoDate && h.employeeId === employeeId)); } } ``` - [ ] **Step 4: Tests laufen lassen — alle grün** ```bash npm test ``` - [ ] **Step 5: Commit** ```bash git add js/data.js tests/data.test.mjs git commit -m "feat(data): removeHistoryEntry supports optional employeeId" ``` --- ### Task 6: data.js — `settings`-Feld in `loadFromJSON` / `saveToJSON` Settings-Objekt beim Speichern/Laden mitführen. Default `{moderatorsPerDay: 1}` wenn fehlend. **Files:** - Modify: `js/data.js` (`loadFromJSON`, `saveToJSON`) - Modify: `tests/data.test.mjs` - [ ] **Step 1: Import-Zeile am Anfang von `tests/data.test.mjs` erweitern** Die existierende Zeile ```javascript import { addHistoryEntries, removeHistoryEntry } from '../js/data.js'; ``` ergänzen zu ```javascript import { addHistoryEntries, removeHistoryEntry, loadFromJSON, saveToJSON } from '../js/data.js'; ``` - [ ] **Step 2: Failing Tests am Ende der Datei einfügen** Am Ende von `tests/data.test.mjs`, vor der `console.log`-Zeile: ```javascript // loadFromJSON: missing settings → default {moderatorsPerDay: 1} { const json = JSON.stringify({ employees: [], calendar: { holidays: [], companyClosures: [] }, history: [] }); const loaded = loadFromJSON(json); assert.deepEqual(loaded.settings, { moderatorsPerDay: 1 }, 'default settings when missing'); } // loadFromJSON: present settings preserved, moderatorsPerDay clamped to [1,5] { const json = JSON.stringify({ employees: [], calendar: { holidays: [], companyClosures: [] }, history: [], settings: { moderatorsPerDay: 3 } }); const loaded = loadFromJSON(json); assert.equal(loaded.settings.moderatorsPerDay, 3, 'preserved value'); } // loadFromJSON: invalid moderatorsPerDay (string, out of range) → fallback to 1 { const tooHigh = loadFromJSON(JSON.stringify({ employees: [], calendar: { holidays: [], companyClosures: [] }, history: [], settings: { moderatorsPerDay: 99 } })); assert.equal(tooHigh.settings.moderatorsPerDay, 1, 'out of range falls back to 1'); const wrongType = loadFromJSON(JSON.stringify({ employees: [], calendar: { holidays: [], companyClosures: [] }, history: [], settings: { moderatorsPerDay: 'two' } })); assert.equal(wrongType.settings.moderatorsPerDay, 1, 'wrong type falls back to 1'); } // saveToJSON: settings are serialized { const state = { employees: [], calendar: { holidays: [], companyClosures: [] }, history: [], settings: { moderatorsPerDay: 2 } }; const json = saveToJSON(state); const parsed = JSON.parse(json); assert.deepEqual(parsed.settings, { moderatorsPerDay: 2 }, 'settings round-trip'); } ``` - [ ] **Step 3: Tests laufen lassen — sie müssen fehlschlagen** ```bash npm test ``` Erwartung: `loaded.settings === undefined`, da `loadFromJSON` das Feld noch nicht kennt. - [ ] **Step 4: `loadFromJSON` und `saveToJSON` anpassen** In `js/data.js`: ```javascript function parseSettings(raw) { const n = raw?.moderatorsPerDay; const valid = Number.isInteger(n) && n >= 1 && n <= 5; return { moderatorsPerDay: valid ? n : 1 }; } export function loadFromJSON(json) { let parsed; try { parsed = JSON.parse(json); } catch (e) { throw new Error('Ungültige JSON-Datei: ' + e.message); } if (typeof parsed !== 'object' || parsed === null) { throw new Error('Ungültiges Dateiformat: Objekt erwartet'); } return { employees: Array.isArray(parsed.employees) ? parsed.employees : [], calendar: (parsed.calendar && typeof parsed.calendar === 'object') ? { holidays: Array.isArray(parsed.calendar.holidays) ? parsed.calendar.holidays : [], companyClosures: Array.isArray(parsed.calendar.companyClosures) ? parsed.calendar.companyClosures : [] } : { holidays: [], companyClosures: [] }, history: Array.isArray(parsed.history) ? parsed.history : [], settings: parseSettings(parsed.settings) }; } export function saveToJSON(state) { return JSON.stringify({ employees: state.employees, calendar: state.calendar, history: state.history, settings: state.settings }, null, 2); } ``` - [ ] **Step 5: Tests laufen lassen — alle grün** ```bash npm test ``` - [ ] **Step 6: Commit** ```bash git add js/data.js tests/data.test.mjs git commit -m "feat(data): settings.moderatorsPerDay in load/save JSON" ``` --- ### Task 7: app.js — `state.settings` + LocalStorage-Persistenz `state.settings.moderatorsPerDay` zum globalen State hinzufügen. Beim App-Start mit Default `1` initialisieren, falls fehlend. `saveState` schreibt `settings` mit. Auch im Daten-Tab-Loader (`history.js`) wird `state.settings` aus dem geladenen JSON übernommen. **Files:** - Modify: `js/app.js` (state init, load, saveState) - Modify: `js/history.js` (`fileInput`-Change-Handler) - [ ] **Step 1: `app.js` — state.settings + Load + Save erweitern** In `js/app.js` (komplette Datei wie folgt, anschließend nur die geänderten Stellen markiert): ```javascript import { renderEmployees } from './employees.js'; import { renderCalendar } from './calendar.js'; import { renderDataTab } from './history.js'; const STORAGE_KEY = 'morning-planner-state'; export const state = { employees: [], calendar: { holidays: [], companyClosures: [] }, history: [], settings: { moderatorsPerDay: 1 } }; try { const saved = localStorage.getItem(STORAGE_KEY); if (saved) { const parsed = JSON.parse(saved); if (Array.isArray(parsed.employees)) state.employees = parsed.employees; if (parsed.calendar) state.calendar = parsed.calendar; if (Array.isArray(parsed.history)) state.history = parsed.history; if (parsed.settings && Number.isInteger(parsed.settings.moderatorsPerDay) && parsed.settings.moderatorsPerDay >= 1 && parsed.settings.moderatorsPerDay <= 5) { state.settings = { moderatorsPerDay: parsed.settings.moderatorsPerDay }; } } } catch { // Corrupt storage — start fresh } export function saveState() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ employees: state.employees, calendar: state.calendar, history: state.history, settings: state.settings })); } catch { // Storage full or unavailable — silently ignore } } document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', () => { const tab = btn.dataset.tab; document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(s => s.classList.add('hidden')); btn.classList.add('active'); document.getElementById('tab-' + tab).classList.remove('hidden'); if (tab === 'planning') renderCalendar(); if (tab === 'data') renderDataTab(); }); }); renderEmployees(); ``` - [ ] **Step 2: `history.js` — `state.settings` beim JSON-Load übernehmen** In `js/history.js`, im `fileInput`-Change-Handler (Zeile ~74), Block ```javascript state.employees = loaded.employees; state.calendar = loaded.calendar; state.history = loaded.history; ``` ersetzen durch ```javascript state.employees = loaded.employees; state.calendar = loaded.calendar; state.history = loaded.history; state.settings = loaded.settings; ``` - [ ] **Step 3: Manuelle Verifikation — Browser** ```bash npm start ``` Browser auf `http://localhost:3001` öffnen. Devtools → Console: ```javascript JSON.parse(localStorage.getItem('morning-planner-state')).settings ``` Erwartung: `{ moderatorsPerDay: 1 }` (oder das, was bei dir gespeichert wurde — bei Erstinstallation 1). Im Daten-Tab eine bestehende JSON-Datei laden (oder eine neue mit `settings`-Feld). `state.settings.moderatorsPerDay` muss übernommen werden. - [ ] **Step 4: npm test laufen lassen — alle bestehenden Tests müssen weiterhin grün sein** ```bash npm test ``` - [ ] **Step 5: Commit** ```bash git add js/app.js js/history.js git commit -m "feat: state.settings.moderatorsPerDay with localStorage persistence" ``` --- ### Task 8: excel.js — Pure Helper für Spalten-Aufbau, Group-by-Date, Join mit `" & "` Reine Hilfsfunktion `buildScheduleColumns(assignments, employees)` extrahieren — gruppiert nach Datum, sortiert Namen alphabetisch innerhalb des Tages, joint mit `" & "`. Testbar in Node ohne XLSX. **Files:** - Create: `tests/excel.test.mjs` - Modify: `js/excel.js` (`exportSchedule` ruft Helper, neuer Export) - Modify: `package.json` (`scripts.test`) - [ ] **Step 1: package.json — Test-Script erweitern** ```json "test": "node tests/algorithm.test.mjs && node tests/data.test.mjs && node tests/excel.test.mjs" ``` - [ ] **Step 2: Test-Datei `tests/excel.test.mjs` anlegen** ```javascript import assert from 'node:assert/strict'; import { buildScheduleColumns } from '../js/excel.js'; const employees = [ { id: 'a1', name: 'Anna Müller' }, { id: 'b1', name: 'Bernd Schulz' }, { id: 'c1', name: 'Carla Weber' } ]; // Single employee per day { const assignments = [ { date: '2026-04-01', employeeId: 'a1' }, { date: '2026-04-02', employeeId: 'b1' } ]; const cols = buildScheduleColumns(assignments, employees); assert.deepEqual(cols.names, ['Anna Müller', 'Bernd Schulz'], 'names per single-entry day'); assert.deepEqual(cols.dates, ['01.04.2026', '02.04.2026'], 'dates formatted DD.MM.YYYY'); } // Two employees per day → joined with " & ", alphabetical { const assignments = [ { date: '2026-04-01', employeeId: 'b1' }, // Bernd inserted first { date: '2026-04-01', employeeId: 'a1' } // Anna inserted second ]; const cols = buildScheduleColumns(assignments, employees); assert.equal(cols.names.length, 1, 'one column per date'); assert.equal(cols.names[0], 'Anna Müller & Bernd Schulz', 'names alphabetical, joined with " & "'); assert.equal(cols.dates[0], '01.04.2026', 'one date entry'); } // Three employees per day { const assignments = [ { date: '2026-04-01', employeeId: 'c1' }, { date: '2026-04-01', employeeId: 'a1' }, { date: '2026-04-01', employeeId: 'b1' } ]; const cols = buildScheduleColumns(assignments, employees); assert.equal(cols.names[0], 'Anna Müller & Bernd Schulz & Carla Weber', 'three names joined alphabetical'); } // Multiple dates with multiple employees, dates ascending { const assignments = [ { date: '2026-04-02', employeeId: 'a1' }, { date: '2026-04-01', employeeId: 'b1' }, { date: '2026-04-01', employeeId: 'a1' } ]; const cols = buildScheduleColumns(assignments, employees); assert.deepEqual(cols.dates, ['01.04.2026', '02.04.2026'], 'dates sorted ascending'); assert.deepEqual(cols.names, ['Anna Müller & Bernd Schulz', 'Anna Müller'], 'names per column in date order'); } // Unknown employeeId: empty string slot, sorted with other names { const assignments = [ { date: '2026-04-01', employeeId: 'a1' }, { date: '2026-04-01', employeeId: 'unknown' } ]; const cols = buildScheduleColumns(assignments, employees); // Empty-string sorts before 'Anna Müller' assert.equal(cols.names[0], ' & Anna Müller', 'unknown employee → empty string in join'); } console.log('All excel tests passed.'); ``` - [ ] **Step 3: Tests laufen lassen — sie müssen fehlschlagen** ```bash npm test ``` Erwartung: `SyntaxError` oder `buildScheduleColumns is not a function` weil der Helper noch nicht exportiert ist. - [ ] **Step 4: `excel.js` umbauen** `js/excel.js` komplett: ```javascript // Reads employee names from first column of xlsx (skips header row 0) export function readEmployeeNames(arrayBuffer) { const wb = XLSX.read(arrayBuffer, { type: 'array' }); const sheet = wb.Sheets[wb.SheetNames[0]]; const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 }); return rows.slice(1) .map(row => String(row[0] ?? '').trim()) .filter(name => name.length > 0); } // Pure helper: returns { names: [...], dates: [...] } columns for a schedule. // Multiple employees per date are joined alphabetically with " & ". export function buildScheduleColumns(assignments, employees) { const nameById = Object.fromEntries(employees.map(e => [e.id, e.name])); const byDate = new Map(); for (const a of assignments) { if (!byDate.has(a.date)) byDate.set(a.date, []); byDate.get(a.date).push(nameById[a.employeeId] ?? ''); } const sortedDates = [...byDate.keys()].sort(); const names = sortedDates.map(d => byDate.get(d).slice().sort((x, y) => x.localeCompare(y, 'de')).join(' & ') ); const dates = sortedDates.map(d => { const [y, m, day] = d.split('-'); return day + '.' + m + '.' + y; }); return { names, dates }; } // Exports schedule as xlsx blob // assignments: [{ date: "2026-04-01", employeeId }] — multiple entries per date allowed // employees: [{ id, name }] export function exportSchedule(assignments, employees) { const { names, dates } = buildScheduleColumns(assignments, employees); const ws = XLSX.utils.aoa_to_sheet([names, dates]); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'Moderationsplan'); const buf = XLSX.write(wb, { type: 'array', bookType: 'xlsx' }); return new Blob([buf], { type: 'application/octet-stream' }); } export function downloadBlob(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } ``` - [ ] **Step 5: Tests laufen lassen — alle grün** ```bash npm test ``` Erwartung: alle 3 Test-Dateien zeigen `... passed`. - [ ] **Step 6: Commit** ```bash git add js/excel.js tests/excel.test.mjs package.json git commit -m "feat(excel): group entries by date, join names with ' & '" ``` --- ### Task 9: calendar.js — „Pro Tag"-Selector in der Controls-Row Neuer Selector zwischen Jahr-Selector und „Plan generieren"-Button. Bei Änderung wird `state.settings.moderatorsPerDay` aktualisiert, persistiert und das Grid neu gerendert. **Files:** - Modify: `js/calendar.js` (`renderCalendar`) - [ ] **Step 1: Selector im Controls-Row einfügen** In `js/calendar.js`, in der Funktion `renderCalendar`, **nach** den `yearSel`-Zeilen (nach Zeile 48) und **vor** dem `genBtn`-Block einfügen: ```javascript const slotsLabel = document.createElement('label'); slotsLabel.textContent = 'Pro Tag:'; const slotsSel = document.createElement('select'); for (let n = 1; n <= 5; n++) { const opt = document.createElement('option'); opt.value = String(n); opt.textContent = String(n); if (n === state.settings.moderatorsPerDay) opt.selected = true; slotsSel.appendChild(opt); } ``` Den `controls.append(...)`-Aufruf so erweitern, dass die neuen Elemente zwischen `yearSel` und `genBtn` einsortiert werden: ```javascript controls.append(monthLabel, monthSel, yearLabel, yearSel, slotsLabel, slotsSel, genBtn, exportBtn); ``` - [ ] **Step 2: Change-Handler für `slotsSel` einbauen** Direkt nach den bestehenden `yearSel.addEventListener`-Zeilen (etwa Zeile 86–90), einfügen: ```javascript slotsSel.addEventListener('change', () => { state.settings.moderatorsPerDay = parseInt(slotsSel.value, 10); saveState(); renderGrid(wrapper, exportBtn); }); ``` - [ ] **Step 3: Manuelle Verifikation** ```bash npm start ``` Browser → Reiter „Monatsplanung". Erwartung: - Selector „Pro Tag: [1|2|3|4|5]" sichtbar zwischen Jahr und „Plan generieren" - Wert 1 ausgewählt (Default) bzw. der zuvor gespeicherte Wert - Bei Änderung des Selektors: Devtools-Console zeigt nach Reload bzw. via `JSON.parse(localStorage.getItem('morning-planner-state')).settings` den neuen Wert Das Grid bleibt visuell unverändert — die N-Slots-Logik kommt erst in Task 10. - [ ] **Step 4: Commit** ```bash git add js/calendar.js git commit -m "feat(calendar): add 'Pro Tag' moderator-count selector" ``` --- ### Task 10: calendar.js — N Dropdowns pro Werktag, Slot-Disable bei Doppelauswahl Pro Werktag-Zelle werden `state.settings.moderatorsPerDay` Dropdowns gerendert. Vorhandene History-Einträge werden alphabetisch nach Mitarbeiternamen auf die ersten Slots verteilt. Personen, die in einem anderen Slot desselben Tages bereits gewählt sind, sind in den anderen Slots disabled. **Files:** - Modify: `js/calendar.js` (`renderGrid`) - [ ] **Step 1: Bestehenden Dropdown-Block durch N-Slot-Loop ersetzen** In `js/calendar.js`, in `renderGrid`, der Block `else { /* Assignee dropdown */ ... }` (Zeilen ~193–243) wird komplett ersetzt. Ich ersetze auch das vorgelagerte Lookup `planByDate` durch eines, das eine Liste pro Datum liefert. Erste Änderung: oben in `renderGrid` (Zeile 125): ```javascript const planByDate = Object.fromEntries(currentPlan.map(a => [a.date, a.employeeId])); ``` ersetzen durch: ```javascript const empNameById = Object.fromEntries(state.employees.map(e => [e.id, e.name])); const entriesByDate = {}; for (const a of currentPlan) { if (!entriesByDate[a.date]) entriesByDate[a.date] = []; entriesByDate[a.date].push(a); } // Sort each date's entries alphabetically by employee name (stable slot order) for (const date of Object.keys(entriesByDate)) { entriesByDate[date].sort((x, y) => (empNameById[x.employeeId] ?? '').localeCompare(empNameById[y.employeeId] ?? '', 'de') ); } ``` Zweite Änderung: in der Werktagszelle (innerhalb `if (!isWeekend) { ... else { ... } }`), den Dropdown-Block (von `// Assignee dropdown` bis vor `// FT / BU mark buttons`) komplett ersetzen durch: ```javascript // N slots per day const slotCount = state.settings.moderatorsPerDay; const dayEntries = entriesByDate[iso] ?? []; // already alphabetically sorted const slotValues = []; // employeeId per slot index, '' if empty for (let s = 0; s < slotCount; s++) { slotValues.push(dayEntries[s]?.employeeId ?? ''); } for (let slotIdx = 0; slotIdx < slotCount; slotIdx++) { const sel = document.createElement('select'); sel.dataset.slot = String(slotIdx); const emptyOpt = document.createElement('option'); emptyOpt.value = ''; emptyOpt.textContent = '— Leer —'; sel.appendChild(emptyOpt); for (const emp of state.employees) { const opt = document.createElement('option'); opt.value = emp.id; opt.textContent = emp.name; // Disable if already picked in a different slot for this day const pickedElsewhere = slotValues.some((id, i) => i !== slotIdx && id === emp.id); if (pickedElsewhere) opt.disabled = true; if (emp.id === slotValues[slotIdx]) opt.selected = true; sel.appendChild(opt); } sel.addEventListener('change', () => { const newId = sel.value; const oldId = slotValues[slotIdx]; if (oldId) { removeHistoryEntry(state, iso, oldId); currentPlan = currentPlan.filter(a => !(a.date === iso && a.employeeId === oldId)); } if (newId) { const entry = { date: iso, employeeId: newId, manual: true }; addHistoryEntries(state, [entry]); currentPlan.push(entry); } saveState(); renderGrid(wrapper, exportBtn); }); cell.appendChild(sel); } ``` (Die FT/BU-Button-Zeile darunter bleibt unverändert.) - [ ] **Step 2: Manuelle Verifikation** ```bash npm start ``` Browser → Reiter „Monatsplanung": 1. „Pro Tag" auf 1 → eine Dropdown pro Werktag, Verhalten wie bisher. 2. „Pro Tag" auf 2 → zwei Dropdowns übereinander pro Werktag. 3. In Slot 1 eine Person wählen → in Slot 2 ist diese Person disabled. 4. In Slot 2 eine andere Person wählen → beide Personen sind im jeweils anderen Slot disabled. 5. Slot 1 auf „— Leer —" → bisheriger Slot-1-Eintrag verschwindet aus History; Slot 2 bleibt. 6. „Pro Tag" auf 1 reduzieren → Slot 2 verschwindet visuell, aber `state.history` enthält den Eintrag noch (in Devtools: `JSON.parse(localStorage.getItem('morning-planner-state')).history`). 7. „Pro Tag" auf 2 erhöhen → Slot 2 erscheint mit dem zuvor versteckten Eintrag. - [ ] **Step 3: npm test grün halten** ```bash npm test ``` Bestehende Algorithmus-Tests müssen unverändert grün bleiben (calendar.js hat keine Tests, aber wir wollen sicherstellen, dass keine importierten Module gebrochen sind). - [ ] **Step 4: Commit** ```bash git add js/calendar.js git commit -m "feat(calendar): N moderator slots per day with disable-on-duplicate" ``` --- ### Task 11: calendar.js — Plan-Generierung mit `slotsPerDay` + Skipped-Dates-Warnbox Der „Plan generieren"-Button übergibt `state.settings.moderatorsPerDay` an `generatePlan` und zeigt nach erfolgreicher Generierung — falls `skippedDates` nicht leer ist — eine schließbare Warnbox über dem Kalender-Grid. **Files:** - Modify: `js/calendar.js` (`renderCalendar`, neuer Helper für Warnbox) - [ ] **Step 1: Container für Warnbox in `renderCalendar` anlegen** In `js/calendar.js`, in `renderCalendar`, **vor** der Zeile `const wrapper = document.createElement('div');` (etwa Zeile 72) einfügen: ```javascript const warnBox = document.createElement('div'); warnBox.id = 'skipped-warning'; warnBox.style.display = 'none'; container.appendChild(warnBox); ``` - [ ] **Step 2: `genBtn`-Click-Handler anpassen** Den bestehenden Handler (Zeilen ~92–105) so ändern: ```javascript genBtn.addEventListener('click', () => { if (state.employees.length === 0) { alert('Bitte zuerst Mitarbeiter hinzufügen.'); return; } const workdays = getWorkdays(currentYear, currentMonth, state.calendar.holidays, state.calendar.companyClosures); const prefix = currentYear + '-' + String(currentMonth).padStart(2, '0'); const historyWithoutMonth = state.history.filter(h => !h.date.startsWith(prefix)); // Also remove month entries from state.history before adding new ones (full month reset) state.history = historyWithoutMonth; const result = generatePlan(workdays, state.employees, historyWithoutMonth, Math.random, state.settings.moderatorsPerDay); currentPlan = result.assignments; addHistoryEntries(state, currentPlan); saveState(); renderSkippedWarning(warnBox, result.skippedDates); renderGrid(wrapper, exportBtn); }); ``` (Achtung: `state.history = historyWithoutMonth` ist neu — vorher hat `addHistoryEntries` per Datum dedupliziert, aber mit der neuen `(date, employeeId)`-Dedup-Regel würden alte Einträge an Daten ohne neuen Eintrag stehen bleiben. Wir resetten den ganzen Monat.) - [ ] **Step 3: `renderSkippedWarning`-Helper am Ende der Datei einfügen** Nach der `renderGrid`-Funktion in `js/calendar.js`: ```javascript function renderSkippedWarning(box, skippedDates) { box.replaceChildren(); if (!skippedDates || skippedDates.length === 0) { box.style.display = 'none'; return; } box.style.display = 'flex'; box.className = 'skipped-warning'; const formatted = skippedDates.map(d => { const [y, m, day] = d.split('-'); return day + '.' + m + '.'; }).join(', '); const text = document.createElement('span'); text.textContent = skippedDates.length === 1 ? '1 Tag konnte nicht vollständig besetzt werden: ' + formatted : skippedDates.length + ' Tage konnten nicht vollständig besetzt werden: ' + formatted; box.appendChild(text); const closeBtn = document.createElement('button'); closeBtn.type = 'button'; closeBtn.textContent = '✕'; closeBtn.addEventListener('click', () => { box.replaceChildren(); box.style.display = 'none'; }); box.appendChild(closeBtn); } ``` - [ ] **Step 4: Manuelle Verifikation** ```bash npm start ``` Vorbereitung in der App: 1. Mitarbeiter-Tab: drei Mitarbeiter anlegen (z. B. Anna, Bernd, Carla). Bei Carla `onlyDays: [1]` setzen (nur Montag). 2. Planungs-Tab: „Pro Tag" auf 2 setzen. 3. „Plan generieren" klicken. Erwartung: Über dem Grid erscheint die Warnbox „… Tage konnten nicht vollständig besetzt werden: 14.04., 21.04., …" (alle Nicht-Montage, an denen Carla nicht zur Verfügung steht und nur Anna+Bernd übrig blieben — `2 ≥ slotsPerDay` ist eigentlich erfüllt … warte). Korrektur des Erwartungs-Setups: bei 3 Mitarbeitern und `slotsPerDay=2` würde an allen Tagen genug Kapazität sein. Besseres Test-Szenario: nur **2 Mitarbeiter**, einen davon mit `vacations: [{ from: '2026-04-15', to: '2026-04-17' }]`. Dann werden die drei Werktage 15./16./17.04. übersprungen. Erwartung: Warnbox zeigt „3 Tage konnten nicht vollständig besetzt werden: 15.04., 16.04., 17.04.". ✕-Button schließt sie. „Plan generieren" erneut → Box erscheint wieder. - [ ] **Step 5: Commit** ```bash git add js/calendar.js git commit -m "feat(calendar): pass slotsPerDay to generator, show skipped-dates warning" ``` --- ### Task 12: history.js — Sortierung Datum desc + Name asc, ✕ löscht einzelnen Eintrag Verlauf-Tabelle: jeder Eintrag in eigener Zeile (bereits so), aber Sortierung muss sekundär nach Name sortieren, damit Mehrfach-Personen-Tage stabil zusammenstehen. Der ✕-Button übergibt jetzt `employeeId` an `removeHistoryEntry`, damit nicht der ganze Tag gelöscht wird. **Files:** - Modify: `js/history.js` (`renderHistoryTable`) - [ ] **Step 1: Sortierung anpassen** In `js/history.js`, `renderHistoryTable`, Zeile ~105: ```javascript const sorted = [...state.history].sort((a, b) => b.date.localeCompare(a.date)); ``` ersetzen durch: ```javascript const sorted = [...state.history].sort((a, b) => { const dateCmp = b.date.localeCompare(a.date); if (dateCmp !== 0) return dateCmp; const nameA = nameById[a.employeeId] ?? ''; const nameB = nameById[b.employeeId] ?? ''; return nameA.localeCompare(nameB, 'de'); }); ``` - [ ] **Step 2: Delete-Button auf Einzeleintrag umstellen** In demselben Block (Zeile ~138): ```javascript delBtn.addEventListener('click', () => { removeHistoryEntry(state, h.date); saveState(); renderHistoryTable(); }); ``` ersetzen durch: ```javascript delBtn.addEventListener('click', () => { removeHistoryEntry(state, h.date, h.employeeId); saveState(); renderHistoryTable(); }); ``` - [ ] **Step 3: Manuelle Verifikation** ```bash npm start ``` 1. Im Planungs-Tab „Pro Tag" auf 2 setzen, „Plan generieren". Mindestens ein Tag muss 2 Personen haben. 2. In den Daten-Tab wechseln. Erwartung: Mehrfach-Personen-Tage stehen direkt untereinander, alphabetisch nach Name. 3. Am Multi-Personen-Tag den ✕-Button bei einer Person klicken. Erwartung: nur diese eine Zeile verschwindet, die andere Person desselben Tages bleibt. 4. Devtools-Check: `state.history` (über Module-Import oder via `JSON.parse(localStorage...)`) enthält noch den anderen Eintrag. - [ ] **Step 4: Commit** ```bash git add js/history.js git commit -m "feat(history): sort by date desc + name asc, delete single entry" ``` --- ### Task 13: style.css — Slot-Spacing und Warnbox-Styling Etwas vertikalen Abstand zwischen den Dropdowns einer Tageszelle und ein dezentes Styling für die `.skipped-warning`-Box. **Files:** - Modify: `style.css` - [ ] **Step 1: Aktuelle Calendar-Styles inspizieren** ```bash grep -n "cal-day" style.css ``` Erwartung: vorhandene Klassen für `.cal-day`, `.cal-day select`, `.cal-btn-row`. Den Block, der `.cal-day select` betrifft, identifizieren — typischerweise `width: 100%`. Den exakten Selektor nutzen, um den nächsten Edit gezielt zu machen. - [ ] **Step 2: Slot-Spacing zur bestehenden Calendar-Sektion hinzufügen** Am **Ende** von `style.css` anhängen: ```css /* Multiple moderator slots per day */ .cal-day select + select { margin-top: 4px; } /* Skipped-dates warning box */ .skipped-warning { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 12px; margin: 8px 0 12px 0; background: #fef3c7; border: 1px solid #fcd34d; border-radius: 6px; color: #78350f; font-size: 13px; } .skipped-warning button { background: transparent; border: none; color: #78350f; cursor: pointer; font-size: 14px; padding: 0 4px; } .skipped-warning button:hover { color: #1f2937; } ``` - [ ] **Step 3: Manuelle Verifikation** ```bash npm start ``` 1. „Pro Tag" auf 2 → in jeder Werktagszelle sind die beiden Dropdowns mit kleinem Abstand erkennbar getrennt. 2. „Plan generieren" mit zu wenigen Mitarbeitern an manchen Tagen → Warnbox erscheint in dezentem Gelb mit Border, ✕-Button ist sichtbar und klickbar. 3. Visuell stimmig mit dem restlichen App-Look (kein Bruch in Schrift/Spacing). - [ ] **Step 4: Commit** ```bash git add style.css git commit -m "style: spacing for moderator slots and skipped-dates warning box" ``` --- ### Task 14: Integration & End-to-End-Verifikation Vollständiger Durchlauf der Funktionalität: Anlegen, Generieren, Editieren, Exportieren, Reload, JSON-Roundtrip. **Files:** - Keine Änderungen — nur Verifikation - [ ] **Step 1: Vollständiger npm test** ```bash npm test ``` Erwartung: Alle drei Test-Dateien grün. - [ ] **Step 2: Browser End-to-End** ```bash npm start ``` Schritte (Devtools offen): 1. Reset: `localStorage.clear()` in der Console, dann Reload. 2. Mitarbeiter-Tab: 4 Mitarbeiter anlegen mit vollen Namen, z. B. „Anna Müller", „Bernd Schulz", „Carla Weber", „Dirk Klein". 3. Planungs-Tab: „Pro Tag" auf 2. 4. „Plan generieren". Erwartung: - Jede Werktagszelle hat 2 Dropdowns mit je einem Eintrag. - Die zwei Personen pro Tag sind verschieden, alphabetisch sortiert. - Keine Warnbox (4 Mitarbeiter, alle uneingeschränkt → genug Kapazität). 5. In einer Tageszelle Slot 2 manuell auf „— Leer —". Slot 1 bleibt. 6. „Als Excel exportieren" → Datei herunterladen, in Excel/LibreOffice öffnen. - Erste Zeile: Namen, Tage mit 2 Personen als „Anna Müller & Bernd Schulz" o. ä., Tag mit nur 1 Person (Schritt 5) als „Bernd Schulz" allein, Spalten chronologisch. - Zweite Zeile: Datum jeweils als `01.04.2026`, `02.04.2026`, … 7. Reiter Daten → „Daten speichern (.json)". Datei öffnen, prüfen: `settings.moderatorsPerDay === 2`, `history` enthält 2 Einträge pro Tag. 8. Reload der Seite. Erwartung: alles wiederhergestellt, „Pro Tag" weiterhin auf 2, Tageszellen unverändert besetzt. 9. „Pro Tag" auf 1 reduzieren → in jeder Zelle nur noch ein Dropdown, das alphabetisch erste Person sichtbar. Excel-Export zeigt aber weiterhin beide Personen pro Tag. 10. „Pro Tag" zurück auf 2. Slot 2 zeigt wieder die zweite Person. 11. Verlauf-Tab → ✕ bei einer einzelnen Person eines 2-Personen-Tages. Nur diese eine Zeile verschwindet. Wenn alle Schritte korrekt: fertig. - [ ] **Step 3: Optional — README-Eintrag** Falls README aktuell explizit „eine Person pro Tag" oder Ähnliches behauptet: ```bash grep -n "moderator\|Moderator\|Person" README.md ``` Falls ein Statement angepasst werden sollte, in der „Features"-Liste ergänzen: ``` - **Mehrere Moderatoren pro Tag** – Globale Einstellung 1–5 Personen pro Tag, Excel-Export joint Namen mit „ & " ``` (Nur committen, falls sich tatsächlich etwas ändert.) - [ ] **Step 4: Final-Commit (falls README angepasst)** ```bash git add README.md git commit -m "docs: mention multi-moderator support in README" ``` --- ## Self-Review Notizen **Spec-Coverage-Check:** - Datenmodell (`state.settings`, dedup-Regel, removeHistoryEntry-Erweiterung): Tasks 4–7 ✓ - Algorithmus (`slotsPerDay`, Top-N, Skip-Day, neue Rückgabe): Tasks 1–3 ✓ - UI Setting-Selector: Task 9 ✓ - UI N Slots + Disable-Logik: Task 10 ✓ - UI Skipped-Warning-Box: Task 11 ✓ - Excel mit `" & "`-Join: Task 8 ✓ - Verlauf-Tab Sortierung + Single-Entry-Delete: Task 12 ✓ - Persistenz (localStorage + JSON): Tasks 6–7 ✓ - Tests: Tasks 1–6, 8 ✓ - Style-Anpassung: Task 13 ✓ - E2E-Verifikation: Task 14 ✓ **Type-Konsistenz:** - `generatePlan` gibt durchgehend `{assignments, skippedDates}` zurück (Tasks 1–3, verwendet in 11). - `addHistoryEntries(state, entries)` Signatur unverändert (Task 4). - `removeHistoryEntry(state, isoDate, employeeId?)` (Task 5, verwendet in 10 und 12). - `state.settings = { moderatorsPerDay: N }` (Tasks 6–7, verwendet in 9, 10, 11). - `buildScheduleColumns(assignments, employees) → {names, dates}` (Task 8). **Placeholder-Scan:** Kein TBD/TODO in den Tasks; alle Code-Blöcke vollständig.