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>
1462 lines
48 KiB
Markdown
1462 lines
48 KiB
Markdown
# Multiple Moderators Per Day Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Mehrere Moderatoren pro Tag zuweisen, Excel-Export joint Namen mit `" & "`, globale Einstellung `moderatorsPerDay` (1–5).
|
||
|
||
**Architecture:** Flaches Datenmodell — mehrere `{date, employeeId, manual}`-Einträge pro Datum erlaubt. Algorithmus pickt Top-N nach unverändertem Score, Tag wird übersprungen wenn weniger Kandidaten verfügbar als `slotsPerDay`. UI rendert N Dropdowns pro Werktag, Export gruppiert nach Datum und joint Namen alphabetisch.
|
||
|
||
**Tech Stack:** Vanilla JS (ES Modules), Node assert für Tests, XLSX (CDN) für Export, localStorage für Persistenz.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-04-28-multiple-moderators-per-day-design.md`
|
||
|
||
---
|
||
|
||
## Reihenfolge & Strategie
|
||
|
||
Bottom-up: Pure-Function-Schichten zuerst (algorithm, data, excel) mit TDD; dann Persistenz (app.js); dann UI (calendar, history, css). Jeder Task ist ein eigener Commit.
|
||
|
||
Tests laufen via `npm test`. Aktuell gibt es nur `tests/algorithm.test.mjs` — neue Test-Dateien werden in package.json registriert.
|
||
|
||
---
|
||
|
||
### Task 1: Algorithmus — Rückgabewert auf `{assignments, skippedDates}` umstellen (Refactor)
|
||
|
||
Reines Refactoring: kein neues Verhalten, aber Rückgabewert ändert sich. Ein einziger Aufrufer (`calendar.js`) wird mit umgestellt, alle bestehenden Tests werden angepasst.
|
||
|
||
**Files:**
|
||
- Modify: `js/algorithm.js` (`generatePlan`)
|
||
- Modify: `js/calendar.js` (`genBtn` Click-Handler, Zeilen 92–105)
|
||
- Modify: `tests/algorithm.test.mjs` (alle Aufrufe von `generatePlan`)
|
||
|
||
- [ ] **Step 1: Tests anpassen — Rückgabe als Objekt mit `.assignments` lesen**
|
||
|
||
In `tests/algorithm.test.mjs` jeden `generatePlan(...)`-Aufruf so umstellen, dass `result.assignments` statt `result` gelesen wird. Konkret:
|
||
|
||
```javascript
|
||
// Vorher:
|
||
const result = generatePlan(['2026-04-01'], [emp('alice'), emp('bob')], [], fixed);
|
||
assert.equal(result.length, 1, 'One workday → one assignment');
|
||
assert.ok(['alice','bob'].includes(result[0].employeeId), 'Assigned to alice or bob');
|
||
|
||
// Nachher:
|
||
const result = generatePlan(['2026-04-01'], [emp('alice'), emp('bob')], [], fixed);
|
||
assert.equal(result.assignments.length, 1, 'One workday → one assignment');
|
||
assert.ok(['alice','bob'].includes(result.assignments[0].employeeId), 'Assigned to alice or bob');
|
||
```
|
||
|
||
Diese Anpassung an **allen 9 Test-Blöcken** vornehmen, die `generatePlan` aufrufen (Zeilen 36, 44, 50, 60, 70, 78, 88, 95, 110, 119 — Suchmuster `result.` bzw. `result[0]`).
|
||
|
||
Auch `assert.equal(result.length, 0, 'No candidate → day skipped')` wird zu `assert.equal(result.assignments.length, 0, 'No candidate → day skipped')` (skippedDates wird in Task 3 separat geprüft).
|
||
|
||
- [ ] **Step 2: Tests laufen lassen — sie müssen jetzt fehlschlagen**
|
||
|
||
```bash
|
||
npm test
|
||
```
|
||
|
||
Erwartung: Fehlermeldung wie `Cannot read properties of undefined (reading 'length')` oder ähnlich, weil `generatePlan` noch ein Array zurückgibt und `.assignments` darauf nicht existiert.
|
||
|
||
- [ ] **Step 3: `generatePlan` umbauen**
|
||
|
||
In `js/algorithm.js` die Funktion so ändern (kein neues Verhalten, nur Rückgabewert):
|
||
|
||
```javascript
|
||
export function generatePlan(workdays, employees, history, randomFn = Math.random) {
|
||
const monthAssignments = [];
|
||
const skippedDates = [];
|
||
|
||
for (const date of workdays) {
|
||
const candidates = employees.filter(e =>
|
||
isEligible(e, date, monthAssignments, history)
|
||
);
|
||
if (candidates.length === 0) continue;
|
||
|
||
const scored = candidates
|
||
.map(e => ({ employee: e, score: scoreCandidate(e, date, history, randomFn) }))
|
||
.sort((a, b) => b.score - a.score);
|
||
|
||
monthAssignments.push({ date, employeeId: scored[0].employee.id, manual: false });
|
||
}
|
||
|
||
return { assignments: monthAssignments, skippedDates };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: `calendar.js` an neue Rückgabe anpassen**
|
||
|
||
In `js/calendar.js`, im `genBtn`-Click-Handler (Zeile ~101) die Zeile
|
||
|
||
```javascript
|
||
currentPlan = generatePlan(workdays, state.employees, historyWithoutMonth);
|
||
addHistoryEntries(state, currentPlan);
|
||
```
|
||
|
||
ersetzen durch
|
||
|
||
```javascript
|
||
const result = generatePlan(workdays, state.employees, historyWithoutMonth);
|
||
currentPlan = result.assignments;
|
||
addHistoryEntries(state, currentPlan);
|
||
```
|
||
|
||
- [ ] **Step 5: Tests laufen lassen — alle grün**
|
||
|
||
```bash
|
||
npm test
|
||
```
|
||
|
||
Erwartung: `All algorithm tests passed.`
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add js/algorithm.js js/calendar.js tests/algorithm.test.mjs
|
||
git commit -m "refactor: generatePlan returns {assignments, skippedDates}"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Algorithmus — `slotsPerDay`-Parameter und Top-N-Auswahl
|
||
|
||
Neuer Parameter `slotsPerDay` (Default 1, Bestandsverhalten unverändert). Pro Tag werden N Personen gewählt — alle aus derselben Kandidatenliste, daher automatisch verschieden.
|
||
|
||
**Files:**
|
||
- Modify: `js/algorithm.js` (`generatePlan`)
|
||
- Modify: `tests/algorithm.test.mjs` (neue Tests am Ende, vor `console.log`)
|
||
|
||
- [ ] **Step 1: Failing Tests schreiben**
|
||
|
||
Am Ende von `tests/algorithm.test.mjs`, **vor** der `console.log('All algorithm tests passed.');`-Zeile, einfügen:
|
||
|
||
```javascript
|
||
// slotsPerDay=2 → 2 different employees per day
|
||
{
|
||
const employees = [emp('alice'), emp('bob'), emp('clara')];
|
||
const result = generatePlan(['2026-04-01'], employees, [], fixed, 2);
|
||
assert.equal(result.assignments.length, 2, 'slotsPerDay=2 → 2 entries');
|
||
const ids = result.assignments.map(a => a.employeeId);
|
||
assert.notEqual(ids[0], ids[1], 'two different employees on same day');
|
||
}
|
||
|
||
// slotsPerDay=2 across multiple days, maxPerMonth still respected
|
||
{
|
||
const days = ['2026-04-01','2026-04-02','2026-04-03'];
|
||
const employees = [
|
||
emp('alice', { maxPerMonth: 1 }),
|
||
emp('bob'),
|
||
emp('clara'),
|
||
emp('dave')
|
||
];
|
||
const result = generatePlan(days, employees, [], fixed, 2);
|
||
const aliceCount = result.assignments.filter(a => a.employeeId === 'alice').length;
|
||
assert.ok(aliceCount <= 1, 'maxPerMonth=1 respected with slotsPerDay=2');
|
||
}
|
||
|
||
// slotsPerDay=2: per day, no employee picked twice
|
||
{
|
||
const employees = [emp('alice'), emp('bob'), emp('clara')];
|
||
const result = generatePlan(['2026-04-01','2026-04-02'], employees, [], fixed, 2);
|
||
for (const date of ['2026-04-01','2026-04-02']) {
|
||
const ids = result.assignments.filter(a => a.date === date).map(a => a.employeeId);
|
||
assert.equal(new Set(ids).size, ids.length, 'no employee picked twice on ' + date);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Tests laufen lassen — sie müssen fehlschlagen**
|
||
|
||
```bash
|
||
npm test
|
||
```
|
||
|
||
Erwartung: Erster neuer Test schlägt fehl, da `slotsPerDay`-Parameter ignoriert wird, nur 1 Eintrag pro Tag erzeugt wird.
|
||
|
||
- [ ] **Step 3: `generatePlan` um `slotsPerDay` erweitern**
|
||
|
||
In `js/algorithm.js`:
|
||
|
||
```javascript
|
||
export function generatePlan(workdays, employees, history, randomFn = Math.random, slotsPerDay = 1) {
|
||
const monthAssignments = [];
|
||
const skippedDates = [];
|
||
|
||
for (const date of workdays) {
|
||
const candidates = employees.filter(e =>
|
||
isEligible(e, date, monthAssignments, history)
|
||
);
|
||
if (candidates.length === 0) continue;
|
||
|
||
const scored = candidates
|
||
.map(e => ({ employee: e, score: scoreCandidate(e, date, history, randomFn) }))
|
||
.sort((a, b) => b.score - a.score);
|
||
|
||
const take = Math.min(slotsPerDay, scored.length);
|
||
for (let i = 0; i < take; i++) {
|
||
monthAssignments.push({ date, employeeId: scored[i].employee.id, manual: false });
|
||
}
|
||
}
|
||
|
||
return { assignments: monthAssignments, skippedDates };
|
||
}
|
||
```
|
||
|
||
Der Skip-Fall (`candidates < slotsPerDay`) kommt erst in Task 3. Hier nehmen wir noch ungeschützt `Math.min(slotsPerDay, scored.length)` — heisst: wenn nur 1 Kandidat verfügbar ist und `slotsPerDay=2`, wird trotzdem 1 zugewiesen. Das wird in Task 3 strenger.
|
||
|
||
- [ ] **Step 4: Tests laufen lassen — alle grün**
|
||
|
||
```bash
|
||
npm test
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add js/algorithm.js tests/algorithm.test.mjs
|
||
git commit -m "feat(algo): slotsPerDay parameter for top-N selection"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Algorithmus — Skip-Day wenn weniger Kandidaten als Slots
|
||
|
||
Wenn `candidates.length < slotsPerDay`, Tag in `skippedDates` aufnehmen, **keine** partielle Zuweisung.
|
||
|
||
**Files:**
|
||
- Modify: `js/algorithm.js` (`generatePlan`)
|
||
- Modify: `tests/algorithm.test.mjs`
|
||
|
||
- [ ] **Step 1: Failing Tests schreiben**
|
||
|
||
Am Ende von `tests/algorithm.test.mjs`, vor der `console.log`-Zeile, einfügen:
|
||
|
||
```javascript
|
||
// slotsPerDay=2 with only 1 eligible employee → day skipped, listed in skippedDates
|
||
{
|
||
const employees = [emp('alice')];
|
||
const result = generatePlan(['2026-04-01'], employees, [], fixed, 2);
|
||
assert.equal(result.assignments.length, 0, 'no assignments when candidates < slotsPerDay');
|
||
assert.deepEqual(result.skippedDates, ['2026-04-01'], 'date listed in skippedDates');
|
||
}
|
||
|
||
// slotsPerDay=1 with 0 candidates → still skipped, listed
|
||
{
|
||
// 2026-04-01 = Wednesday (3), only employee blocks Wednesday
|
||
const employees = [emp('alice', { neverDays: [3] })];
|
||
const result = generatePlan(['2026-04-01'], employees, [], fixed, 1);
|
||
assert.equal(result.assignments.length, 0, 'no assignments when 0 candidates');
|
||
assert.deepEqual(result.skippedDates, ['2026-04-01'], 'date listed in skippedDates');
|
||
}
|
||
|
||
// Mixed: some days fully assigned, some skipped
|
||
{
|
||
// 2026-04-01 = Wed, 2026-04-02 = Thu
|
||
// alice can only Wed, bob can always work
|
||
const employees = [
|
||
emp('alice', { onlyDays: [3] }),
|
||
emp('bob')
|
||
];
|
||
const result = generatePlan(['2026-04-01','2026-04-02'], employees, [], fixed, 2);
|
||
// 2026-04-01: 2 candidates → 2 entries
|
||
assert.equal(result.assignments.filter(a => a.date === '2026-04-01').length, 2, 'Wed has 2 entries');
|
||
// 2026-04-02: only bob → skipped
|
||
assert.equal(result.assignments.filter(a => a.date === '2026-04-02').length, 0, 'Thu skipped');
|
||
assert.deepEqual(result.skippedDates, ['2026-04-02'], 'Thu in skippedDates');
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Tests laufen lassen — sie müssen fehlschlagen**
|
||
|
||
```bash
|
||
npm test
|
||
```
|
||
|
||
Erwartung: Erster neuer Test bekommt `assignments.length === 1` (statt 0) und leeres `skippedDates`.
|
||
|
||
- [ ] **Step 3: Skip-Logik einbauen**
|
||
|
||
In `js/algorithm.js`, die innere Tagesschleife so anpassen:
|
||
|
||
```javascript
|
||
for (const date of workdays) {
|
||
const candidates = employees.filter(e =>
|
||
isEligible(e, date, monthAssignments, history)
|
||
);
|
||
|
||
if (candidates.length < slotsPerDay) {
|
||
skippedDates.push(date);
|
||
continue;
|
||
}
|
||
|
||
const scored = candidates
|
||
.map(e => ({ employee: e, score: scoreCandidate(e, date, history, randomFn) }))
|
||
.sort((a, b) => b.score - a.score);
|
||
|
||
for (let i = 0; i < slotsPerDay; i++) {
|
||
monthAssignments.push({ date, employeeId: scored[i].employee.id, manual: false });
|
||
}
|
||
}
|
||
```
|
||
|
||
(Die `Math.min`-Sicherung von Task 2 entfällt, weil wir jetzt frühzeitig prüfen.)
|
||
|
||
- [ ] **Step 4: Tests laufen lassen — alle grün**
|
||
|
||
```bash
|
||
npm test
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add js/algorithm.js tests/algorithm.test.mjs
|
||
git commit -m "feat(algo): skip day when candidates < slotsPerDay"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: data.js — `addHistoryEntries` dedupliziert nach `(date, employeeId)`
|
||
|
||
Bisher entfernte `addHistoryEntries` alle History-Einträge mit den enthaltenen Daten. Künftig nur diejenigen mit derselben `(date, employeeId)`-Kombination — damit mehrere Personen pro Tag koexistieren.
|
||
|
||
**Files:**
|
||
- Create: `tests/data.test.mjs`
|
||
- Modify: `js/data.js` (`addHistoryEntries`)
|
||
- Modify: `package.json` (`scripts.test`)
|
||
|
||
- [ ] **Step 1: package.json — Test-Skript erweitern**
|
||
|
||
`package.json` `scripts.test` ändern auf:
|
||
|
||
```json
|
||
"test": "node tests/algorithm.test.mjs && node tests/data.test.mjs"
|
||
```
|
||
|
||
- [ ] **Step 2: Test-Datei `tests/data.test.mjs` anlegen**
|
||
|
||
Datei mit Inhalt:
|
||
|
||
```javascript
|
||
import assert from 'node:assert/strict';
|
||
import { addHistoryEntries, removeHistoryEntry } from '../js/data.js';
|
||
|
||
function makeState(history = []) {
|
||
return {
|
||
employees: [],
|
||
calendar: { holidays: [], companyClosures: [] },
|
||
history: [...history]
|
||
};
|
||
}
|
||
|
||
// addHistoryEntries: two entries with same date but different employees both kept
|
||
{
|
||
const state = makeState();
|
||
addHistoryEntries(state, [
|
||
{ date: '2026-04-01', employeeId: 'alice', manual: false },
|
||
{ date: '2026-04-01', employeeId: 'bob', manual: false }
|
||
]);
|
||
assert.equal(state.history.length, 2, 'two distinct entries kept on same date');
|
||
const ids = state.history.map(h => h.employeeId).sort();
|
||
assert.deepEqual(ids, ['alice', 'bob'], 'both employees present');
|
||
}
|
||
|
||
// addHistoryEntries: same (date, employeeId) replaced, others on same date untouched
|
||
{
|
||
const state = makeState([
|
||
{ date: '2026-04-01', employeeId: 'alice', manual: false },
|
||
{ date: '2026-04-01', employeeId: 'bob', manual: false }
|
||
]);
|
||
addHistoryEntries(state, [
|
||
{ date: '2026-04-01', employeeId: 'alice', manual: true }
|
||
]);
|
||
assert.equal(state.history.length, 2, 'still two entries');
|
||
const alice = state.history.find(h => h.employeeId === 'alice');
|
||
assert.equal(alice.manual, true, 'alice replaced with manual=true');
|
||
const bob = state.history.find(h => h.employeeId === 'bob');
|
||
assert.equal(bob.manual, false, 'bob untouched');
|
||
}
|
||
|
||
// addHistoryEntries: empty input → no change
|
||
{
|
||
const state = makeState([{ date: '2026-04-01', employeeId: 'alice', manual: false }]);
|
||
addHistoryEntries(state, []);
|
||
assert.equal(state.history.length, 1, 'empty entries → no change');
|
||
}
|
||
|
||
console.log('All data tests passed.');
|
||
```
|
||
|
||
- [ ] **Step 3: Tests laufen lassen — sie müssen fehlschlagen**
|
||
|
||
```bash
|
||
npm test
|
||
```
|
||
|
||
Erwartung: Im ersten Datentest `state.history.length === 1` (statt 2), weil das aktuelle `addHistoryEntries` alle Einträge des Datums entfernt und dann nur die neuen einfügt — wenn die zwei neuen aber dasselbe Datum haben, würde das eigentlich beide hinzufügen. Tatsächlich: das aktuelle Verhalten fügt beide hinzu (`state.history.push(...entries)`). Der Fehler kommt erst beim zweiten Test (Mix aus alter+neuer Einträge), weil dort der bestehende `bob`-Eintrag zusammen mit `alice` gelöscht wird.
|
||
|
||
- [ ] **Step 4: `addHistoryEntries` umbauen**
|
||
|
||
In `js/data.js`:
|
||
|
||
```javascript
|
||
export function addHistoryEntries(state, entries) {
|
||
if (!entries?.length) return;
|
||
const keys = new Set(entries.map(e => e.date + '|' + e.employeeId));
|
||
state.history = state.history.filter(h => !keys.has(h.date + '|' + h.employeeId));
|
||
state.history.push(...entries);
|
||
state.history.sort((a, b) => a.date.localeCompare(b.date));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Tests laufen lassen — alle grün**
|
||
|
||
```bash
|
||
npm test
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add js/data.js tests/data.test.mjs package.json
|
||
git commit -m "feat(data): dedupe history by (date, employeeId)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: data.js — `removeHistoryEntry` mit optionalem `employeeId`
|
||
|
||
Erweiterte Signatur: `removeHistoryEntry(state, isoDate, employeeId?)`. Ohne `employeeId` Verhalten unverändert (alle Einträge des Tages); mit `employeeId` nur der eine Eintrag.
|
||
|
||
**Files:**
|
||
- Modify: `js/data.js` (`removeHistoryEntry`)
|
||
- Modify: `tests/data.test.mjs`
|
||
|
||
- [ ] **Step 1: Failing Tests am Ende von `tests/data.test.mjs` einfügen**
|
||
|
||
Vor der `console.log`-Zeile:
|
||
|
||
```javascript
|
||
// removeHistoryEntry without employeeId: removes all entries on that date
|
||
{
|
||
const state = makeState([
|
||
{ date: '2026-04-01', employeeId: 'alice', manual: false },
|
||
{ date: '2026-04-01', employeeId: 'bob', manual: false },
|
||
{ date: '2026-04-02', employeeId: 'alice', manual: false }
|
||
]);
|
||
removeHistoryEntry(state, '2026-04-01');
|
||
assert.equal(state.history.length, 1, 'only 2026-04-02 alice remains');
|
||
assert.equal(state.history[0].date, '2026-04-02', 'remaining entry is 2026-04-02');
|
||
}
|
||
|
||
// removeHistoryEntry with employeeId: removes only that one entry
|
||
{
|
||
const state = makeState([
|
||
{ date: '2026-04-01', employeeId: 'alice', manual: false },
|
||
{ date: '2026-04-01', employeeId: 'bob', manual: false }
|
||
]);
|
||
removeHistoryEntry(state, '2026-04-01', 'alice');
|
||
assert.equal(state.history.length, 1, 'one entry remains');
|
||
assert.equal(state.history[0].employeeId, 'bob', 'bob still there');
|
||
}
|
||
|
||
// removeHistoryEntry with employeeId that does not exist: no-op
|
||
{
|
||
const state = makeState([
|
||
{ date: '2026-04-01', employeeId: 'alice', manual: false }
|
||
]);
|
||
removeHistoryEntry(state, '2026-04-01', 'nonexistent');
|
||
assert.equal(state.history.length, 1, 'no change when employeeId not found');
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Tests laufen lassen — sie müssen fehlschlagen**
|
||
|
||
```bash
|
||
npm test
|
||
```
|
||
|
||
Erwartung: Der zweite Test schlägt fehl (`state.history.length === 0`), weil aktuell der gesamte Tag entfernt wird, unabhängig vom 3. Argument.
|
||
|
||
- [ ] **Step 3: Implementierung anpassen**
|
||
|
||
In `js/data.js`:
|
||
|
||
```javascript
|
||
export function removeHistoryEntry(state, isoDate, employeeId) {
|
||
if (employeeId === undefined) {
|
||
state.history = state.history.filter(h => h.date !== isoDate);
|
||
} else {
|
||
state.history = state.history.filter(h => !(h.date === isoDate && h.employeeId === employeeId));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Tests laufen lassen — alle grün**
|
||
|
||
```bash
|
||
npm test
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add js/data.js tests/data.test.mjs
|
||
git commit -m "feat(data): removeHistoryEntry supports optional employeeId"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: data.js — `settings`-Feld in `loadFromJSON` / `saveToJSON`
|
||
|
||
Settings-Objekt beim Speichern/Laden mitführen. Default `{moderatorsPerDay: 1}` wenn fehlend.
|
||
|
||
**Files:**
|
||
- Modify: `js/data.js` (`loadFromJSON`, `saveToJSON`)
|
||
- Modify: `tests/data.test.mjs`
|
||
|
||
- [ ] **Step 1: Import-Zeile am Anfang von `tests/data.test.mjs` erweitern**
|
||
|
||
Die existierende Zeile
|
||
|
||
```javascript
|
||
import { addHistoryEntries, removeHistoryEntry } from '../js/data.js';
|
||
```
|
||
|
||
ergänzen zu
|
||
|
||
```javascript
|
||
import { addHistoryEntries, removeHistoryEntry, loadFromJSON, saveToJSON } from '../js/data.js';
|
||
```
|
||
|
||
- [ ] **Step 2: Failing Tests am Ende der Datei einfügen**
|
||
|
||
Am Ende von `tests/data.test.mjs`, vor der `console.log`-Zeile:
|
||
|
||
```javascript
|
||
// loadFromJSON: missing settings → default {moderatorsPerDay: 1}
|
||
{
|
||
const json = JSON.stringify({
|
||
employees: [],
|
||
calendar: { holidays: [], companyClosures: [] },
|
||
history: []
|
||
});
|
||
const loaded = loadFromJSON(json);
|
||
assert.deepEqual(loaded.settings, { moderatorsPerDay: 1 }, 'default settings when missing');
|
||
}
|
||
|
||
// loadFromJSON: present settings preserved, moderatorsPerDay clamped to [1,5]
|
||
{
|
||
const json = JSON.stringify({
|
||
employees: [], calendar: { holidays: [], companyClosures: [] }, history: [],
|
||
settings: { moderatorsPerDay: 3 }
|
||
});
|
||
const loaded = loadFromJSON(json);
|
||
assert.equal(loaded.settings.moderatorsPerDay, 3, 'preserved value');
|
||
}
|
||
|
||
// loadFromJSON: invalid moderatorsPerDay (string, out of range) → fallback to 1
|
||
{
|
||
const tooHigh = loadFromJSON(JSON.stringify({
|
||
employees: [], calendar: { holidays: [], companyClosures: [] }, history: [],
|
||
settings: { moderatorsPerDay: 99 }
|
||
}));
|
||
assert.equal(tooHigh.settings.moderatorsPerDay, 1, 'out of range falls back to 1');
|
||
|
||
const wrongType = loadFromJSON(JSON.stringify({
|
||
employees: [], calendar: { holidays: [], companyClosures: [] }, history: [],
|
||
settings: { moderatorsPerDay: 'two' }
|
||
}));
|
||
assert.equal(wrongType.settings.moderatorsPerDay, 1, 'wrong type falls back to 1');
|
||
}
|
||
|
||
// saveToJSON: settings are serialized
|
||
{
|
||
const state = {
|
||
employees: [], calendar: { holidays: [], companyClosures: [] }, history: [],
|
||
settings: { moderatorsPerDay: 2 }
|
||
};
|
||
const json = saveToJSON(state);
|
||
const parsed = JSON.parse(json);
|
||
assert.deepEqual(parsed.settings, { moderatorsPerDay: 2 }, 'settings round-trip');
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Tests laufen lassen — sie müssen fehlschlagen**
|
||
|
||
```bash
|
||
npm test
|
||
```
|
||
|
||
Erwartung: `loaded.settings === undefined`, da `loadFromJSON` das Feld noch nicht kennt.
|
||
|
||
- [ ] **Step 4: `loadFromJSON` und `saveToJSON` anpassen**
|
||
|
||
In `js/data.js`:
|
||
|
||
```javascript
|
||
function parseSettings(raw) {
|
||
const n = raw?.moderatorsPerDay;
|
||
const valid = Number.isInteger(n) && n >= 1 && n <= 5;
|
||
return { moderatorsPerDay: valid ? n : 1 };
|
||
}
|
||
|
||
export function loadFromJSON(json) {
|
||
let parsed;
|
||
try {
|
||
parsed = JSON.parse(json);
|
||
} catch (e) {
|
||
throw new Error('Ungültige JSON-Datei: ' + e.message);
|
||
}
|
||
if (typeof parsed !== 'object' || parsed === null) {
|
||
throw new Error('Ungültiges Dateiformat: Objekt erwartet');
|
||
}
|
||
return {
|
||
employees: Array.isArray(parsed.employees) ? parsed.employees : [],
|
||
calendar: (parsed.calendar && typeof parsed.calendar === 'object')
|
||
? {
|
||
holidays: Array.isArray(parsed.calendar.holidays) ? parsed.calendar.holidays : [],
|
||
companyClosures: Array.isArray(parsed.calendar.companyClosures) ? parsed.calendar.companyClosures : []
|
||
}
|
||
: { holidays: [], companyClosures: [] },
|
||
history: Array.isArray(parsed.history) ? parsed.history : [],
|
||
settings: parseSettings(parsed.settings)
|
||
};
|
||
}
|
||
|
||
export function saveToJSON(state) {
|
||
return JSON.stringify({
|
||
employees: state.employees,
|
||
calendar: state.calendar,
|
||
history: state.history,
|
||
settings: state.settings
|
||
}, null, 2);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Tests laufen lassen — alle grün**
|
||
|
||
```bash
|
||
npm test
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add js/data.js tests/data.test.mjs
|
||
git commit -m "feat(data): settings.moderatorsPerDay in load/save JSON"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: app.js — `state.settings` + LocalStorage-Persistenz
|
||
|
||
`state.settings.moderatorsPerDay` zum globalen State hinzufügen. Beim App-Start mit Default `1` initialisieren, falls fehlend. `saveState` schreibt `settings` mit. Auch im Daten-Tab-Loader (`history.js`) wird `state.settings` aus dem geladenen JSON übernommen.
|
||
|
||
**Files:**
|
||
- Modify: `js/app.js` (state init, load, saveState)
|
||
- Modify: `js/history.js` (`fileInput`-Change-Handler)
|
||
|
||
- [ ] **Step 1: `app.js` — state.settings + Load + Save erweitern**
|
||
|
||
In `js/app.js` (komplette Datei wie folgt, anschließend nur die geänderten Stellen markiert):
|
||
|
||
```javascript
|
||
import { renderEmployees } from './employees.js';
|
||
import { renderCalendar } from './calendar.js';
|
||
import { renderDataTab } from './history.js';
|
||
|
||
const STORAGE_KEY = 'morning-planner-state';
|
||
|
||
export const state = {
|
||
employees: [],
|
||
calendar: { holidays: [], companyClosures: [] },
|
||
history: [],
|
||
settings: { moderatorsPerDay: 1 }
|
||
};
|
||
|
||
try {
|
||
const saved = localStorage.getItem(STORAGE_KEY);
|
||
if (saved) {
|
||
const parsed = JSON.parse(saved);
|
||
if (Array.isArray(parsed.employees)) state.employees = parsed.employees;
|
||
if (parsed.calendar) state.calendar = parsed.calendar;
|
||
if (Array.isArray(parsed.history)) state.history = parsed.history;
|
||
if (parsed.settings && Number.isInteger(parsed.settings.moderatorsPerDay)
|
||
&& parsed.settings.moderatorsPerDay >= 1 && parsed.settings.moderatorsPerDay <= 5) {
|
||
state.settings = { moderatorsPerDay: parsed.settings.moderatorsPerDay };
|
||
}
|
||
}
|
||
} catch {
|
||
// Corrupt storage — start fresh
|
||
}
|
||
|
||
export function saveState() {
|
||
try {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||
employees: state.employees,
|
||
calendar: state.calendar,
|
||
history: state.history,
|
||
settings: state.settings
|
||
}));
|
||
} catch {
|
||
// Storage full or unavailable — silently ignore
|
||
}
|
||
}
|
||
|
||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const tab = btn.dataset.tab;
|
||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||
document.querySelectorAll('.tab-content').forEach(s => s.classList.add('hidden'));
|
||
btn.classList.add('active');
|
||
document.getElementById('tab-' + tab).classList.remove('hidden');
|
||
if (tab === 'planning') renderCalendar();
|
||
if (tab === 'data') renderDataTab();
|
||
});
|
||
});
|
||
|
||
renderEmployees();
|
||
```
|
||
|
||
- [ ] **Step 2: `history.js` — `state.settings` beim JSON-Load übernehmen**
|
||
|
||
In `js/history.js`, im `fileInput`-Change-Handler (Zeile ~74), Block
|
||
|
||
```javascript
|
||
state.employees = loaded.employees;
|
||
state.calendar = loaded.calendar;
|
||
state.history = loaded.history;
|
||
```
|
||
|
||
ersetzen durch
|
||
|
||
```javascript
|
||
state.employees = loaded.employees;
|
||
state.calendar = loaded.calendar;
|
||
state.history = loaded.history;
|
||
state.settings = loaded.settings;
|
||
```
|
||
|
||
- [ ] **Step 3: Manuelle Verifikation — Browser**
|
||
|
||
```bash
|
||
npm start
|
||
```
|
||
|
||
Browser auf `http://localhost:3001` öffnen. Devtools → Console:
|
||
|
||
```javascript
|
||
JSON.parse(localStorage.getItem('morning-planner-state')).settings
|
||
```
|
||
|
||
Erwartung: `{ moderatorsPerDay: 1 }` (oder das, was bei dir gespeichert wurde — bei Erstinstallation 1).
|
||
|
||
Im Daten-Tab eine bestehende JSON-Datei laden (oder eine neue mit `settings`-Feld). `state.settings.moderatorsPerDay` muss übernommen werden.
|
||
|
||
- [ ] **Step 4: npm test laufen lassen — alle bestehenden Tests müssen weiterhin grün sein**
|
||
|
||
```bash
|
||
npm test
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add js/app.js js/history.js
|
||
git commit -m "feat: state.settings.moderatorsPerDay with localStorage persistence"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: excel.js — Pure Helper für Spalten-Aufbau, Group-by-Date, Join mit `" & "`
|
||
|
||
Reine Hilfsfunktion `buildScheduleColumns(assignments, employees)` extrahieren — gruppiert nach Datum, sortiert Namen alphabetisch innerhalb des Tages, joint mit `" & "`. Testbar in Node ohne XLSX.
|
||
|
||
**Files:**
|
||
- Create: `tests/excel.test.mjs`
|
||
- Modify: `js/excel.js` (`exportSchedule` ruft Helper, neuer Export)
|
||
- Modify: `package.json` (`scripts.test`)
|
||
|
||
- [ ] **Step 1: package.json — Test-Script erweitern**
|
||
|
||
```json
|
||
"test": "node tests/algorithm.test.mjs && node tests/data.test.mjs && node tests/excel.test.mjs"
|
||
```
|
||
|
||
- [ ] **Step 2: Test-Datei `tests/excel.test.mjs` anlegen**
|
||
|
||
```javascript
|
||
import assert from 'node:assert/strict';
|
||
import { buildScheduleColumns } from '../js/excel.js';
|
||
|
||
const employees = [
|
||
{ id: 'a1', name: 'Anna Müller' },
|
||
{ id: 'b1', name: 'Bernd Schulz' },
|
||
{ id: 'c1', name: 'Carla Weber' }
|
||
];
|
||
|
||
// Single employee per day
|
||
{
|
||
const assignments = [
|
||
{ date: '2026-04-01', employeeId: 'a1' },
|
||
{ date: '2026-04-02', employeeId: 'b1' }
|
||
];
|
||
const cols = buildScheduleColumns(assignments, employees);
|
||
assert.deepEqual(cols.names, ['Anna Müller', 'Bernd Schulz'], 'names per single-entry day');
|
||
assert.deepEqual(cols.dates, ['01.04.2026', '02.04.2026'], 'dates formatted DD.MM.YYYY');
|
||
}
|
||
|
||
// Two employees per day → joined with " & ", alphabetical
|
||
{
|
||
const assignments = [
|
||
{ date: '2026-04-01', employeeId: 'b1' }, // Bernd inserted first
|
||
{ date: '2026-04-01', employeeId: 'a1' } // Anna inserted second
|
||
];
|
||
const cols = buildScheduleColumns(assignments, employees);
|
||
assert.equal(cols.names.length, 1, 'one column per date');
|
||
assert.equal(cols.names[0], 'Anna Müller & Bernd Schulz', 'names alphabetical, joined with " & "');
|
||
assert.equal(cols.dates[0], '01.04.2026', 'one date entry');
|
||
}
|
||
|
||
// Three employees per day
|
||
{
|
||
const assignments = [
|
||
{ date: '2026-04-01', employeeId: 'c1' },
|
||
{ date: '2026-04-01', employeeId: 'a1' },
|
||
{ date: '2026-04-01', employeeId: 'b1' }
|
||
];
|
||
const cols = buildScheduleColumns(assignments, employees);
|
||
assert.equal(cols.names[0], 'Anna Müller & Bernd Schulz & Carla Weber', 'three names joined alphabetical');
|
||
}
|
||
|
||
// Multiple dates with multiple employees, dates ascending
|
||
{
|
||
const assignments = [
|
||
{ date: '2026-04-02', employeeId: 'a1' },
|
||
{ date: '2026-04-01', employeeId: 'b1' },
|
||
{ date: '2026-04-01', employeeId: 'a1' }
|
||
];
|
||
const cols = buildScheduleColumns(assignments, employees);
|
||
assert.deepEqual(cols.dates, ['01.04.2026', '02.04.2026'], 'dates sorted ascending');
|
||
assert.deepEqual(cols.names, ['Anna Müller & Bernd Schulz', 'Anna Müller'], 'names per column in date order');
|
||
}
|
||
|
||
// Unknown employeeId: empty string slot, sorted with other names
|
||
{
|
||
const assignments = [
|
||
{ date: '2026-04-01', employeeId: 'a1' },
|
||
{ date: '2026-04-01', employeeId: 'unknown' }
|
||
];
|
||
const cols = buildScheduleColumns(assignments, employees);
|
||
// Empty-string sorts before 'Anna Müller'
|
||
assert.equal(cols.names[0], ' & Anna Müller', 'unknown employee → empty string in join');
|
||
}
|
||
|
||
console.log('All excel tests passed.');
|
||
```
|
||
|
||
- [ ] **Step 3: Tests laufen lassen — sie müssen fehlschlagen**
|
||
|
||
```bash
|
||
npm test
|
||
```
|
||
|
||
Erwartung: `SyntaxError` oder `buildScheduleColumns is not a function` weil der Helper noch nicht exportiert ist.
|
||
|
||
- [ ] **Step 4: `excel.js` umbauen**
|
||
|
||
`js/excel.js` komplett:
|
||
|
||
```javascript
|
||
// Reads employee names from first column of xlsx (skips header row 0)
|
||
export function readEmployeeNames(arrayBuffer) {
|
||
const wb = XLSX.read(arrayBuffer, { type: 'array' });
|
||
const sheet = wb.Sheets[wb.SheetNames[0]];
|
||
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 });
|
||
return rows.slice(1)
|
||
.map(row => String(row[0] ?? '').trim())
|
||
.filter(name => name.length > 0);
|
||
}
|
||
|
||
// Pure helper: returns { names: [...], dates: [...] } columns for a schedule.
|
||
// Multiple employees per date are joined alphabetically with " & ".
|
||
export function buildScheduleColumns(assignments, employees) {
|
||
const nameById = Object.fromEntries(employees.map(e => [e.id, e.name]));
|
||
const byDate = new Map();
|
||
for (const a of assignments) {
|
||
if (!byDate.has(a.date)) byDate.set(a.date, []);
|
||
byDate.get(a.date).push(nameById[a.employeeId] ?? '');
|
||
}
|
||
const sortedDates = [...byDate.keys()].sort();
|
||
const names = sortedDates.map(d =>
|
||
byDate.get(d).slice().sort((x, y) => x.localeCompare(y, 'de')).join(' & ')
|
||
);
|
||
const dates = sortedDates.map(d => {
|
||
const [y, m, day] = d.split('-');
|
||
return day + '.' + m + '.' + y;
|
||
});
|
||
return { names, dates };
|
||
}
|
||
|
||
// Exports schedule as xlsx blob
|
||
// assignments: [{ date: "2026-04-01", employeeId }] — multiple entries per date allowed
|
||
// employees: [{ id, name }]
|
||
export function exportSchedule(assignments, employees) {
|
||
const { names, dates } = buildScheduleColumns(assignments, employees);
|
||
const ws = XLSX.utils.aoa_to_sheet([names, dates]);
|
||
const wb = XLSX.utils.book_new();
|
||
XLSX.utils.book_append_sheet(wb, ws, 'Moderationsplan');
|
||
const buf = XLSX.write(wb, { type: 'array', bookType: 'xlsx' });
|
||
return new Blob([buf], { type: 'application/octet-stream' });
|
||
}
|
||
|
||
export function downloadBlob(blob, filename) {
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Tests laufen lassen — alle grün**
|
||
|
||
```bash
|
||
npm test
|
||
```
|
||
|
||
Erwartung: alle 3 Test-Dateien zeigen `... passed`.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add js/excel.js tests/excel.test.mjs package.json
|
||
git commit -m "feat(excel): group entries by date, join names with ' & '"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: calendar.js — „Pro Tag"-Selector in der Controls-Row
|
||
|
||
Neuer Selector zwischen Jahr-Selector und „Plan generieren"-Button. Bei Änderung wird `state.settings.moderatorsPerDay` aktualisiert, persistiert und das Grid neu gerendert.
|
||
|
||
**Files:**
|
||
- Modify: `js/calendar.js` (`renderCalendar`)
|
||
|
||
- [ ] **Step 1: Selector im Controls-Row einfügen**
|
||
|
||
In `js/calendar.js`, in der Funktion `renderCalendar`, **nach** den `yearSel`-Zeilen (nach Zeile 48) und **vor** dem `genBtn`-Block einfügen:
|
||
|
||
```javascript
|
||
const slotsLabel = document.createElement('label');
|
||
slotsLabel.textContent = 'Pro Tag:';
|
||
|
||
const slotsSel = document.createElement('select');
|
||
for (let n = 1; n <= 5; n++) {
|
||
const opt = document.createElement('option');
|
||
opt.value = String(n);
|
||
opt.textContent = String(n);
|
||
if (n === state.settings.moderatorsPerDay) opt.selected = true;
|
||
slotsSel.appendChild(opt);
|
||
}
|
||
```
|
||
|
||
Den `controls.append(...)`-Aufruf so erweitern, dass die neuen Elemente zwischen `yearSel` und `genBtn` einsortiert werden:
|
||
|
||
```javascript
|
||
controls.append(monthLabel, monthSel, yearLabel, yearSel, slotsLabel, slotsSel, genBtn, exportBtn);
|
||
```
|
||
|
||
- [ ] **Step 2: Change-Handler für `slotsSel` einbauen**
|
||
|
||
Direkt nach den bestehenden `yearSel.addEventListener`-Zeilen (etwa Zeile 86–90), einfügen:
|
||
|
||
```javascript
|
||
slotsSel.addEventListener('change', () => {
|
||
state.settings.moderatorsPerDay = parseInt(slotsSel.value, 10);
|
||
saveState();
|
||
renderGrid(wrapper, exportBtn);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Manuelle Verifikation**
|
||
|
||
```bash
|
||
npm start
|
||
```
|
||
|
||
Browser → Reiter „Monatsplanung". Erwartung:
|
||
- Selector „Pro Tag: [1|2|3|4|5]" sichtbar zwischen Jahr und „Plan generieren"
|
||
- Wert 1 ausgewählt (Default) bzw. der zuvor gespeicherte Wert
|
||
- Bei Änderung des Selektors: Devtools-Console zeigt nach Reload bzw. via `JSON.parse(localStorage.getItem('morning-planner-state')).settings` den neuen Wert
|
||
|
||
Das Grid bleibt visuell unverändert — die N-Slots-Logik kommt erst in Task 10.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add js/calendar.js
|
||
git commit -m "feat(calendar): add 'Pro Tag' moderator-count selector"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: calendar.js — N Dropdowns pro Werktag, Slot-Disable bei Doppelauswahl
|
||
|
||
Pro Werktag-Zelle werden `state.settings.moderatorsPerDay` Dropdowns gerendert. Vorhandene History-Einträge werden alphabetisch nach Mitarbeiternamen auf die ersten Slots verteilt. Personen, die in einem anderen Slot desselben Tages bereits gewählt sind, sind in den anderen Slots disabled.
|
||
|
||
**Files:**
|
||
- Modify: `js/calendar.js` (`renderGrid`)
|
||
|
||
- [ ] **Step 1: Bestehenden Dropdown-Block durch N-Slot-Loop ersetzen**
|
||
|
||
In `js/calendar.js`, in `renderGrid`, der Block `else { /* Assignee dropdown */ ... }` (Zeilen ~193–243) wird komplett ersetzt. Ich ersetze auch das vorgelagerte Lookup `planByDate` durch eines, das eine Liste pro Datum liefert.
|
||
|
||
Erste Änderung: oben in `renderGrid` (Zeile 125):
|
||
|
||
```javascript
|
||
const planByDate = Object.fromEntries(currentPlan.map(a => [a.date, a.employeeId]));
|
||
```
|
||
|
||
ersetzen durch:
|
||
|
||
```javascript
|
||
const empNameById = Object.fromEntries(state.employees.map(e => [e.id, e.name]));
|
||
const entriesByDate = {};
|
||
for (const a of currentPlan) {
|
||
if (!entriesByDate[a.date]) entriesByDate[a.date] = [];
|
||
entriesByDate[a.date].push(a);
|
||
}
|
||
// Sort each date's entries alphabetically by employee name (stable slot order)
|
||
for (const date of Object.keys(entriesByDate)) {
|
||
entriesByDate[date].sort((x, y) =>
|
||
(empNameById[x.employeeId] ?? '').localeCompare(empNameById[y.employeeId] ?? '', 'de')
|
||
);
|
||
}
|
||
```
|
||
|
||
Zweite Änderung: in der Werktagszelle (innerhalb `if (!isWeekend) { ... else { ... } }`), den Dropdown-Block (von `// Assignee dropdown` bis vor `// FT / BU mark buttons`) komplett ersetzen durch:
|
||
|
||
```javascript
|
||
// N slots per day
|
||
const slotCount = state.settings.moderatorsPerDay;
|
||
const dayEntries = entriesByDate[iso] ?? []; // already alphabetically sorted
|
||
const slotValues = []; // employeeId per slot index, '' if empty
|
||
for (let s = 0; s < slotCount; s++) {
|
||
slotValues.push(dayEntries[s]?.employeeId ?? '');
|
||
}
|
||
|
||
for (let slotIdx = 0; slotIdx < slotCount; slotIdx++) {
|
||
const sel = document.createElement('select');
|
||
sel.dataset.slot = String(slotIdx);
|
||
|
||
const emptyOpt = document.createElement('option');
|
||
emptyOpt.value = '';
|
||
emptyOpt.textContent = '— Leer —';
|
||
sel.appendChild(emptyOpt);
|
||
|
||
for (const emp of state.employees) {
|
||
const opt = document.createElement('option');
|
||
opt.value = emp.id;
|
||
opt.textContent = emp.name;
|
||
// Disable if already picked in a different slot for this day
|
||
const pickedElsewhere = slotValues.some((id, i) => i !== slotIdx && id === emp.id);
|
||
if (pickedElsewhere) opt.disabled = true;
|
||
if (emp.id === slotValues[slotIdx]) opt.selected = true;
|
||
sel.appendChild(opt);
|
||
}
|
||
|
||
sel.addEventListener('change', () => {
|
||
const newId = sel.value;
|
||
const oldId = slotValues[slotIdx];
|
||
if (oldId) {
|
||
removeHistoryEntry(state, iso, oldId);
|
||
currentPlan = currentPlan.filter(a => !(a.date === iso && a.employeeId === oldId));
|
||
}
|
||
if (newId) {
|
||
const entry = { date: iso, employeeId: newId, manual: true };
|
||
addHistoryEntries(state, [entry]);
|
||
currentPlan.push(entry);
|
||
}
|
||
saveState();
|
||
renderGrid(wrapper, exportBtn);
|
||
});
|
||
|
||
cell.appendChild(sel);
|
||
}
|
||
```
|
||
|
||
(Die FT/BU-Button-Zeile darunter bleibt unverändert.)
|
||
|
||
- [ ] **Step 2: Manuelle Verifikation**
|
||
|
||
```bash
|
||
npm start
|
||
```
|
||
|
||
Browser → Reiter „Monatsplanung":
|
||
1. „Pro Tag" auf 1 → eine Dropdown pro Werktag, Verhalten wie bisher.
|
||
2. „Pro Tag" auf 2 → zwei Dropdowns übereinander pro Werktag.
|
||
3. In Slot 1 eine Person wählen → in Slot 2 ist diese Person disabled.
|
||
4. In Slot 2 eine andere Person wählen → beide Personen sind im jeweils anderen Slot disabled.
|
||
5. Slot 1 auf „— Leer —" → bisheriger Slot-1-Eintrag verschwindet aus History; Slot 2 bleibt.
|
||
6. „Pro Tag" auf 1 reduzieren → Slot 2 verschwindet visuell, aber `state.history` enthält den Eintrag noch (in Devtools: `JSON.parse(localStorage.getItem('morning-planner-state')).history`).
|
||
7. „Pro Tag" auf 2 erhöhen → Slot 2 erscheint mit dem zuvor versteckten Eintrag.
|
||
|
||
- [ ] **Step 3: npm test grün halten**
|
||
|
||
```bash
|
||
npm test
|
||
```
|
||
|
||
Bestehende Algorithmus-Tests müssen unverändert grün bleiben (calendar.js hat keine Tests, aber wir wollen sicherstellen, dass keine importierten Module gebrochen sind).
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add js/calendar.js
|
||
git commit -m "feat(calendar): N moderator slots per day with disable-on-duplicate"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: calendar.js — Plan-Generierung mit `slotsPerDay` + Skipped-Dates-Warnbox
|
||
|
||
Der „Plan generieren"-Button übergibt `state.settings.moderatorsPerDay` an `generatePlan` und zeigt nach erfolgreicher Generierung — falls `skippedDates` nicht leer ist — eine schließbare Warnbox über dem Kalender-Grid.
|
||
|
||
**Files:**
|
||
- Modify: `js/calendar.js` (`renderCalendar`, neuer Helper für Warnbox)
|
||
|
||
- [ ] **Step 1: Container für Warnbox in `renderCalendar` anlegen**
|
||
|
||
In `js/calendar.js`, in `renderCalendar`, **vor** der Zeile `const wrapper = document.createElement('div');` (etwa Zeile 72) einfügen:
|
||
|
||
```javascript
|
||
const warnBox = document.createElement('div');
|
||
warnBox.id = 'skipped-warning';
|
||
warnBox.style.display = 'none';
|
||
container.appendChild(warnBox);
|
||
```
|
||
|
||
- [ ] **Step 2: `genBtn`-Click-Handler anpassen**
|
||
|
||
Den bestehenden Handler (Zeilen ~92–105) so ändern:
|
||
|
||
```javascript
|
||
genBtn.addEventListener('click', () => {
|
||
if (state.employees.length === 0) {
|
||
alert('Bitte zuerst Mitarbeiter hinzufügen.');
|
||
return;
|
||
}
|
||
const workdays = getWorkdays(currentYear, currentMonth,
|
||
state.calendar.holidays, state.calendar.companyClosures);
|
||
const prefix = currentYear + '-' + String(currentMonth).padStart(2, '0');
|
||
const historyWithoutMonth = state.history.filter(h => !h.date.startsWith(prefix));
|
||
// Also remove month entries from state.history before adding new ones (full month reset)
|
||
state.history = historyWithoutMonth;
|
||
const result = generatePlan(workdays, state.employees, historyWithoutMonth, Math.random, state.settings.moderatorsPerDay);
|
||
currentPlan = result.assignments;
|
||
addHistoryEntries(state, currentPlan);
|
||
saveState();
|
||
renderSkippedWarning(warnBox, result.skippedDates);
|
||
renderGrid(wrapper, exportBtn);
|
||
});
|
||
```
|
||
|
||
(Achtung: `state.history = historyWithoutMonth` ist neu — vorher hat `addHistoryEntries` per Datum dedupliziert, aber mit der neuen `(date, employeeId)`-Dedup-Regel würden alte Einträge an Daten ohne neuen Eintrag stehen bleiben. Wir resetten den ganzen Monat.)
|
||
|
||
- [ ] **Step 3: `renderSkippedWarning`-Helper am Ende der Datei einfügen**
|
||
|
||
Nach der `renderGrid`-Funktion in `js/calendar.js`:
|
||
|
||
```javascript
|
||
function renderSkippedWarning(box, skippedDates) {
|
||
box.replaceChildren();
|
||
if (!skippedDates || skippedDates.length === 0) {
|
||
box.style.display = 'none';
|
||
return;
|
||
}
|
||
box.style.display = 'flex';
|
||
box.className = 'skipped-warning';
|
||
|
||
const formatted = skippedDates.map(d => {
|
||
const [y, m, day] = d.split('-');
|
||
return day + '.' + m + '.';
|
||
}).join(', ');
|
||
|
||
const text = document.createElement('span');
|
||
text.textContent = skippedDates.length === 1
|
||
? '1 Tag konnte nicht vollständig besetzt werden: ' + formatted
|
||
: skippedDates.length + ' Tage konnten nicht vollständig besetzt werden: ' + formatted;
|
||
box.appendChild(text);
|
||
|
||
const closeBtn = document.createElement('button');
|
||
closeBtn.type = 'button';
|
||
closeBtn.textContent = '✕';
|
||
closeBtn.addEventListener('click', () => {
|
||
box.replaceChildren();
|
||
box.style.display = 'none';
|
||
});
|
||
box.appendChild(closeBtn);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Manuelle Verifikation**
|
||
|
||
```bash
|
||
npm start
|
||
```
|
||
|
||
Vorbereitung in der App:
|
||
1. Mitarbeiter-Tab: drei Mitarbeiter anlegen (z. B. Anna, Bernd, Carla). Bei Carla `onlyDays: [1]` setzen (nur Montag).
|
||
2. Planungs-Tab: „Pro Tag" auf 2 setzen.
|
||
3. „Plan generieren" klicken.
|
||
|
||
Erwartung: Über dem Grid erscheint die Warnbox „… Tage konnten nicht vollständig besetzt werden: 14.04., 21.04., …" (alle Nicht-Montage, an denen Carla nicht zur Verfügung steht und nur Anna+Bernd übrig blieben — `2 ≥ slotsPerDay` ist eigentlich erfüllt … warte).
|
||
|
||
Korrektur des Erwartungs-Setups: bei 3 Mitarbeitern und `slotsPerDay=2` würde an allen Tagen genug Kapazität sein. Besseres Test-Szenario: nur **2 Mitarbeiter**, einen davon mit `vacations: [{ from: '2026-04-15', to: '2026-04-17' }]`. Dann werden die drei Werktage 15./16./17.04. übersprungen.
|
||
|
||
Erwartung: Warnbox zeigt „3 Tage konnten nicht vollständig besetzt werden: 15.04., 16.04., 17.04.". ✕-Button schließt sie. „Plan generieren" erneut → Box erscheint wieder.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add js/calendar.js
|
||
git commit -m "feat(calendar): pass slotsPerDay to generator, show skipped-dates warning"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: history.js — Sortierung Datum desc + Name asc, ✕ löscht einzelnen Eintrag
|
||
|
||
Verlauf-Tabelle: jeder Eintrag in eigener Zeile (bereits so), aber Sortierung muss sekundär nach Name sortieren, damit Mehrfach-Personen-Tage stabil zusammenstehen. Der ✕-Button übergibt jetzt `employeeId` an `removeHistoryEntry`, damit nicht der ganze Tag gelöscht wird.
|
||
|
||
**Files:**
|
||
- Modify: `js/history.js` (`renderHistoryTable`)
|
||
|
||
- [ ] **Step 1: Sortierung anpassen**
|
||
|
||
In `js/history.js`, `renderHistoryTable`, Zeile ~105:
|
||
|
||
```javascript
|
||
const sorted = [...state.history].sort((a, b) => b.date.localeCompare(a.date));
|
||
```
|
||
|
||
ersetzen durch:
|
||
|
||
```javascript
|
||
const sorted = [...state.history].sort((a, b) => {
|
||
const dateCmp = b.date.localeCompare(a.date);
|
||
if (dateCmp !== 0) return dateCmp;
|
||
const nameA = nameById[a.employeeId] ?? '';
|
||
const nameB = nameById[b.employeeId] ?? '';
|
||
return nameA.localeCompare(nameB, 'de');
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Delete-Button auf Einzeleintrag umstellen**
|
||
|
||
In demselben Block (Zeile ~138):
|
||
|
||
```javascript
|
||
delBtn.addEventListener('click', () => {
|
||
removeHistoryEntry(state, h.date);
|
||
saveState();
|
||
renderHistoryTable();
|
||
});
|
||
```
|
||
|
||
ersetzen durch:
|
||
|
||
```javascript
|
||
delBtn.addEventListener('click', () => {
|
||
removeHistoryEntry(state, h.date, h.employeeId);
|
||
saveState();
|
||
renderHistoryTable();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Manuelle Verifikation**
|
||
|
||
```bash
|
||
npm start
|
||
```
|
||
|
||
1. Im Planungs-Tab „Pro Tag" auf 2 setzen, „Plan generieren". Mindestens ein Tag muss 2 Personen haben.
|
||
2. In den Daten-Tab wechseln. Erwartung: Mehrfach-Personen-Tage stehen direkt untereinander, alphabetisch nach Name.
|
||
3. Am Multi-Personen-Tag den ✕-Button bei einer Person klicken. Erwartung: nur diese eine Zeile verschwindet, die andere Person desselben Tages bleibt.
|
||
4. Devtools-Check: `state.history` (über Module-Import oder via `JSON.parse(localStorage...)`) enthält noch den anderen Eintrag.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add js/history.js
|
||
git commit -m "feat(history): sort by date desc + name asc, delete single entry"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: style.css — Slot-Spacing und Warnbox-Styling
|
||
|
||
Etwas vertikalen Abstand zwischen den Dropdowns einer Tageszelle und ein dezentes Styling für die `.skipped-warning`-Box.
|
||
|
||
**Files:**
|
||
- Modify: `style.css`
|
||
|
||
- [ ] **Step 1: Aktuelle Calendar-Styles inspizieren**
|
||
|
||
```bash
|
||
grep -n "cal-day" style.css
|
||
```
|
||
|
||
Erwartung: vorhandene Klassen für `.cal-day`, `.cal-day select`, `.cal-btn-row`. Den Block, der `.cal-day select` betrifft, identifizieren — typischerweise `width: 100%`. Den exakten Selektor nutzen, um den nächsten Edit gezielt zu machen.
|
||
|
||
- [ ] **Step 2: Slot-Spacing zur bestehenden Calendar-Sektion hinzufügen**
|
||
|
||
Am **Ende** von `style.css` anhängen:
|
||
|
||
```css
|
||
/* Multiple moderator slots per day */
|
||
.cal-day select + select {
|
||
margin-top: 4px;
|
||
}
|
||
|
||
/* Skipped-dates warning box */
|
||
.skipped-warning {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 8px 12px;
|
||
margin: 8px 0 12px 0;
|
||
background: #fef3c7;
|
||
border: 1px solid #fcd34d;
|
||
border-radius: 6px;
|
||
color: #78350f;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.skipped-warning button {
|
||
background: transparent;
|
||
border: none;
|
||
color: #78350f;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
padding: 0 4px;
|
||
}
|
||
|
||
.skipped-warning button:hover {
|
||
color: #1f2937;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Manuelle Verifikation**
|
||
|
||
```bash
|
||
npm start
|
||
```
|
||
|
||
1. „Pro Tag" auf 2 → in jeder Werktagszelle sind die beiden Dropdowns mit kleinem Abstand erkennbar getrennt.
|
||
2. „Plan generieren" mit zu wenigen Mitarbeitern an manchen Tagen → Warnbox erscheint in dezentem Gelb mit Border, ✕-Button ist sichtbar und klickbar.
|
||
3. Visuell stimmig mit dem restlichen App-Look (kein Bruch in Schrift/Spacing).
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add style.css
|
||
git commit -m "style: spacing for moderator slots and skipped-dates warning box"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: Integration & End-to-End-Verifikation
|
||
|
||
Vollständiger Durchlauf der Funktionalität: Anlegen, Generieren, Editieren, Exportieren, Reload, JSON-Roundtrip.
|
||
|
||
**Files:**
|
||
- Keine Änderungen — nur Verifikation
|
||
|
||
- [ ] **Step 1: Vollständiger npm test**
|
||
|
||
```bash
|
||
npm test
|
||
```
|
||
|
||
Erwartung: Alle drei Test-Dateien grün.
|
||
|
||
- [ ] **Step 2: Browser End-to-End**
|
||
|
||
```bash
|
||
npm start
|
||
```
|
||
|
||
Schritte (Devtools offen):
|
||
|
||
1. Reset: `localStorage.clear()` in der Console, dann Reload.
|
||
2. Mitarbeiter-Tab: 4 Mitarbeiter anlegen mit vollen Namen, z. B. „Anna Müller", „Bernd Schulz", „Carla Weber", „Dirk Klein".
|
||
3. Planungs-Tab: „Pro Tag" auf 2.
|
||
4. „Plan generieren". Erwartung:
|
||
- Jede Werktagszelle hat 2 Dropdowns mit je einem Eintrag.
|
||
- Die zwei Personen pro Tag sind verschieden, alphabetisch sortiert.
|
||
- Keine Warnbox (4 Mitarbeiter, alle uneingeschränkt → genug Kapazität).
|
||
5. In einer Tageszelle Slot 2 manuell auf „— Leer —". Slot 1 bleibt.
|
||
6. „Als Excel exportieren" → Datei herunterladen, in Excel/LibreOffice öffnen.
|
||
- Erste Zeile: Namen, Tage mit 2 Personen als „Anna Müller & Bernd Schulz" o. ä., Tag mit nur 1 Person (Schritt 5) als „Bernd Schulz" allein, Spalten chronologisch.
|
||
- Zweite Zeile: Datum jeweils als `01.04.2026`, `02.04.2026`, …
|
||
7. Reiter Daten → „Daten speichern (.json)". Datei öffnen, prüfen: `settings.moderatorsPerDay === 2`, `history` enthält 2 Einträge pro Tag.
|
||
8. Reload der Seite. Erwartung: alles wiederhergestellt, „Pro Tag" weiterhin auf 2, Tageszellen unverändert besetzt.
|
||
9. „Pro Tag" auf 1 reduzieren → in jeder Zelle nur noch ein Dropdown, das alphabetisch erste Person sichtbar. Excel-Export zeigt aber weiterhin beide Personen pro Tag.
|
||
10. „Pro Tag" zurück auf 2. Slot 2 zeigt wieder die zweite Person.
|
||
11. Verlauf-Tab → ✕ bei einer einzelnen Person eines 2-Personen-Tages. Nur diese eine Zeile verschwindet.
|
||
|
||
Wenn alle Schritte korrekt: fertig.
|
||
|
||
- [ ] **Step 3: Optional — README-Eintrag**
|
||
|
||
Falls README aktuell explizit „eine Person pro Tag" oder Ähnliches behauptet:
|
||
|
||
```bash
|
||
grep -n "moderator\|Moderator\|Person" README.md
|
||
```
|
||
|
||
Falls ein Statement angepasst werden sollte, in der „Features"-Liste ergänzen:
|
||
```
|
||
- **Mehrere Moderatoren pro Tag** – Globale Einstellung 1–5 Personen pro Tag, Excel-Export joint Namen mit „ & "
|
||
```
|
||
|
||
(Nur committen, falls sich tatsächlich etwas ändert.)
|
||
|
||
- [ ] **Step 4: Final-Commit (falls README angepasst)**
|
||
|
||
```bash
|
||
git add README.md
|
||
git commit -m "docs: mention multi-moderator support in README"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review Notizen
|
||
|
||
**Spec-Coverage-Check:**
|
||
- Datenmodell (`state.settings`, dedup-Regel, removeHistoryEntry-Erweiterung): Tasks 4–7 ✓
|
||
- Algorithmus (`slotsPerDay`, Top-N, Skip-Day, neue Rückgabe): Tasks 1–3 ✓
|
||
- UI Setting-Selector: Task 9 ✓
|
||
- UI N Slots + Disable-Logik: Task 10 ✓
|
||
- UI Skipped-Warning-Box: Task 11 ✓
|
||
- Excel mit `" & "`-Join: Task 8 ✓
|
||
- Verlauf-Tab Sortierung + Single-Entry-Delete: Task 12 ✓
|
||
- Persistenz (localStorage + JSON): Tasks 6–7 ✓
|
||
- Tests: Tasks 1–6, 8 ✓
|
||
- Style-Anpassung: Task 13 ✓
|
||
- E2E-Verifikation: Task 14 ✓
|
||
|
||
**Type-Konsistenz:**
|
||
- `generatePlan` gibt durchgehend `{assignments, skippedDates}` zurück (Tasks 1–3, verwendet in 11).
|
||
- `addHistoryEntries(state, entries)` Signatur unverändert (Task 4).
|
||
- `removeHistoryEntry(state, isoDate, employeeId?)` (Task 5, verwendet in 10 und 12).
|
||
- `state.settings = { moderatorsPerDay: N }` (Tasks 6–7, verwendet in 9, 10, 11).
|
||
- `buildScheduleColumns(assignments, employees) → {names, dates}` (Task 8).
|
||
|
||
**Placeholder-Scan:** Kein TBD/TODO in den Tasks; alle Code-Blöcke vollständig.
|