From 6fd2cfcf040f6d9eae94193c531c4d4da656fb15 Mon Sep 17 00:00:00 2001 From: Ferdinand Urban Date: Tue, 28 Apr 2026 09:31:43 +0000 Subject: [PATCH] docs: implementation plan for multiple moderators per day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 14 Tasks, bottom-up: algorithmus → data.js → app.js (persistence) → UI (calendar/history/css). TDD wo möglich (algorithm/data/excel mit Tests), manuelle Verifikation für UI. Co-Authored-By: Claude Opus 4.7 --- .../2026-04-28-multiple-moderators-per-day.md | 1461 +++++++++++++++++ 1 file changed, 1461 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-28-multiple-moderators-per-day.md diff --git a/docs/superpowers/plans/2026-04-28-multiple-moderators-per-day.md b/docs/superpowers/plans/2026-04-28-multiple-moderators-per-day.md new file mode 100644 index 0000000..4ae064d --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-multiple-moderators-per-day.md @@ -0,0 +1,1461 @@ +# 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.