# 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" ```