Files
Meeting-Mixer/docs/superpowers/plans/2026-04-07-morning-meeting-planner.md

1339 lines
43 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Morning Meeting Planner</title>
<link rel="stylesheet" href="style.css">
<script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js"></script>
</head>
<body>
<header>
<h1>Morning Meeting Planner</h1>
</header>
<nav class="tabs">
<button class="tab-btn active" data-tab="employees">Mitarbeiter</button>
<button class="tab-btn" data-tab="planning">Monatsplanung</button>
<button class="tab-btn" data-tab="data">Daten</button>
</nav>
<main>
<section id="tab-employees" class="tab-content"></section>
<section id="tab-planning" class="tab-content hidden"></section>
<section id="tab-data" class="tab-content hidden"></section>
</main>
<script type="module" src="js/app.js"></script>
</body>
</html>
```
- [ ] **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,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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-2024 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"
```