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

48 KiB
Raw Blame History

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:

// 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: generatePlan umbauen

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.js an 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, 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:

// 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: generatePlan um slotsPerDay erweitern

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.mjs anlegen

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: addHistoryEntries umbauen

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.mjs einfü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.mjs erweitern

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: loadFromJSON und saveToJSON anpassen

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.settings beim 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 (exportSchedule ruft 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.mjs anlegen
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.js umbauen

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 slotsSel einbauen

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')).settings den 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":

  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
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 renderCalendar anlegen

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:

  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
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
  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
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
  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
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):

  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:

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:

  • 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.