diff --git a/docs/superpowers/specs/2026-04-28-multiple-moderators-per-day-design.md b/docs/superpowers/specs/2026-04-28-multiple-moderators-per-day-design.md new file mode 100644 index 0000000..429a0f9 --- /dev/null +++ b/docs/superpowers/specs/2026-04-28-multiple-moderators-per-day-design.md @@ -0,0 +1,192 @@ +# Mehrere Moderatoren pro Tag + +## Ziel + +Die Morning Meeting Planner App soll pro Tag **N Moderatoren** zuweisen können (statt bislang fix 1). N ist eine globale Einstellung mit gültigem Bereich 1–5, Default **1** (sowohl bei Neuinstallation als auch wenn das Feld in vorhandenen Daten fehlt — Abwärtskompatibilität, Bestandsverhalten unverändert). Der Nutzer hebt den Wert in der UI bewusst auf 2 (oder höher) an. + +Sichtbarstes Ergebnis: Excel-Export joint mehrere Personen eines Tages mit `" & "` zu `"Vorname1 Nachname1 & Vorname2 Nachname2"`. + +## Entscheidungen aus dem Brainstorming + +| # | Entscheidung | +|---|---| +| 1 | Globale Einstellung `moderatorsPerDay` (1–5), nicht pro Tag konfigurierbar | +| 2 | Wenn weniger Kandidaten verfügbar als `moderatorsPerDay` → Tag bleibt **leer**, Warnung im UI | +| 3 | Algorithmus: **Top-N nach Einzelscore**, keine Pairing-Vielfalt | +| 4 | UI: **N dynamische Dropdowns** pro Tag, dieselbe Person nicht zweimal wählbar | +| 5 | Setting-Control: **oben im Planungs-Reiter**, neben Monat/Jahr | +| 6 | Datenmodell: **flach**, mehrere Einträge pro Datum erlaubt | + +## Datenmodell + +### Neues Feld in `state` + +```js +state.settings = { moderatorsPerDay: 2 } // gültig: 1, 2, 3, 4, 5 +``` + +- App-Start ohne `settings` im LocalStorage → Default `{ moderatorsPerDay: 1 }` (Bestandsnutzer behalten Verhalten). +- `loadFromJSON` ohne `settings` → ebenfalls `{ moderatorsPerDay: 1 }`. +- Wert wird in `localStorage` und in `saveToJSON` mitpersistiert. + +### History-Einträge + +Form **unverändert**: `{ date: "YYYY-MM-DD", employeeId: "...", manual: bool }`. + +Neu: **mehrere Einträge mit demselben `date` sind erlaubt**. Eindeutigkeit liegt jetzt auf `(date, employeeId)`. + +### Geänderte Dedup-Logik in `data.js` + +`addHistoryEntries(state, entries)`: +- Vorher: löschte alle History-Einträge mit Datum aus `entries.map(e => e.date)`. +- Nachher: löscht nur Einträge mit demselben `(date, employeeId)`-Tupel wie ein eingefügter Eintrag. + +`removeHistoryEntry(state, isoDate, employeeId?)`: +- Ohne `employeeId`: löscht **alle** Einträge des Tages (Robustheits-Fallback). +- Mit `employeeId`: löscht nur den einen Eintrag — Standardfall für Slot-Editierungen und Verlauf-✕. + +Plan-Generierung verwendet weiterhin den bestehenden Mechanismus (Prefix-Filter entfernt den ganzen Monat aus History, danach werden die neu generierten Einträge per `addHistoryEntries` hinzugefügt). Eine separate `replaceDayEntries`-Funktion ist nicht nötig. + +## Algorithmus + +### Signatur-Änderung in `algorithm.js` + +```js +generatePlan(workdays, employees, history, randomFn = Math.random, slotsPerDay = 1) + → { assignments, skippedDates } +``` + +- Alter Rückgabewert war `Array`. Neu: Objekt `{ assignments, skippedDates }`. Es gibt nur einen Aufrufer (`calendar.js`), der wird hart umgestellt. + +### Pro Tag + +1. Eligible Kandidaten ermitteln (wie bisher: `neverDays`, `onlyDays`, `vacations`, `maxPerMonth`, `minGapDays`). +2. Zusätzlicher Filter: Mitarbeiter, die heute schon in `monthAssignments` stehen, sind nicht erneut wählbar (verhindert Doppelung im selben Tag). +3. Wenn `candidates.length < slotsPerDay`: Tag überspringen, `iso` an `skippedDates` anhängen. +4. Sonst: alle scoren (`scoreCandidate` unverändert), nach Score absteigend sortieren, Top-`slotsPerDay` nehmen. +5. Für jede gewählte Person ein Eintrag `{ date, employeeId, manual: false }` an `monthAssignments` hängen — **bevor** der nächste Tag verarbeitet wird (damit `maxPerMonth`/`minGapDays` korrekt zählen). + +### Scoring + +Unverändert: weniger bisherige Einsätze → höherer Score, `lowPriorityDays` → −5, Jitter ±0.49. + +## UI: Planungs-Reiter + +### Neue Setting-Steuerung + +Neben den bestehenden Monat/Jahr-Selects, in derselben `controls`-Row: + +``` +Pro Tag: [Select 1 | 2 | 3 | 4 | 5] +``` + +- Initialwert: `state.settings.moderatorsPerDay`. +- Bei Änderung: `state.settings.moderatorsPerDay = N`, `saveState()`, `renderGrid()`. +- Existierende Einträge im Monat bleiben unverändert; nur die Zahl der sichtbaren Slots pro Zelle ändert sich. + +### Pro Werktag-Zelle (Wochentag, kein Feiertag/Betriebsurlaub) + +- **N Dropdowns** untereinander rendern (`N = state.settings.moderatorsPerDay`). +- Jeder Dropdown: `— Leer —` als erste Option, dann alle aktiven Mitarbeiter. +- Vorhandene History-Einträge des Tages **alphabetisch nach Mitarbeiternamen sortieren**, dann auf die ersten Slots verteilen. Restliche Slots zeigen `— Leer —`. +- Im Slot bereits gewählte Personen werden in den anderen Slots desselben Tages **disabled** (verhindert Doppel-Auswahl). +- **Mehr Einträge als Slots** (Setting wurde reduziert): überzählige Einträge bleiben in `state.history` gespeichert, werden im Kalender nicht gezeigt, aber im Excel-Export weiterhin berücksichtigt. + +### Slot-Änderungen + +- Wert auf einen Mitarbeiter setzen: + - Falls Slot vorher leer: `addHistoryEntries(state, [{ date, employeeId, manual: true }])`. + - Falls Slot vorher belegt war: erst `removeHistoryEntry(state, date, prevEmployeeId)`, dann `addHistoryEntries(...)`. +- Wert auf `— Leer —`: `removeHistoryEntry(state, date, prevEmployeeId)`. +- `currentPlan` synchron mitführen, `saveState()` nach jeder Änderung. + +### Plan-Generierung + +- Aktuelle Monatseinträge aus History per Prefix-Filter entfernen (`state.history = state.history.filter(h => !h.date.startsWith(prefix))`). +- `generatePlan(..., state.settings.moderatorsPerDay)` aufrufen. +- `result.assignments` per `addHistoryEntries` in History schreiben. +- `result.skippedDates`: nicht-blockierende **Inline-Warn-Box** über dem Kalender: + > „2 Tage konnten nicht vollständig besetzt werden: 14.04., 22.04." + - Box hat ein dezentes Warn-Styling (gelb/orange), schließbar via ✕. + - Bei `skippedDates.length === 0` keine Box rendern. + +## Excel-Export + +### Format + +Unverändert: 2-Zeilen-Layout (Namen oben, Datum unten), eine Spalte pro Tag mit ≥ 1 Eintrag. + +**Zellinhalt der Namen-Zeile:** +- 1 Person: `"Vorname Nachname"` +- 2 Personen: `"Vorname1 Nachname1 & Vorname2 Nachname2"` +- N ≥ 3: `"A & B & C"` (mit `" & "` joinen) +- **Reihenfolge:** alphabetisch nach Mitarbeitername — deterministisch, unabhängig von Insertion-Order. + +### Verhalten bei Diskrepanz Setting ↔ History + +Der Export folgt **der History**, nicht dem aktuellen `moderatorsPerDay`. Hat ein Tag 2 Einträge gespeichert und das Setting steht aktuell auf 1, exportiert die App trotzdem beide Personen (joined mit `" & "`). Begründung: User hat das so eingetragen, niemand soll stillschweigend „verschwinden". + +### Implementierung in `excel.js` `exportSchedule` + +- Eingabe-Form unverändert: `assignments: [{ date, employeeId }]` — aber jetzt mehrere Einträge mit demselben `date` möglich. +- Vor dem Spalten-Aufbau: nach `date` gruppieren, je Gruppe Namen alphabetisch sortieren, mit `" & "` joinen. +- Spaltenreihenfolge: Datum aufsteigend. + +## Verlauf-Tab + +`history.js` zeigt eine Tabelle `Datum | Mitarbeiter | Typ | ✕`. + +Mit mehreren Personen pro Tag: +- **Eine Zeile pro Eintrag** (nicht pro Tag). Bei 2 Personen am 14.04. erscheinen 2 Zeilen. +- Sortierung: Datum **absteigend**, sekundär Name **aufsteigend** (stabile Tagesgruppen). +- ✕-Button: löscht **nur diese eine Person/diesen einen Eintrag** via `removeHistoryEntry(state, date, employeeId)`. + +Beispiel-Render: +``` +14.04.2026 | Anna Müller | Auto | ✕ +14.04.2026 | Bernd Schulz | Auto | ✕ +13.04.2026 | Carla Weber | Manuell | ✕ +``` + +## Persistenz / Migration + +- `app.js`: `STORAGE_KEY` bleibt; gespeichertes Objekt wird um `settings` erweitert. +- Beim Laden ohne `settings`: Default `{ moderatorsPerDay: 1 }` setzen — keine Verhaltensänderung für bestehende Nutzer, bis sie im UI umstellen. +- `data.js` `saveToJSON` / `loadFromJSON` analog erweitern. +- Bestehende JSON-Dateien (Pre-Feature) laden ohne Migration — sie haben einfach 1 Eintrag pro Datum, was im neuen Modell ein gültiger Spezialfall ist. + +## Tests + +`tests/algorithm.test.mjs`: + +**Bestehende Tests** rufen `generatePlan` ohne `slotsPerDay` auf → Default 1 → müssen aber auf neue Rückgabe-Form `{ assignments, skippedDates }` umgestellt werden. + +**Neue Tests:** +1. `slotsPerDay=2`: 2 verschiedene Personen pro Tag, Top-2 nach Score. +2. Eine Person darf am selben Tag **nicht doppelt** vorkommen. +3. `skippedDates` enthält Tage mit `< slotsPerDay` Kandidaten (z. B. 1 Mitarbeiter mit `onlyDays=[1]` an einem Mo, `slotsPerDay=2`). +4. `maxPerMonth` zählt korrekt, wenn eine Person an mehreren Tagen 2-Slot-Generierung trifft (nicht öfter als `maxPerMonth` zugewiesen). +5. `minGapDays`-Test bleibt grün auch bei `slotsPerDay=2`. + +## Edge Cases + +| Situation | Verhalten | +|---|---| +| Setting 2→1 reduziert, Tag hat 2 History-Einträge | History bleibt unverändert; Kalender zeigt nur Slot 1 (alphabetisch erste); Excel-Export zeigt weiterhin beide | +| Setting 1→2 erhöht, alle Tage haben 1 Eintrag | Slot 2 zeigt `— Leer —`; „Plan generieren" überschreibt den ganzen Monat (Reset-Semantik) | +| Mitarbeiter gelöscht, der in History steht | Anzeige `(unbekannt)` wie heute; Eintrag bleibt erhalten, bis manuell entfernt | +| Tag hat 0 Einträge | Wie heute: nicht in Excel-Export, alle Slots leer im Kalender | +| User wählt in Slot 2 dieselbe Person wie in Slot 1 | Verhindert: Person ist in Slot 2 disabled | + +## Änderungs-Übersicht (zur Implementierungsschätzung) + +| Datei | Änderung | +|---|---| +| `js/app.js` | `state.settings` ergänzen, persistieren | +| `js/data.js` | Dedup nach `(date, employeeId)`, `removeHistoryEntry` mit optionalem `employeeId`, `loadFromJSON`/`saveToJSON` um `settings` | +| `js/algorithm.js` | `slotsPerDay`-Param, neuer Rückgabewert, „nicht doppelt am selben Tag"-Filter | +| `js/calendar.js` | Setting-Selector, N Dropdowns pro Zelle, Slot-Disable, Skipped-Dates-Warnbox, Plan-Gen-Aufruf mit `slotsPerDay` | +| `js/excel.js` | Gruppierung nach Datum, alphabetisches Join mit `" & "` | +| `js/history.js` | Sortierung Datum desc, Name asc; ✕ löscht einzelnen Eintrag | +| `style.css` | ggf. kleines Spacing für mehrere Dropdowns pro Zelle, Warnbox-Styling | +| `tests/algorithm.test.mjs` | Neue Tests, Anpassung an neue Rückgabe-Form |