diff --git a/docs/superpowers/plans/2026-04-07-morning-meeting-planner.md b/docs/superpowers/plans/2026-04-07-morning-meeting-planner.md
new file mode 100644
index 0000000..6f97262
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-07-morning-meeting-planner.md
@@ -0,0 +1,1338 @@
+# Morning Meeting Planner — 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:** Build a browser-based web app to assign employees to workdays for morning meeting moderation, with per-employee constraints and cross-month fairness balancing.
+
+**Architecture:** Pure HTML + Vanilla JS (ES modules) + CSS. SheetJS loaded via CDN for Excel import/export. Data persisted as a downloadable/uploadable JSON file. No server, no build step — needs `npx serve` for ES module support.
+
+**Tech Stack:** HTML5, Vanilla JavaScript (ES modules), CSS3, SheetJS (`https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js`)
+
+> **Security note:** All user-provided content (employee names etc.) rendered into HTML must be escaped with `escHtml()`. Date strings and IDs come from internal state only and are safe to interpolate directly.
+
+---
+
+## File Map
+
+| File | Responsibility |
+|---|---|
+| `index.html` | App shell, tab navigation, script loading |
+| `style.css` | All styling |
+| `js/data.js` | Data model, JSON load/save, employee CRUD, history management |
+| `js/algorithm.js` | Pure assignment algorithm (filter + score) |
+| `js/excel.js` | SheetJS wrapper: employee import, schedule export |
+| `js/employees.js` | Tab 1 UI: list, add/remove, constraint editor |
+| `js/calendar.js` | Tab 2 UI: month grid, holiday marking, generate, manual override |
+| `js/history.js` | Tab 3 UI: JSON load/save, history table and delete |
+| `js/app.js` | Entry point: shared state, tab switching |
+| `tests/algorithm.test.mjs` | Node.js unit tests for algorithm |
+| `package.json` | Dev server + test scripts |
+
+---
+
+## Task 1: Project Scaffold
+
+**Files:**
+- Create: `index.html`
+- Create: `style.css`
+- Create: `js/app.js`
+
+- [ ] **Step 1: Create `index.html`**
+
+```html
+
+
+
+
+
+ Morning Meeting Planner
+
+
+
+
+
+ Morning Meeting Planner
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 2: Create `style.css`**
+
+```css
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+body {
+ font-family: system-ui, sans-serif;
+ font-size: 14px;
+ color: #1a1a1a;
+ background: #f5f5f5;
+}
+
+header { background: #2563eb; color: white; padding: 16px 24px; }
+header h1 { font-size: 18px; font-weight: 600; }
+
+.tabs { display: flex; gap: 2px; background: #e5e7eb; padding: 8px 24px 0; }
+.tab-btn {
+ padding: 8px 20px; border: none; background: #d1d5db;
+ cursor: pointer; border-radius: 6px 6px 0 0; font-size: 14px; color: #374151;
+}
+.tab-btn.active { background: white; color: #2563eb; font-weight: 600; }
+.tab-content { background: white; min-height: calc(100vh - 100px); padding: 24px; }
+.hidden { display: none; }
+
+button {
+ cursor: pointer; padding: 6px 14px; border-radius: 4px;
+ border: 1px solid #d1d5db; background: white; font-size: 13px;
+}
+button.primary { background: #2563eb; color: white; border-color: #2563eb; }
+button.danger { background: #ef4444; color: white; border-color: #ef4444; }
+input, select { padding: 5px 8px; border: 1px solid #d1d5db; border-radius: 4px; font-size: 13px; }
+
+table { border-collapse: collapse; width: 100%; }
+th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #e5e7eb; }
+th { background: #f9fafb; font-weight: 600; }
+
+.section-title { font-size: 16px; font-weight: 600; margin-bottom: 16px; }
+.row { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
+
+.calendar-outer { overflow-x: auto; }
+.calendar-header-grid { display: grid; grid-template-columns: repeat(7,1fr); gap: 6px; margin-bottom: 6px; }
+.calendar-body-grid { display: grid; grid-template-columns: repeat(7,1fr); gap: 6px; }
+.cal-day { border: 1px solid #e5e7eb; border-radius: 6px; padding: 8px; background: white; min-height: 80px; }
+.cal-day.weekend { background: #f3f4f6; color: #9ca3af; }
+.cal-day.holiday { background: #fef3c7; }
+.cal-day.closure { background: #fee2e2; }
+.cal-day.empty-cell { border: none; background: transparent; min-height: 0; }
+.day-num { font-weight: 600; font-size: 13px; }
+.day-tag { font-size: 10px; color: #6b7280; }
+
+.employee-item { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid #f3f4f6; }
+.employee-name { font-weight: 500; flex: 1; }
+.constraint-form { background: #f9fafb; padding: 16px; border-radius: 8px; margin-top: 4px; margin-bottom: 8px; }
+.constraint-form label { display: block; margin-bottom: 4px; font-size: 12px; color: #6b7280; }
+.constraint-row { margin-bottom: 12px; }
+.day-toggle-group { display: flex; gap: 4px; }
+.day-toggle {
+ width: 32px; height: 32px; border-radius: 50%; border: 1px solid #d1d5db;
+ background: white; font-size: 11px; cursor: pointer;
+}
+.day-toggle.active { background: #2563eb; color: white; border-color: #2563eb; }
+```
+
+- [ ] **Step 3: Create `js/app.js`**
+
+```javascript
+import { renderEmployees } from './employees.js';
+import { renderCalendar } from './calendar.js';
+import { renderDataTab } from './history.js';
+
+// Shared mutable state — imported by all other modules
+export const state = {
+ employees: [],
+ calendar: { holidays: [], companyClosures: [] },
+ history: []
+};
+
+// Tab switching
+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 4: Create `package.json`**
+
+```json
+{
+ "name": "morning-meeting-planner",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "start": "npx serve . -p 3000",
+ "test": "node tests/algorithm.test.mjs"
+ }
+}
+```
+
+- [ ] **Step 5: Start dev server and verify scaffold**
+
+```bash
+cd /Users/ferdinand/coding/morning-planner
+npm start
+```
+
+Open `http://localhost:3000`. Expected: blue header, 3 clickable tabs, white content area. Browser console may show import errors for missing modules — that's OK at this stage.
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd /Users/ferdinand/coding/morning-planner
+git add index.html style.css js/app.js package.json
+git commit -m "feat: project scaffold with tabs, styles and package.json"
+```
+
+---
+
+## Task 2: Data Module
+
+**Files:**
+- Create: `js/data.js`
+
+- [ ] **Step 1: Create `js/data.js`**
+
+```javascript
+export function loadFromJSON(json) {
+ const parsed = JSON.parse(json);
+ return {
+ employees: parsed.employees ?? [],
+ calendar: parsed.calendar ?? { holidays: [], companyClosures: [] },
+ history: parsed.history ?? []
+ };
+}
+
+export function saveToJSON(state) {
+ return JSON.stringify({
+ employees: state.employees,
+ calendar: state.calendar,
+ history: state.history
+ }, null, 2);
+}
+
+export function createEmployee(name) {
+ return {
+ id: crypto.randomUUID(),
+ name: name.trim(),
+ constraints: {
+ neverDays: [],
+ lowPriorityDays: [],
+ onlyDays: [],
+ maxPerMonth: null,
+ minGapDays: 0,
+ vacations: []
+ }
+ };
+}
+
+export function removeEmployee(state, id) {
+ state.employees = state.employees.filter(e => e.id !== id);
+}
+
+export function toggleHoliday(state, isoDate) {
+ const arr = state.calendar.holidays;
+ const idx = arr.indexOf(isoDate);
+ if (idx >= 0) arr.splice(idx, 1); else arr.push(isoDate);
+}
+
+export function toggleClosure(state, isoDate) {
+ const arr = state.calendar.companyClosures;
+ const idx = arr.indexOf(isoDate);
+ if (idx >= 0) arr.splice(idx, 1); else arr.push(isoDate);
+}
+
+// Adds entries to history, replacing any existing entries for the same dates
+export function addHistoryEntries(state, entries) {
+ const dates = new Set(entries.map(e => e.date));
+ state.history = state.history.filter(h => !dates.has(h.date));
+ state.history.push(...entries);
+ state.history.sort((a, b) => a.date.localeCompare(b.date));
+}
+
+export function removeHistoryEntry(state, isoDate) {
+ state.history = state.history.filter(h => h.date !== isoDate);
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add js/data.js
+git commit -m "feat: data module with JSON load/save and employee CRUD"
+```
+
+---
+
+## Task 3: Algorithm Module + Tests
+
+**Files:**
+- Create: `js/algorithm.js`
+- Create: `tests/algorithm.test.mjs`
+
+- [ ] **Step 1: Create `js/algorithm.js`**
+
+```javascript
+function toISO(year, month, day) {
+ return year + '-' + String(month).padStart(2,'0') + '-' + String(day).padStart(2,'0');
+}
+
+// Returns 1=Mo, 2=Di, 3=Mi, 4=Do, 5=Fr, 6=Sa, 7=So
+function getWeekday(isoDate) {
+ const d = new Date(isoDate + 'T12:00:00Z');
+ const day = d.getUTCDay(); // 0=Sun
+ return day === 0 ? 7 : day;
+}
+
+function isOnVacation(employee, isoDate) {
+ return employee.constraints.vacations.some(v => v.from <= isoDate && isoDate <= v.to);
+}
+
+function daysBetween(isoA, isoB) {
+ return Math.abs(
+ (new Date(isoA + 'T12:00:00Z') - new Date(isoB + 'T12:00:00Z')) / 86400000
+ );
+}
+
+// Returns sorted array of ISO date strings that are workdays in the given month
+export function getWorkdays(year, month, holidays, companyClosures) {
+ const days = [];
+ const daysInMonth = new Date(year, month, 0).getDate();
+ for (let d = 1; d <= daysInMonth; d++) {
+ const iso = toISO(year, month, d);
+ const wd = getWeekday(iso);
+ if (wd >= 6) continue; // weekend
+ if (holidays.includes(iso) || companyClosures.includes(iso)) continue;
+ days.push(iso);
+ }
+ return days;
+}
+
+function isEligible(employee, isoDate, monthAssignments, allHistory) {
+ const c = employee.constraints;
+ const wd = getWeekday(isoDate);
+
+ if (c.neverDays.includes(wd)) return false;
+ if (c.onlyDays.length > 0 && !c.onlyDays.includes(wd)) return false;
+ if (isOnVacation(employee, isoDate)) return false;
+
+ if (c.maxPerMonth !== null) {
+ const count = monthAssignments.filter(a => a.employeeId === employee.id).length;
+ if (count >= c.maxPerMonth) return false;
+ }
+
+ if (c.minGapDays > 0) {
+ const allEntries = [...allHistory, ...monthAssignments];
+ const lastDate = allEntries
+ .filter(a => a.employeeId === employee.id && a.date < isoDate)
+ .map(a => a.date)
+ .sort()
+ .at(-1);
+ if (lastDate && daysBetween(lastDate, isoDate) < c.minGapDays) return false;
+ }
+
+ return true;
+}
+
+// randomFn injectable so tests can pass () => 0.5 for deterministic output
+function scoreCandidate(employee, isoDate, allHistory, randomFn) {
+ const wd = getWeekday(isoDate);
+ const myTotal = allHistory.filter(h => h.employeeId === employee.id).length;
+
+ // Fewer past assignments → higher (less negative) score
+ let score = -myTotal;
+
+ // Low priority: increase negative score (less likely to be picked)
+ if (employee.constraints.lowPriorityDays.includes(wd)) score -= 5;
+
+ // Jitter ±0.49
+ score += (randomFn() - 0.5) * 0.98;
+
+ return score;
+}
+
+// Returns [{ date, employeeId, manual: false }] for each assigned workday
+export function generatePlan(workdays, employees, history, randomFn = Math.random) {
+ const monthAssignments = [];
+
+ 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 monthAssignments;
+}
+```
+
+- [ ] **Step 2: Write the failing test first**
+
+Create `tests/algorithm.test.mjs`:
+
+```javascript
+import assert from 'node:assert/strict';
+import { getWorkdays, generatePlan } from '../js/algorithm.js';
+
+const fixed = () => 0.5; // deterministic random for tests
+
+function emp(id, constraints = {}) {
+ return {
+ id, name: id,
+ constraints: {
+ neverDays: [], lowPriorityDays: [], onlyDays: [],
+ maxPerMonth: null, minGapDays: 0, vacations: [],
+ ...constraints
+ }
+ };
+}
+
+// getWorkdays
+{
+ const days = getWorkdays(2026, 4, [], []);
+ assert.equal(days.length, 22, 'April 2026: 22 workdays');
+ assert.equal(days[0], '2026-04-01', 'First workday = 2026-04-01');
+ assert.equal(days.at(-1), '2026-04-30', 'Last workday = 2026-04-30');
+}
+{
+ const days = getWorkdays(2026, 4, ['2026-04-01'], []);
+ assert.equal(days.length, 21, 'Holiday reduces count by 1');
+ assert.ok(!days.includes('2026-04-01'), 'Holiday excluded');
+}
+{
+ const days = getWorkdays(2026, 4, [], []);
+ assert.ok(!days.includes('2026-04-04'), '2026-04-04 Saturday excluded');
+ assert.ok(!days.includes('2026-04-05'), '2026-04-05 Sunday excluded');
+}
+
+// generatePlan — basic assignment
+{
+ 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');
+}
+
+// neverDays blocks employee
+{
+ // 2026-04-01 is Wednesday (3)
+ const result = generatePlan(['2026-04-01'], [emp('alice', {neverDays:[3]}), emp('bob')], [], fixed);
+ assert.equal(result[0].employeeId, 'bob', 'alice blocked on Wednesday → bob assigned');
+}
+
+// onlyDays restricts to specific days
+{
+ // 2026-04-01 = Wednesday (3), 2026-04-02 = Thursday (4)
+ const employees = [emp('alice', {onlyDays:[3]}), emp('bob')];
+ const result = generatePlan(['2026-04-01','2026-04-02'], employees, [], fixed);
+ const aliceOnThursday = result.find(r => r.date === '2026-04-02' && r.employeeId === 'alice');
+ assert.ok(!aliceOnThursday, 'alice with onlyDays:[3] not assigned on Thursday');
+}
+
+// vacation blocks employee
+{
+ const employees = [
+ emp('alice', {vacations:[{from:'2026-04-01', to:'2026-04-03'}]}),
+ emp('bob')
+ ];
+ const result = generatePlan(['2026-04-01'], employees, [], fixed);
+ assert.equal(result[0].employeeId, 'bob', 'alice on vacation → bob assigned');
+}
+
+// maxPerMonth cap
+{
+ const days = ['2026-04-01','2026-04-02','2026-04-03'];
+ const employees = [emp('alice', {maxPerMonth:1}), emp('bob'), emp('clara')];
+ const result = generatePlan(days, employees, [], fixed);
+ const aliceCount = result.filter(r => r.employeeId === 'alice').length;
+ assert.ok(aliceCount <= 1, 'alice maxPerMonth=1 → assigned at most once');
+}
+
+// minGapDays
+{
+ // 2026-04-06 = Monday, alice last assigned 2026-04-02 (Thu) = 4 days ago, gap=5 → blocked
+ const history = [{date:'2026-04-02', employeeId:'alice', manual:false}];
+ const employees = [emp('alice', {minGapDays:5}), emp('bob')];
+ const result = generatePlan(['2026-04-06'], employees, history, fixed);
+ assert.equal(result[0].employeeId, 'bob', 'alice within minGap (4<5) → bob assigned');
+}
+
+// No candidates → day skipped
+{
+ // 2026-04-01 = Wednesday (3), only employee blocked
+ const result = generatePlan(['2026-04-01'], [emp('alice', {neverDays:[3]})], [], fixed);
+ assert.equal(result.length, 0, 'No candidate → day skipped');
+}
+
+// Fairness: less history = higher priority
+{
+ // alice has 5 history entries, bob has 0 → bob should get priority
+ const history = [1,2,3,4,5].map(i => ({
+ date: '2026-04-0' + i, employeeId: 'alice', manual: false
+ }));
+ const result = generatePlan(['2026-05-04'], [emp('alice'), emp('bob')], history, fixed);
+ assert.equal(result[0].employeeId, 'bob', 'bob (0 history) beats alice (5 history)');
+}
+
+console.log('All algorithm tests passed.');
+```
+
+- [ ] **Step 3: Run tests — expect FAIL (module not yet complete)**
+
+```bash
+cd /Users/ferdinand/coding/morning-planner
+node tests/algorithm.test.mjs
+```
+
+If `js/algorithm.js` was already created in Step 1, tests should pass. If any fail, fix the algorithm before continuing.
+
+Expected output:
+```
+All algorithm tests passed.
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add js/algorithm.js tests/algorithm.test.mjs
+git commit -m "feat: algorithm module with unit tests"
+```
+
+---
+
+## Task 4: Excel Module
+
+**Files:**
+- Create: `js/excel.js`
+
+- [ ] **Step 1: Create `js/excel.js`**
+
+```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);
+}
+
+// Exports schedule as xlsx blob
+// assignments: [{ date: "2026-04-01", employeeId }]
+// employees: [{ id, name }]
+export function exportSchedule(assignments, employees) {
+ const nameById = Object.fromEntries(employees.map(e => [e.id, e.name]));
+ const names = assignments.map(a => nameById[a.employeeId] ?? '');
+ const dates = assignments.map(a => {
+ const [y, m, d] = a.date.split('-');
+ return d + '.' + m + '.' + y;
+ });
+ 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 2: Commit**
+
+```bash
+git add js/excel.js
+git commit -m "feat: Excel import/export module via SheetJS"
+```
+
+---
+
+## Task 5: Employees Tab
+
+**Files:**
+- Create: `js/employees.js`
+
+- [ ] **Step 1: Create `js/employees.js`**
+
+```javascript
+import { state } from './app.js';
+import { createEmployee, removeEmployee } from './data.js';
+import { readEmployeeNames } from './excel.js';
+
+const WD_LABELS = ['Mo','Di','Mi','Do','Fr'];
+const WD_VALUES = [1,2,3,4,5];
+
+// Escape HTML to prevent XSS when rendering user-provided names
+function esc(str) {
+ return String(str)
+ .replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
+}
+
+export function renderEmployees() {
+ const container = document.getElementById('tab-employees');
+
+ // Build static shell
+ const shell = document.createElement('div');
+ const title = document.createElement('h2');
+ title.className = 'section-title';
+ title.textContent = 'Mitarbeiter';
+ shell.appendChild(title);
+
+ const row = document.createElement('div');
+ row.className = 'row';
+
+ const nameInput = document.createElement('input');
+ nameInput.id = 'emp-name-input';
+ nameInput.type = 'text';
+ nameInput.placeholder = 'Name eingeben...';
+ nameInput.style.width = '220px';
+
+ const addBtn = document.createElement('button');
+ addBtn.className = 'primary';
+ addBtn.textContent = 'Hinzufügen';
+
+ const excelBtn = document.createElement('button');
+ excelBtn.textContent = 'Excel importieren';
+
+ const fileInput = document.createElement('input');
+ fileInput.id = 'emp-excel-input';
+ fileInput.type = 'file';
+ fileInput.accept = '.xlsx,.xls';
+ fileInput.style.display = 'none';
+
+ row.append(nameInput, addBtn, excelBtn, fileInput);
+
+ const list = document.createElement('div');
+ list.id = 'employee-list';
+
+ shell.append(row, list);
+ container.replaceChildren(shell);
+
+ refreshList();
+
+ addBtn.addEventListener('click', () => {
+ const name = nameInput.value.trim();
+ if (!name) return;
+ state.employees.push(createEmployee(name));
+ nameInput.value = '';
+ refreshList();
+ });
+
+ nameInput.addEventListener('keydown', e => { if (e.key === 'Enter') addBtn.click(); });
+
+ excelBtn.addEventListener('click', () => fileInput.click());
+
+ fileInput.addEventListener('change', async e => {
+ const file = e.target.files[0];
+ if (!file) return;
+ const buf = await file.arrayBuffer();
+ const names = readEmployeeNames(buf);
+ for (const name of names) {
+ if (!state.employees.some(emp => emp.name === name)) {
+ state.employees.push(createEmployee(name));
+ }
+ }
+ e.target.value = '';
+ refreshList();
+ });
+}
+
+function refreshList() {
+ const list = document.getElementById('employee-list');
+ list.replaceChildren();
+
+ if (state.employees.length === 0) {
+ const msg = document.createElement('p');
+ msg.style.cssText = 'color:#9ca3af;margin-top:16px';
+ msg.textContent = 'Noch keine Mitarbeiter hinzugefügt.';
+ list.appendChild(msg);
+ return;
+ }
+
+ for (const emp of state.employees) {
+ const item = document.createElement('div');
+ item.className = 'employee-item';
+
+ const nameSpan = document.createElement('span');
+ nameSpan.className = 'employee-name';
+ nameSpan.textContent = emp.name; // textContent: safe, no XSS
+
+ const editBtn = document.createElement('button');
+ editBtn.textContent = 'Einschränkungen';
+
+ const removeBtn = document.createElement('button');
+ removeBtn.className = 'danger';
+ removeBtn.textContent = 'Entfernen';
+
+ item.append(nameSpan, editBtn, removeBtn);
+ list.appendChild(item);
+
+ const editorDiv = document.createElement('div');
+ editorDiv.id = 'editor-' + emp.id;
+ editorDiv.style.display = 'none';
+ list.appendChild(editorDiv);
+
+ removeBtn.addEventListener('click', () => {
+ removeEmployee(state, emp.id);
+ refreshList();
+ });
+
+ editBtn.addEventListener('click', () => {
+ if (editorDiv.style.display === 'none') {
+ renderConstraintEditor(emp.id);
+ editorDiv.style.display = 'block';
+ } else {
+ editorDiv.style.display = 'none';
+ }
+ });
+ }
+}
+
+function renderConstraintEditor(empId) {
+ const emp = state.employees.find(e => e.id === empId);
+ if (!emp) return;
+ const c = emp.constraints;
+ const el = document.getElementById('editor-' + empId);
+ el.replaceChildren();
+
+ const form = document.createElement('div');
+ form.className = 'constraint-form';
+
+ // Helper: day toggle group
+ function makeDayGroup(label, constraintKey) {
+ const row = document.createElement('div');
+ row.className = 'constraint-row';
+ const lbl = document.createElement('label');
+ lbl.textContent = label;
+ const group = document.createElement('div');
+ group.className = 'day-toggle-group';
+ for (let i = 0; i < 5; i++) {
+ const day = WD_VALUES[i];
+ const btn = document.createElement('button');
+ btn.className = 'day-toggle' + (c[constraintKey].includes(day) ? ' active' : '');
+ btn.textContent = WD_LABELS[i];
+ btn.addEventListener('click', () => {
+ const arr = emp.constraints[constraintKey];
+ const idx = arr.indexOf(day);
+ if (idx >= 0) arr.splice(idx, 1); else arr.push(day);
+ btn.classList.toggle('active');
+ });
+ group.appendChild(btn);
+ }
+ row.append(lbl, group);
+ return row;
+ }
+
+ // Helper: number input
+ function makeNumberInput(label, constraintKey, placeholder) {
+ const row = document.createElement('div');
+ row.className = 'constraint-row';
+ const lbl = document.createElement('label');
+ lbl.textContent = label;
+ const input = document.createElement('input');
+ input.type = 'number';
+ input.min = '0';
+ input.style.width = '120px';
+ input.placeholder = placeholder;
+ input.value = c[constraintKey] != null ? c[constraintKey] : '';
+ input.addEventListener('change', () => {
+ const val = input.value.trim();
+ emp.constraints[constraintKey] = val === '' ? null : parseInt(val, 10);
+ });
+ row.append(lbl, input);
+ return row;
+ }
+
+ form.appendChild(makeDayGroup('Nie verfügbar', 'neverDays'));
+ form.appendChild(makeDayGroup('Niedrige Priorität / Home-Office', 'lowPriorityDays'));
+ form.appendChild(makeDayGroup('Nur an diesen Tagen (leer = alle)', 'onlyDays'));
+ form.appendChild(makeNumberInput('Max. Einsätze pro Monat', 'maxPerMonth', 'Kein Limit'));
+ form.appendChild(makeNumberInput('Mindestabstand (Tage)', 'minGapDays', '0'));
+
+ // Vacation section
+ const vacRow = document.createElement('div');
+ vacRow.className = 'constraint-row';
+ const vacLabel = document.createElement('label');
+ vacLabel.textContent = 'Urlaub';
+ const vacContainer = document.createElement('div');
+ vacContainer.id = 'vacations-' + empId;
+ const addVacBtn = document.createElement('button');
+ addVacBtn.textContent = '+ Urlaub hinzufügen';
+ addVacBtn.style.marginTop = '6px';
+ vacRow.append(vacLabel, vacContainer, addVacBtn);
+ form.appendChild(vacRow);
+
+ el.appendChild(form);
+
+ renderVacationList(empId);
+
+ addVacBtn.addEventListener('click', () => {
+ emp.constraints.vacations.push({ from: '', to: '' });
+ renderVacationList(empId);
+ });
+}
+
+function renderVacationList(empId) {
+ const emp = state.employees.find(e => e.id === empId);
+ const container = document.getElementById('vacations-' + empId);
+ container.replaceChildren();
+
+ if (emp.constraints.vacations.length === 0) {
+ const msg = document.createElement('span');
+ msg.style.cssText = 'color:#9ca3af;font-size:12px';
+ msg.textContent = 'Kein Urlaub eingetragen';
+ container.appendChild(msg);
+ return;
+ }
+
+ emp.constraints.vacations.forEach((v, i) => {
+ const row = document.createElement('div');
+ row.className = 'row';
+ row.style.marginBottom = '4px';
+
+ const fromInput = document.createElement('input');
+ fromInput.type = 'date';
+ fromInput.value = v.from;
+ fromInput.addEventListener('change', () => { emp.constraints.vacations[i].from = fromInput.value; });
+
+ const sep = document.createElement('span');
+ sep.textContent = 'bis';
+
+ const toInput = document.createElement('input');
+ toInput.type = 'date';
+ toInput.value = v.to;
+ toInput.addEventListener('change', () => { emp.constraints.vacations[i].to = toInput.value; });
+
+ const delBtn = document.createElement('button');
+ delBtn.textContent = '✕';
+ delBtn.addEventListener('click', () => {
+ emp.constraints.vacations.splice(i, 1);
+ renderVacationList(empId);
+ });
+
+ row.append(fromInput, sep, toInput, delBtn);
+ container.appendChild(row);
+ });
+}
+```
+
+- [ ] **Step 2: Verify in browser**
+
+1. Open `http://localhost:3000`
+2. Type "Anna Muster" + Enter → appears in list
+3. Click "Einschränkungen" → form expands below
+4. Toggle "Fr" under "Nie verfügbar" → button turns blue
+5. Enter 2 under "Max. Einsätze pro Monat"
+6. Click "+ Urlaub hinzufügen" → two date pickers appear
+7. Click "✕" next to vacation → removed
+8. Click "Entfernen" → employee removed
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add js/employees.js
+git commit -m "feat: employees tab with constraint editor (DOM-based, XSS-safe)"
+```
+
+---
+
+## Task 6: Calendar Tab
+
+**Files:**
+- Create: `js/calendar.js`
+
+- [ ] **Step 1: Create `js/calendar.js`**
+
+```javascript
+import { state } from './app.js';
+import { getWorkdays, generatePlan } from './algorithm.js';
+import { toggleHoliday, toggleClosure, addHistoryEntries } from './data.js';
+import { exportSchedule, downloadBlob } from './excel.js';
+
+const MONTHS = ['Januar','Februar','März','April','Mai','Juni',
+ 'Juli','August','September','Oktober','November','Dezember'];
+const WD_LABELS = ['Mo','Di','Mi','Do','Fr','Sa','So'];
+
+let currentYear = 2026;
+let currentMonth = Math.min(Math.max(new Date().getMonth() + 1, 1), 12);
+let currentPlan = []; // [{ date, employeeId, manual }] for displayed month
+
+export function renderCalendar() {
+ const container = document.getElementById('tab-planning');
+ container.replaceChildren();
+
+ // Title
+ const title = document.createElement('h2');
+ title.className = 'section-title';
+ title.textContent = 'Monatsplanung';
+ container.appendChild(title);
+
+ // Controls row
+ const controls = document.createElement('div');
+ controls.className = 'row';
+
+ const monthLabel = document.createElement('label');
+ monthLabel.textContent = 'Monat:';
+
+ const monthSel = document.createElement('select');
+ MONTHS.forEach((m, i) => {
+ const opt = document.createElement('option');
+ opt.value = String(i + 1);
+ opt.textContent = m;
+ if (i + 1 === currentMonth) opt.selected = true;
+ monthSel.appendChild(opt);
+ });
+
+ const yearLabel = document.createElement('label');
+ yearLabel.textContent = 'Jahr:';
+
+ const yearSel = document.createElement('select');
+ const yearOpt = document.createElement('option');
+ yearOpt.value = '2026';
+ yearOpt.textContent = '2026';
+ yearOpt.selected = true;
+ yearSel.appendChild(yearOpt);
+
+ const genBtn = document.createElement('button');
+ genBtn.className = 'primary';
+ genBtn.textContent = 'Plan generieren';
+
+ const exportBtn = document.createElement('button');
+ exportBtn.textContent = 'Als Excel exportieren';
+ exportBtn.style.display = 'none';
+
+ controls.append(monthLabel, monthSel, yearLabel, yearSel, genBtn, exportBtn);
+ container.appendChild(controls);
+
+ // Legend
+ const legend = document.createElement('div');
+ legend.className = 'row';
+ legend.style.cssText = 'font-size:12px;color:#6b7280';
+ legend.textContent = 'FT = Feiertag markieren | BU = Betriebsurlaub markieren';
+ container.appendChild(legend);
+
+ // Calendar grid wrapper
+ const wrapper = document.createElement('div');
+ wrapper.className = 'calendar-outer';
+ wrapper.id = 'calendar-wrapper';
+ container.appendChild(wrapper);
+
+ loadPlanFromHistory();
+ renderGrid(wrapper, exportBtn);
+
+ monthSel.addEventListener('change', () => {
+ currentMonth = parseInt(monthSel.value);
+ loadPlanFromHistory();
+ renderGrid(wrapper, exportBtn);
+ });
+
+ 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));
+ currentPlan = generatePlan(workdays, state.employees, historyWithoutMonth);
+ addHistoryEntries(state, currentPlan);
+ renderGrid(wrapper, exportBtn);
+ });
+
+ exportBtn.addEventListener('click', () => {
+ const blob = exportSchedule(currentPlan, state.employees);
+ downloadBlob(blob, 'Moderationsplan-' + MONTHS[currentMonth-1] + '-' + currentYear + '.xlsx');
+ });
+}
+
+function loadPlanFromHistory() {
+ const prefix = currentYear + '-' + String(currentMonth).padStart(2,'0');
+ currentPlan = state.history
+ .filter(h => h.date.startsWith(prefix))
+ .map(h => ({ ...h }));
+}
+
+function renderGrid(wrapper, exportBtn) {
+ wrapper.replaceChildren();
+
+ const daysInMonth = new Date(currentYear, currentMonth, 0).getDate();
+ const nameById = Object.fromEntries(state.employees.map(e => [e.id, e.name]));
+ const planByDate = Object.fromEntries(currentPlan.map(a => [a.date, a.employeeId]));
+
+ // Day-of-week header row
+ const headerGrid = document.createElement('div');
+ headerGrid.className = 'calendar-header-grid';
+ WD_LABELS.forEach(lbl => {
+ const cell = document.createElement('div');
+ cell.style.cssText = 'text-align:center;font-weight:600;font-size:12px;color:#6b7280;padding:4px';
+ cell.textContent = lbl;
+ headerGrid.appendChild(cell);
+ });
+ wrapper.appendChild(headerGrid);
+
+ // Body grid
+ const bodyGrid = document.createElement('div');
+ bodyGrid.className = 'calendar-body-grid';
+
+ // Offset empty cells before first day
+ const firstISO = currentYear + '-' + String(currentMonth).padStart(2,'0') + '-01';
+ const firstWD = (() => { const d = new Date(firstISO + 'T12:00:00Z').getUTCDay(); return d === 0 ? 7 : d; })();
+ for (let i = 1; i < firstWD; i++) {
+ const empty = document.createElement('div');
+ empty.className = 'cal-day empty-cell';
+ bodyGrid.appendChild(empty);
+ }
+
+ // Day cells
+ for (let d = 1; d <= daysInMonth; d++) {
+ const iso = currentYear + '-' + String(currentMonth).padStart(2,'0') + '-' + String(d).padStart(2,'0');
+ const wdNum = (() => { const x = new Date(iso + 'T12:00:00Z').getUTCDay(); return x === 0 ? 7 : x; })();
+ const isWeekend = wdNum >= 6;
+ const isHoliday = state.calendar.holidays.includes(iso);
+ const isClosure = state.calendar.companyClosures.includes(iso);
+ const assigneeId = planByDate[iso];
+
+ const cell = document.createElement('div');
+ cell.className = 'cal-day';
+ if (isWeekend) cell.classList.add('weekend');
+ else if (isHoliday) cell.classList.add('holiday');
+ else if (isClosure) cell.classList.add('closure');
+
+ // Day number + weekday label
+ const dayNum = document.createElement('div');
+ dayNum.className = 'day-num';
+ dayNum.textContent = d + ' ';
+ const wdSpan = document.createElement('span');
+ wdSpan.className = 'day-tag';
+ wdSpan.textContent = WD_LABELS[wdNum - 1];
+ dayNum.appendChild(wdSpan);
+ cell.appendChild(dayNum);
+
+ if (!isWeekend) {
+ if (isHoliday || isClosure) {
+ // Show label + remove button
+ const tag = document.createElement('div');
+ tag.style.cssText = 'font-size:11px;margin-top:4px';
+ tag.textContent = isHoliday ? 'Feiertag' : 'Betriebsurlaub';
+ const removeBtn = document.createElement('button');
+ removeBtn.textContent = '✕';
+ removeBtn.style.cssText = 'font-size:9px;padding:1px 4px;margin-left:4px';
+ removeBtn.addEventListener('click', () => {
+ if (isHoliday) toggleHoliday(state, iso);
+ else toggleClosure(state, iso);
+ renderGrid(wrapper, exportBtn);
+ });
+ tag.appendChild(removeBtn);
+ cell.appendChild(tag);
+ } else {
+ // Assignee dropdown
+ const sel = document.createElement('select');
+ sel.style.cssText = 'width:100%;margin-top:4px;font-size:11px';
+
+ 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; // textContent: safe
+ if (emp.id === assigneeId) opt.selected = true;
+ sel.appendChild(opt);
+ }
+
+ sel.addEventListener('change', () => {
+ const empId = sel.value;
+ if (empId) {
+ const entry = { date: iso, employeeId: empId, manual: true };
+ addHistoryEntries(state, [entry]);
+ const idx = currentPlan.findIndex(a => a.date === iso);
+ if (idx >= 0) currentPlan[idx] = entry; else currentPlan.push(entry);
+ } else {
+ state.history = state.history.filter(h => h.date !== iso);
+ currentPlan = currentPlan.filter(a => a.date !== iso);
+ }
+ exportBtn.style.display = currentPlan.length > 0 ? 'inline-block' : 'none';
+ });
+
+ cell.appendChild(sel);
+
+ // FT / BU mark buttons
+ const btnRow = document.createElement('div');
+ btnRow.style.cssText = 'margin-top:4px;display:flex;gap:2px';
+
+ const ftBtn = document.createElement('button');
+ ftBtn.textContent = 'FT';
+ ftBtn.style.cssText = 'font-size:9px;padding:1px 4px';
+ ftBtn.addEventListener('click', () => { toggleHoliday(state, iso); renderGrid(wrapper, exportBtn); });
+
+ const buBtn = document.createElement('button');
+ buBtn.textContent = 'BU';
+ buBtn.style.cssText = 'font-size:9px;padding:1px 4px';
+ buBtn.addEventListener('click', () => { toggleClosure(state, iso); renderGrid(wrapper, exportBtn); });
+
+ btnRow.append(ftBtn, buBtn);
+ cell.appendChild(btnRow);
+ }
+ }
+
+ bodyGrid.appendChild(cell);
+ }
+
+ wrapper.appendChild(bodyGrid);
+ exportBtn.style.display = currentPlan.length > 0 ? 'inline-block' : 'none';
+}
+```
+
+- [ ] **Step 2: Verify in browser**
+
+1. Add 5 employees in Mitarbeiter tab (e.g. Anna, Ben, Clara, David, Eva)
+2. Switch to Monatsplanung → calendar renders for current month
+3. Click "FT" on a weekday → cell turns yellow; click ✕ → reverts
+4. Click "BU" on a weekday → cell turns red
+5. Click "Plan generieren" → dropdowns show assigned names
+6. "Als Excel exportieren" appears
+7. Change a day via dropdown → shows new name
+8. Click export → xlsx downloads, open it: row 1 = names, row 2 = dates (dd.mm.yyyy)
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add js/calendar.js
+git commit -m "feat: calendar tab with generation, holiday marking and manual override"
+```
+
+---
+
+## Task 7: Data Tab
+
+**Files:**
+- Create: `js/history.js`
+
+- [ ] **Step 1: Create `js/history.js`**
+
+```javascript
+import { state } from './app.js';
+import { loadFromJSON, saveToJSON, removeHistoryEntry } from './data.js';
+import { downloadBlob } from './excel.js';
+
+export function renderDataTab() {
+ const container = document.getElementById('tab-data');
+ container.replaceChildren();
+
+ const title = document.createElement('h2');
+ title.className = 'section-title';
+ title.textContent = 'Daten';
+ container.appendChild(title);
+
+ const grid = document.createElement('div');
+ grid.style.cssText = 'display:grid;grid-template-columns:1fr 1fr;gap:32px';
+
+ // Left: save/load
+ const saveLoadCol = document.createElement('div');
+
+ const saveTitle = document.createElement('h3');
+ saveTitle.style.cssText = 'margin-bottom:12px;font-size:15px';
+ saveTitle.textContent = 'Speichern / Laden';
+ saveLoadCol.appendChild(saveTitle);
+
+ const saveRow = document.createElement('div');
+ saveRow.className = 'row';
+ const saveBtn = document.createElement('button');
+ saveBtn.className = 'primary';
+ saveBtn.textContent = 'Daten speichern (.json)';
+ saveRow.appendChild(saveBtn);
+ saveLoadCol.appendChild(saveRow);
+
+ const loadRow = document.createElement('div');
+ loadRow.className = 'row';
+ const loadBtn = document.createElement('button');
+ loadBtn.textContent = 'Daten laden (.json)';
+ const fileInput = document.createElement('input');
+ fileInput.type = 'file';
+ fileInput.accept = '.json';
+ fileInput.style.display = 'none';
+ loadRow.append(loadBtn, fileInput);
+ saveLoadCol.appendChild(loadRow);
+
+ const hint = document.createElement('p');
+ hint.style.cssText = 'color:#6b7280;font-size:12px;margin-top:8px';
+ hint.textContent = 'Speichert Mitarbeiterliste, Einschränkungen und Verlauf. Beim nächsten Mal diese Datei laden.';
+ saveLoadCol.appendChild(hint);
+
+ // Right: history
+ const historyCol = document.createElement('div');
+ const historyTitle = document.createElement('h3');
+ historyTitle.style.cssText = 'margin-bottom:12px;font-size:15px';
+ historyTitle.textContent = 'Verlauf';
+ historyCol.appendChild(historyTitle);
+
+ const historyTable = document.createElement('div');
+ historyTable.id = 'history-table';
+ historyCol.appendChild(historyTable);
+
+ grid.append(saveLoadCol, historyCol);
+ container.appendChild(grid);
+
+ renderHistoryTable();
+
+ saveBtn.addEventListener('click', () => {
+ const blob = new Blob([saveToJSON(state)], { type: 'application/json' });
+ downloadBlob(blob, 'morning-planner-daten.json');
+ });
+
+ loadBtn.addEventListener('click', () => fileInput.click());
+
+ fileInput.addEventListener('change', async e => {
+ const file = e.target.files[0];
+ if (!file) return;
+ try {
+ const loaded = loadFromJSON(await file.text());
+ state.employees = loaded.employees;
+ state.calendar = loaded.calendar;
+ state.history = loaded.history;
+ alert('Daten erfolgreich geladen.');
+ renderDataTab();
+ } catch (err) {
+ alert('Fehler beim Laden: ' + err.message);
+ }
+ e.target.value = '';
+ });
+}
+
+function renderHistoryTable() {
+ const el = document.getElementById('history-table');
+ el.replaceChildren();
+
+ if (state.history.length === 0) {
+ const msg = document.createElement('p');
+ msg.style.cssText = 'color:#9ca3af;font-size:13px';
+ msg.textContent = 'Noch keine Einträge im Verlauf.';
+ el.appendChild(msg);
+ return;
+ }
+
+ const nameById = Object.fromEntries(state.employees.map(e => [e.id, e.name]));
+ const sorted = [...state.history].sort((a, b) => b.date.localeCompare(a.date));
+
+ const table = document.createElement('table');
+ const thead = document.createElement('thead');
+ const headRow = document.createElement('tr');
+ ['Datum','Mitarbeiter','Typ',''].forEach(txt => {
+ const th = document.createElement('th');
+ th.textContent = txt;
+ headRow.appendChild(th);
+ });
+ thead.appendChild(headRow);
+ table.appendChild(thead);
+
+ const tbody = document.createElement('tbody');
+ for (const h of sorted) {
+ const [y, m, d] = h.date.split('-');
+ const tr = document.createElement('tr');
+
+ const dateTd = document.createElement('td');
+ dateTd.textContent = d + '.' + m + '.' + y;
+
+ const nameTd = document.createElement('td');
+ nameTd.textContent = nameById[h.employeeId] ?? '(unbekannt)'; // textContent: safe
+
+ const typeTd = document.createElement('td');
+ typeTd.style.color = '#6b7280';
+ typeTd.textContent = h.manual ? 'Manuell' : 'Auto';
+
+ const actionTd = document.createElement('td');
+ const delBtn = document.createElement('button');
+ delBtn.textContent = '✕';
+ delBtn.style.cssText = 'font-size:11px;padding:2px 6px';
+ delBtn.addEventListener('click', () => {
+ removeHistoryEntry(state, h.date);
+ renderHistoryTable();
+ });
+ actionTd.appendChild(delBtn);
+
+ tr.append(dateTd, nameTd, typeTd, actionTd);
+ tbody.appendChild(tr);
+ }
+ table.appendChild(tbody);
+ el.appendChild(table);
+}
+```
+
+- [ ] **Step 2: Verify in browser**
+
+1. Add employees, generate plan for a month
+2. Switch to "Daten" tab → history table shows all assigned days
+3. Click ✕ on an entry → removed from table
+4. Click "Daten speichern" → `morning-planner-daten.json` downloads
+5. Reload page → data gone
+6. Click "Daten laden" → upload the JSON → all data restored
+7. Switch to Monatsplanung → plan visible again
+
+- [ ] **Step 3: Final test run**
+
+```bash
+cd /Users/ferdinand/coding/morning-planner
+npm test
+```
+
+Expected:
+```
+All algorithm tests passed.
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add js/history.js
+git commit -m "feat: data tab with JSON load/save and history management"
+```
+
+---
+
+## Complete End-to-End Verification
+
+- [ ] Follow this workflow once before calling the app done:
+
+1. **Start fresh** — reload page, no data loaded
+2. **Mitarbeiter:** Add 5 employees. Set Anna: neverDays=Friday. Set Ben: lowPriority=Friday, maxPerMonth=3. Add Clara: vacation 2026-04-20 to 2026-04-24
+3. **Excel import:** Create a simple xlsx with header "Name" in A1 and 3 more names in A2:A4. Import → names appear without duplicates
+4. **Monatsplanung:** Select April 2026. Mark 2026-04-10 as Feiertag. Mark 2026-04-13 as Betriebsurlaub. Click "Plan generieren"
+5. **Verify:** 2026-04-10 and 2026-04-13 empty. Anna has no Friday assignments. 2026-04-20–24 not assigned to Clara
+6. **Override:** Change one day manually via dropdown
+7. **Export:** Click "Als Excel exportieren". Open xlsx: row 1 = names, row 2 = dates in dd.mm.yyyy. Days 10 and 13 absent
+8. **Daten:** Click "Daten speichern". Reload page. Load JSON. All data restored. Plan visible in Monatsplanung
+9. **Multi-month fairness:** Generate February 2026 plan. Employees with fewer April assignments get more February days
+
+- [ ] **Final commit**
+
+```bash
+git add -A
+git commit -m "feat: complete morning meeting planner v1.0"
+```