Globale Einstellung moderatorsPerDay (1-5), Top-N-Algorithmus, dynamische N Slots pro Tag im Kalender, Excel joint Namen mit " & ", Verlauf zeigt eine Zeile pro Person. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
193 lines
10 KiB
Markdown
193 lines
10 KiB
Markdown
# 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<assignment>`. 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 |
|