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>
48 KiB
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(genBtnClick-Handler, Zeilen 92–105) -
Modify:
tests/algorithm.test.mjs(alle Aufrufe vongeneratePlan) -
Step 1: Tests anpassen — Rückgabe als Objekt mit
.assignmentslesen
In tests/algorithm.test.mjs jeden generatePlan(...)-Aufruf so umstellen, dass result.assignments statt result gelesen wird. Konkret:
// 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
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:
generatePlanumbauen
In js/algorithm.js die Funktion so ändern (kein neues Verhalten, nur Rückgabewert):
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.jsan neue Rückgabe anpassen
In js/calendar.js, im genBtn-Click-Handler (Zeile ~101) die Zeile
currentPlan = generatePlan(workdays, state.employees, historyWithoutMonth);
addHistoryEntries(state, currentPlan);
ersetzen durch
const result = generatePlan(workdays, state.employees, historyWithoutMonth);
currentPlan = result.assignments;
addHistoryEntries(state, currentPlan);
- Step 5: Tests laufen lassen — alle grün
npm test
Erwartung: All algorithm tests passed.
- Step 6: Commit
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, vorconsole.log) -
Step 1: Failing Tests schreiben
Am Ende von tests/algorithm.test.mjs, vor der console.log('All algorithm tests passed.');-Zeile, einfügen:
// 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
npm test
Erwartung: Erster neuer Test schlägt fehl, da slotsPerDay-Parameter ignoriert wird, nur 1 Eintrag pro Tag erzeugt wird.
- Step 3:
generatePlanumslotsPerDayerweitern
In js/algorithm.js:
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
npm test
- Step 5: Commit
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:
// 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
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:
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
npm test
- Step 5: Commit
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:
"test": "node tests/algorithm.test.mjs && node tests/data.test.mjs"
- Step 2: Test-Datei
tests/data.test.mjsanlegen
Datei mit Inhalt:
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
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:
addHistoryEntriesumbauen
In js/data.js:
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
npm test
- Step 6: Commit
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.mjseinfügen
Vor der console.log-Zeile:
// 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
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:
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
npm test
- Step 5: Commit
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.mjserweitern
Die existierende Zeile
import { addHistoryEntries, removeHistoryEntry } from '../js/data.js';
ergänzen zu
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:
// 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
npm test
Erwartung: loaded.settings === undefined, da loadFromJSON das Feld noch nicht kennt.
- Step 4:
loadFromJSONundsaveToJSONanpassen
In js/data.js:
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
npm test
- Step 6: Commit
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):
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.settingsbeim JSON-Load übernehmen
In js/history.js, im fileInput-Change-Handler (Zeile ~74), Block
state.employees = loaded.employees;
state.calendar = loaded.calendar;
state.history = loaded.history;
ersetzen durch
state.employees = loaded.employees;
state.calendar = loaded.calendar;
state.history = loaded.history;
state.settings = loaded.settings;
- Step 3: Manuelle Verifikation — Browser
npm start
Browser auf http://localhost:3001 öffnen. Devtools → Console:
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
npm test
- Step 5: Commit
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(exportScheduleruft Helper, neuer Export) -
Modify:
package.json(scripts.test) -
Step 1: package.json — Test-Script erweitern
"test": "node tests/algorithm.test.mjs && node tests/data.test.mjs && node tests/excel.test.mjs"
- Step 2: Test-Datei
tests/excel.test.mjsanlegen
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
npm test
Erwartung: SyntaxError oder buildScheduleColumns is not a function weil der Helper noch nicht exportiert ist.
- Step 4:
excel.jsumbauen
js/excel.js komplett:
// 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
npm test
Erwartung: alle 3 Test-Dateien zeigen ... passed.
- Step 6: Commit
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:
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:
controls.append(monthLabel, monthSel, yearLabel, yearSel, slotsLabel, slotsSel, genBtn, exportBtn);
- Step 2: Change-Handler für
slotsSeleinbauen
Direkt nach den bestehenden yearSel.addEventListener-Zeilen (etwa Zeile 86–90), einfügen:
slotsSel.addEventListener('change', () => {
state.settings.moderatorsPerDay = parseInt(slotsSel.value, 10);
saveState();
renderGrid(wrapper, exportBtn);
});
- Step 3: Manuelle Verifikation
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')).settingsden neuen Wert
Das Grid bleibt visuell unverändert — die N-Slots-Logik kommt erst in Task 10.
- Step 4: Commit
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):
const planByDate = Object.fromEntries(currentPlan.map(a => [a.date, a.employeeId]));
ersetzen durch:
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:
// 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
npm start
Browser → Reiter „Monatsplanung":
- „Pro Tag" auf 1 → eine Dropdown pro Werktag, Verhalten wie bisher.
- „Pro Tag" auf 2 → zwei Dropdowns übereinander pro Werktag.
- In Slot 1 eine Person wählen → in Slot 2 ist diese Person disabled.
- In Slot 2 eine andere Person wählen → beide Personen sind im jeweils anderen Slot disabled.
- Slot 1 auf „— Leer —" → bisheriger Slot-1-Eintrag verschwindet aus History; Slot 2 bleibt.
- „Pro Tag" auf 1 reduzieren → Slot 2 verschwindet visuell, aber
state.historyenthält den Eintrag noch (in Devtools:JSON.parse(localStorage.getItem('morning-planner-state')).history). - „Pro Tag" auf 2 erhöhen → Slot 2 erscheint mit dem zuvor versteckten Eintrag.
- Step 3: npm test grün halten
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
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
renderCalendaranlegen
In js/calendar.js, in renderCalendar, vor der Zeile const wrapper = document.createElement('div'); (etwa Zeile 72) einfügen:
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:
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:
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
npm start
Vorbereitung in der App:
- Mitarbeiter-Tab: drei Mitarbeiter anlegen (z. B. Anna, Bernd, Carla). Bei Carla
onlyDays: [1]setzen (nur Montag). - Planungs-Tab: „Pro Tag" auf 2 setzen.
- „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
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:
const sorted = [...state.history].sort((a, b) => b.date.localeCompare(a.date));
ersetzen durch:
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):
delBtn.addEventListener('click', () => {
removeHistoryEntry(state, h.date);
saveState();
renderHistoryTable();
});
ersetzen durch:
delBtn.addEventListener('click', () => {
removeHistoryEntry(state, h.date, h.employeeId);
saveState();
renderHistoryTable();
});
- Step 3: Manuelle Verifikation
npm start
- Im Planungs-Tab „Pro Tag" auf 2 setzen, „Plan generieren". Mindestens ein Tag muss 2 Personen haben.
- In den Daten-Tab wechseln. Erwartung: Mehrfach-Personen-Tage stehen direkt untereinander, alphabetisch nach Name.
- Am Multi-Personen-Tag den ✕-Button bei einer Person klicken. Erwartung: nur diese eine Zeile verschwindet, die andere Person desselben Tages bleibt.
- Devtools-Check:
state.history(über Module-Import oder viaJSON.parse(localStorage...)) enthält noch den anderen Eintrag.
- Step 4: Commit
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
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:
/* 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
npm start
- „Pro Tag" auf 2 → in jeder Werktagszelle sind die beiden Dropdowns mit kleinem Abstand erkennbar getrennt.
- „Plan generieren" mit zu wenigen Mitarbeitern an manchen Tagen → Warnbox erscheint in dezentem Gelb mit Border, ✕-Button ist sichtbar und klickbar.
- Visuell stimmig mit dem restlichen App-Look (kein Bruch in Schrift/Spacing).
- Step 4: Commit
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
npm test
Erwartung: Alle drei Test-Dateien grün.
- Step 2: Browser End-to-End
npm start
Schritte (Devtools offen):
- Reset:
localStorage.clear()in der Console, dann Reload. - Mitarbeiter-Tab: 4 Mitarbeiter anlegen mit vollen Namen, z. B. „Anna Müller", „Bernd Schulz", „Carla Weber", „Dirk Klein".
- Planungs-Tab: „Pro Tag" auf 2.
- „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).
- In einer Tageszelle Slot 2 manuell auf „— Leer —". Slot 1 bleibt.
- „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, …
- Reiter Daten → „Daten speichern (.json)". Datei öffnen, prüfen:
settings.moderatorsPerDay === 2,historyenthält 2 Einträge pro Tag. - Reload der Seite. Erwartung: alles wiederhergestellt, „Pro Tag" weiterhin auf 2, Tageszellen unverändert besetzt.
- „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.
- „Pro Tag" zurück auf 2. Slot 2 zeigt wieder die zweite Person.
- 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:
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)
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:
generatePlangibt 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.