Files
Meeting-Mixer/docs/superpowers/plans/2026-04-28-multiple-moderators-per-day.md
T
ferdi2goandClaude Opus 4.7 6fd2cfcf04 docs: implementation plan for multiple moderators per day
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 <noreply@anthropic.com>
2026-04-28 09:31:43 +00:00

1462 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.