43 KiB
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
<!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
*, *::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
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
{
"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
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
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
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
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
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:
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)
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
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
// 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
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
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,'>').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
- Open
http://localhost:3000 - Type "Anna Muster" + Enter → appears in list
- Click "Einschränkungen" → form expands below
- Toggle "Fr" under "Nie verfügbar" → button turns blue
- Enter 2 under "Max. Einsätze pro Monat"
- Click "+ Urlaub hinzufügen" → two date pickers appear
- Click "✕" next to vacation → removed
- Click "Entfernen" → employee removed
- Step 3: Commit
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
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
- Add 5 employees in Mitarbeiter tab (e.g. Anna, Ben, Clara, David, Eva)
- Switch to Monatsplanung → calendar renders for current month
- Click "FT" on a weekday → cell turns yellow; click ✕ → reverts
- Click "BU" on a weekday → cell turns red
- Click "Plan generieren" → dropdowns show assigned names
- "Als Excel exportieren" appears
- Change a day via dropdown → shows new name
- Click export → xlsx downloads, open it: row 1 = names, row 2 = dates (dd.mm.yyyy)
- Step 3: Commit
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
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
- Add employees, generate plan for a month
- Switch to "Daten" tab → history table shows all assigned days
- Click ✕ on an entry → removed from table
- Click "Daten speichern" →
morning-planner-daten.jsondownloads - Reload page → data gone
- Click "Daten laden" → upload the JSON → all data restored
- Switch to Monatsplanung → plan visible again
- Step 3: Final test run
cd /Users/ferdinand/coding/morning-planner
npm test
Expected:
All algorithm tests passed.
- Step 4: Commit
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:
- Start fresh — reload page, no data loaded
- Mitarbeiter: Add 5 employees. Set Anna: neverDays=Friday. Set Ben: lowPriority=Friday, maxPerMonth=3. Add Clara: vacation 2026-04-20 to 2026-04-24
- Excel import: Create a simple xlsx with header "Name" in A1 and 3 more names in A2:A4. Import → names appear without duplicates
- Monatsplanung: Select April 2026. Mark 2026-04-10 as Feiertag. Mark 2026-04-13 as Betriebsurlaub. Click "Plan generieren"
- Verify: 2026-04-10 and 2026-04-13 empty. Anna has no Friday assignments. 2026-04-20–24 not assigned to Clara
- Override: Change one day manually via dropdown
- Export: Click "Als Excel exportieren". Open xlsx: row 1 = names, row 2 = dates in dd.mm.yyyy. Days 10 and 13 absent
- Daten: Click "Daten speichern". Reload page. Load JSON. All data restored. Plan visible in Monatsplanung
- Multi-month fairness: Generate February 2026 plan. Employees with fewer April assignments get more February days
- Final commit
git add -A
git commit -m "feat: complete morning meeting planner v1.0"