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

43 KiB
Raw Permalink Blame History

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,'&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
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
  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
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
  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
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:
  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
git add -A
git commit -m "feat: complete morning meeting planner v1.0"