From 0997689d827bb72067d7022fbf1f30dc8810b125 Mon Sep 17 00:00:00 2001 From: Ferdinand Date: Wed, 8 Apr 2026 10:29:56 +0200 Subject: [PATCH] docs: add implementation plan for morning meeting planner --- .../2026-04-07-morning-meeting-planner.md | 1338 +++++++++++++++++ 1 file changed, 1338 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-07-morning-meeting-planner.md 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" +```