1339 lines
43 KiB
Markdown
1339 lines
43 KiB
Markdown
# 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,'&').replace(/</g,'<')
|
||
.replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
export function renderEmployees() {
|
||
const container = document.getElementById('tab-employees');
|
||
|
||
// Build static shell
|
||
const shell = document.createElement('div');
|
||
const title = document.createElement('h2');
|
||
title.className = 'section-title';
|
||
title.textContent = 'Mitarbeiter';
|
||
shell.appendChild(title);
|
||
|
||
const row = document.createElement('div');
|
||
row.className = 'row';
|
||
|
||
const nameInput = document.createElement('input');
|
||
nameInput.id = 'emp-name-input';
|
||
nameInput.type = 'text';
|
||
nameInput.placeholder = 'Name eingeben...';
|
||
nameInput.style.width = '220px';
|
||
|
||
const addBtn = document.createElement('button');
|
||
addBtn.className = 'primary';
|
||
addBtn.textContent = 'Hinzufügen';
|
||
|
||
const excelBtn = document.createElement('button');
|
||
excelBtn.textContent = 'Excel importieren';
|
||
|
||
const fileInput = document.createElement('input');
|
||
fileInput.id = 'emp-excel-input';
|
||
fileInput.type = 'file';
|
||
fileInput.accept = '.xlsx,.xls';
|
||
fileInput.style.display = 'none';
|
||
|
||
row.append(nameInput, addBtn, excelBtn, fileInput);
|
||
|
||
const list = document.createElement('div');
|
||
list.id = 'employee-list';
|
||
|
||
shell.append(row, list);
|
||
container.replaceChildren(shell);
|
||
|
||
refreshList();
|
||
|
||
addBtn.addEventListener('click', () => {
|
||
const name = nameInput.value.trim();
|
||
if (!name) return;
|
||
state.employees.push(createEmployee(name));
|
||
nameInput.value = '';
|
||
refreshList();
|
||
});
|
||
|
||
nameInput.addEventListener('keydown', e => { if (e.key === 'Enter') addBtn.click(); });
|
||
|
||
excelBtn.addEventListener('click', () => fileInput.click());
|
||
|
||
fileInput.addEventListener('change', async e => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
const buf = await file.arrayBuffer();
|
||
const names = readEmployeeNames(buf);
|
||
for (const name of names) {
|
||
if (!state.employees.some(emp => emp.name === name)) {
|
||
state.employees.push(createEmployee(name));
|
||
}
|
||
}
|
||
e.target.value = '';
|
||
refreshList();
|
||
});
|
||
}
|
||
|
||
function refreshList() {
|
||
const list = document.getElementById('employee-list');
|
||
list.replaceChildren();
|
||
|
||
if (state.employees.length === 0) {
|
||
const msg = document.createElement('p');
|
||
msg.style.cssText = 'color:#9ca3af;margin-top:16px';
|
||
msg.textContent = 'Noch keine Mitarbeiter hinzugefügt.';
|
||
list.appendChild(msg);
|
||
return;
|
||
}
|
||
|
||
for (const emp of state.employees) {
|
||
const item = document.createElement('div');
|
||
item.className = 'employee-item';
|
||
|
||
const nameSpan = document.createElement('span');
|
||
nameSpan.className = 'employee-name';
|
||
nameSpan.textContent = emp.name; // textContent: safe, no XSS
|
||
|
||
const editBtn = document.createElement('button');
|
||
editBtn.textContent = 'Einschränkungen';
|
||
|
||
const removeBtn = document.createElement('button');
|
||
removeBtn.className = 'danger';
|
||
removeBtn.textContent = 'Entfernen';
|
||
|
||
item.append(nameSpan, editBtn, removeBtn);
|
||
list.appendChild(item);
|
||
|
||
const editorDiv = document.createElement('div');
|
||
editorDiv.id = 'editor-' + emp.id;
|
||
editorDiv.style.display = 'none';
|
||
list.appendChild(editorDiv);
|
||
|
||
removeBtn.addEventListener('click', () => {
|
||
removeEmployee(state, emp.id);
|
||
refreshList();
|
||
});
|
||
|
||
editBtn.addEventListener('click', () => {
|
||
if (editorDiv.style.display === 'none') {
|
||
renderConstraintEditor(emp.id);
|
||
editorDiv.style.display = 'block';
|
||
} else {
|
||
editorDiv.style.display = 'none';
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
function renderConstraintEditor(empId) {
|
||
const emp = state.employees.find(e => e.id === empId);
|
||
if (!emp) return;
|
||
const c = emp.constraints;
|
||
const el = document.getElementById('editor-' + empId);
|
||
el.replaceChildren();
|
||
|
||
const form = document.createElement('div');
|
||
form.className = 'constraint-form';
|
||
|
||
// Helper: day toggle group
|
||
function makeDayGroup(label, constraintKey) {
|
||
const row = document.createElement('div');
|
||
row.className = 'constraint-row';
|
||
const lbl = document.createElement('label');
|
||
lbl.textContent = label;
|
||
const group = document.createElement('div');
|
||
group.className = 'day-toggle-group';
|
||
for (let i = 0; i < 5; i++) {
|
||
const day = WD_VALUES[i];
|
||
const btn = document.createElement('button');
|
||
btn.className = 'day-toggle' + (c[constraintKey].includes(day) ? ' active' : '');
|
||
btn.textContent = WD_LABELS[i];
|
||
btn.addEventListener('click', () => {
|
||
const arr = emp.constraints[constraintKey];
|
||
const idx = arr.indexOf(day);
|
||
if (idx >= 0) arr.splice(idx, 1); else arr.push(day);
|
||
btn.classList.toggle('active');
|
||
});
|
||
group.appendChild(btn);
|
||
}
|
||
row.append(lbl, group);
|
||
return row;
|
||
}
|
||
|
||
// Helper: number input
|
||
function makeNumberInput(label, constraintKey, placeholder) {
|
||
const row = document.createElement('div');
|
||
row.className = 'constraint-row';
|
||
const lbl = document.createElement('label');
|
||
lbl.textContent = label;
|
||
const input = document.createElement('input');
|
||
input.type = 'number';
|
||
input.min = '0';
|
||
input.style.width = '120px';
|
||
input.placeholder = placeholder;
|
||
input.value = c[constraintKey] != null ? c[constraintKey] : '';
|
||
input.addEventListener('change', () => {
|
||
const val = input.value.trim();
|
||
emp.constraints[constraintKey] = val === '' ? null : parseInt(val, 10);
|
||
});
|
||
row.append(lbl, input);
|
||
return row;
|
||
}
|
||
|
||
form.appendChild(makeDayGroup('Nie verfügbar', 'neverDays'));
|
||
form.appendChild(makeDayGroup('Niedrige Priorität / Home-Office', 'lowPriorityDays'));
|
||
form.appendChild(makeDayGroup('Nur an diesen Tagen (leer = alle)', 'onlyDays'));
|
||
form.appendChild(makeNumberInput('Max. Einsätze pro Monat', 'maxPerMonth', 'Kein Limit'));
|
||
form.appendChild(makeNumberInput('Mindestabstand (Tage)', 'minGapDays', '0'));
|
||
|
||
// Vacation section
|
||
const vacRow = document.createElement('div');
|
||
vacRow.className = 'constraint-row';
|
||
const vacLabel = document.createElement('label');
|
||
vacLabel.textContent = 'Urlaub';
|
||
const vacContainer = document.createElement('div');
|
||
vacContainer.id = 'vacations-' + empId;
|
||
const addVacBtn = document.createElement('button');
|
||
addVacBtn.textContent = '+ Urlaub hinzufügen';
|
||
addVacBtn.style.marginTop = '6px';
|
||
vacRow.append(vacLabel, vacContainer, addVacBtn);
|
||
form.appendChild(vacRow);
|
||
|
||
el.appendChild(form);
|
||
|
||
renderVacationList(empId);
|
||
|
||
addVacBtn.addEventListener('click', () => {
|
||
emp.constraints.vacations.push({ from: '', to: '' });
|
||
renderVacationList(empId);
|
||
});
|
||
}
|
||
|
||
function renderVacationList(empId) {
|
||
const emp = state.employees.find(e => e.id === empId);
|
||
const container = document.getElementById('vacations-' + empId);
|
||
container.replaceChildren();
|
||
|
||
if (emp.constraints.vacations.length === 0) {
|
||
const msg = document.createElement('span');
|
||
msg.style.cssText = 'color:#9ca3af;font-size:12px';
|
||
msg.textContent = 'Kein Urlaub eingetragen';
|
||
container.appendChild(msg);
|
||
return;
|
||
}
|
||
|
||
emp.constraints.vacations.forEach((v, i) => {
|
||
const row = document.createElement('div');
|
||
row.className = 'row';
|
||
row.style.marginBottom = '4px';
|
||
|
||
const fromInput = document.createElement('input');
|
||
fromInput.type = 'date';
|
||
fromInput.value = v.from;
|
||
fromInput.addEventListener('change', () => { emp.constraints.vacations[i].from = fromInput.value; });
|
||
|
||
const sep = document.createElement('span');
|
||
sep.textContent = 'bis';
|
||
|
||
const toInput = document.createElement('input');
|
||
toInput.type = 'date';
|
||
toInput.value = v.to;
|
||
toInput.addEventListener('change', () => { emp.constraints.vacations[i].to = toInput.value; });
|
||
|
||
const delBtn = document.createElement('button');
|
||
delBtn.textContent = '✕';
|
||
delBtn.addEventListener('click', () => {
|
||
emp.constraints.vacations.splice(i, 1);
|
||
renderVacationList(empId);
|
||
});
|
||
|
||
row.append(fromInput, sep, toInput, delBtn);
|
||
container.appendChild(row);
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify in browser**
|
||
|
||
1. Open `http://localhost:3000`
|
||
2. Type "Anna Muster" + Enter → appears in list
|
||
3. Click "Einschränkungen" → form expands below
|
||
4. Toggle "Fr" under "Nie verfügbar" → button turns blue
|
||
5. Enter 2 under "Max. Einsätze pro Monat"
|
||
6. Click "+ Urlaub hinzufügen" → two date pickers appear
|
||
7. Click "✕" next to vacation → removed
|
||
8. Click "Entfernen" → employee removed
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add js/employees.js
|
||
git commit -m "feat: employees tab with constraint editor (DOM-based, XSS-safe)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Calendar Tab
|
||
|
||
**Files:**
|
||
- Create: `js/calendar.js`
|
||
|
||
- [ ] **Step 1: Create `js/calendar.js`**
|
||
|
||
```javascript
|
||
import { state } from './app.js';
|
||
import { getWorkdays, generatePlan } from './algorithm.js';
|
||
import { toggleHoliday, toggleClosure, addHistoryEntries } from './data.js';
|
||
import { exportSchedule, downloadBlob } from './excel.js';
|
||
|
||
const MONTHS = ['Januar','Februar','März','April','Mai','Juni',
|
||
'Juli','August','September','Oktober','November','Dezember'];
|
||
const WD_LABELS = ['Mo','Di','Mi','Do','Fr','Sa','So'];
|
||
|
||
let currentYear = 2026;
|
||
let currentMonth = Math.min(Math.max(new Date().getMonth() + 1, 1), 12);
|
||
let currentPlan = []; // [{ date, employeeId, manual }] for displayed month
|
||
|
||
export function renderCalendar() {
|
||
const container = document.getElementById('tab-planning');
|
||
container.replaceChildren();
|
||
|
||
// Title
|
||
const title = document.createElement('h2');
|
||
title.className = 'section-title';
|
||
title.textContent = 'Monatsplanung';
|
||
container.appendChild(title);
|
||
|
||
// Controls row
|
||
const controls = document.createElement('div');
|
||
controls.className = 'row';
|
||
|
||
const monthLabel = document.createElement('label');
|
||
monthLabel.textContent = 'Monat:';
|
||
|
||
const monthSel = document.createElement('select');
|
||
MONTHS.forEach((m, i) => {
|
||
const opt = document.createElement('option');
|
||
opt.value = String(i + 1);
|
||
opt.textContent = m;
|
||
if (i + 1 === currentMonth) opt.selected = true;
|
||
monthSel.appendChild(opt);
|
||
});
|
||
|
||
const yearLabel = document.createElement('label');
|
||
yearLabel.textContent = 'Jahr:';
|
||
|
||
const yearSel = document.createElement('select');
|
||
const yearOpt = document.createElement('option');
|
||
yearOpt.value = '2026';
|
||
yearOpt.textContent = '2026';
|
||
yearOpt.selected = true;
|
||
yearSel.appendChild(yearOpt);
|
||
|
||
const genBtn = document.createElement('button');
|
||
genBtn.className = 'primary';
|
||
genBtn.textContent = 'Plan generieren';
|
||
|
||
const exportBtn = document.createElement('button');
|
||
exportBtn.textContent = 'Als Excel exportieren';
|
||
exportBtn.style.display = 'none';
|
||
|
||
controls.append(monthLabel, monthSel, yearLabel, yearSel, genBtn, exportBtn);
|
||
container.appendChild(controls);
|
||
|
||
// Legend
|
||
const legend = document.createElement('div');
|
||
legend.className = 'row';
|
||
legend.style.cssText = 'font-size:12px;color:#6b7280';
|
||
legend.textContent = 'FT = Feiertag markieren | BU = Betriebsurlaub markieren';
|
||
container.appendChild(legend);
|
||
|
||
// Calendar grid wrapper
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'calendar-outer';
|
||
wrapper.id = 'calendar-wrapper';
|
||
container.appendChild(wrapper);
|
||
|
||
loadPlanFromHistory();
|
||
renderGrid(wrapper, exportBtn);
|
||
|
||
monthSel.addEventListener('change', () => {
|
||
currentMonth = parseInt(monthSel.value);
|
||
loadPlanFromHistory();
|
||
renderGrid(wrapper, exportBtn);
|
||
});
|
||
|
||
genBtn.addEventListener('click', () => {
|
||
if (state.employees.length === 0) {
|
||
alert('Bitte zuerst Mitarbeiter hinzufügen.');
|
||
return;
|
||
}
|
||
const workdays = getWorkdays(currentYear, currentMonth,
|
||
state.calendar.holidays, state.calendar.companyClosures);
|
||
const prefix = currentYear + '-' + String(currentMonth).padStart(2,'0');
|
||
const historyWithoutMonth = state.history.filter(h => !h.date.startsWith(prefix));
|
||
currentPlan = generatePlan(workdays, state.employees, historyWithoutMonth);
|
||
addHistoryEntries(state, currentPlan);
|
||
renderGrid(wrapper, exportBtn);
|
||
});
|
||
|
||
exportBtn.addEventListener('click', () => {
|
||
const blob = exportSchedule(currentPlan, state.employees);
|
||
downloadBlob(blob, 'Moderationsplan-' + MONTHS[currentMonth-1] + '-' + currentYear + '.xlsx');
|
||
});
|
||
}
|
||
|
||
function loadPlanFromHistory() {
|
||
const prefix = currentYear + '-' + String(currentMonth).padStart(2,'0');
|
||
currentPlan = state.history
|
||
.filter(h => h.date.startsWith(prefix))
|
||
.map(h => ({ ...h }));
|
||
}
|
||
|
||
function renderGrid(wrapper, exportBtn) {
|
||
wrapper.replaceChildren();
|
||
|
||
const daysInMonth = new Date(currentYear, currentMonth, 0).getDate();
|
||
const nameById = Object.fromEntries(state.employees.map(e => [e.id, e.name]));
|
||
const planByDate = Object.fromEntries(currentPlan.map(a => [a.date, a.employeeId]));
|
||
|
||
// Day-of-week header row
|
||
const headerGrid = document.createElement('div');
|
||
headerGrid.className = 'calendar-header-grid';
|
||
WD_LABELS.forEach(lbl => {
|
||
const cell = document.createElement('div');
|
||
cell.style.cssText = 'text-align:center;font-weight:600;font-size:12px;color:#6b7280;padding:4px';
|
||
cell.textContent = lbl;
|
||
headerGrid.appendChild(cell);
|
||
});
|
||
wrapper.appendChild(headerGrid);
|
||
|
||
// Body grid
|
||
const bodyGrid = document.createElement('div');
|
||
bodyGrid.className = 'calendar-body-grid';
|
||
|
||
// Offset empty cells before first day
|
||
const firstISO = currentYear + '-' + String(currentMonth).padStart(2,'0') + '-01';
|
||
const firstWD = (() => { const d = new Date(firstISO + 'T12:00:00Z').getUTCDay(); return d === 0 ? 7 : d; })();
|
||
for (let i = 1; i < firstWD; i++) {
|
||
const empty = document.createElement('div');
|
||
empty.className = 'cal-day empty-cell';
|
||
bodyGrid.appendChild(empty);
|
||
}
|
||
|
||
// Day cells
|
||
for (let d = 1; d <= daysInMonth; d++) {
|
||
const iso = currentYear + '-' + String(currentMonth).padStart(2,'0') + '-' + String(d).padStart(2,'0');
|
||
const wdNum = (() => { const x = new Date(iso + 'T12:00:00Z').getUTCDay(); return x === 0 ? 7 : x; })();
|
||
const isWeekend = wdNum >= 6;
|
||
const isHoliday = state.calendar.holidays.includes(iso);
|
||
const isClosure = state.calendar.companyClosures.includes(iso);
|
||
const assigneeId = planByDate[iso];
|
||
|
||
const cell = document.createElement('div');
|
||
cell.className = 'cal-day';
|
||
if (isWeekend) cell.classList.add('weekend');
|
||
else if (isHoliday) cell.classList.add('holiday');
|
||
else if (isClosure) cell.classList.add('closure');
|
||
|
||
// Day number + weekday label
|
||
const dayNum = document.createElement('div');
|
||
dayNum.className = 'day-num';
|
||
dayNum.textContent = d + ' ';
|
||
const wdSpan = document.createElement('span');
|
||
wdSpan.className = 'day-tag';
|
||
wdSpan.textContent = WD_LABELS[wdNum - 1];
|
||
dayNum.appendChild(wdSpan);
|
||
cell.appendChild(dayNum);
|
||
|
||
if (!isWeekend) {
|
||
if (isHoliday || isClosure) {
|
||
// Show label + remove button
|
||
const tag = document.createElement('div');
|
||
tag.style.cssText = 'font-size:11px;margin-top:4px';
|
||
tag.textContent = isHoliday ? 'Feiertag' : 'Betriebsurlaub';
|
||
const removeBtn = document.createElement('button');
|
||
removeBtn.textContent = '✕';
|
||
removeBtn.style.cssText = 'font-size:9px;padding:1px 4px;margin-left:4px';
|
||
removeBtn.addEventListener('click', () => {
|
||
if (isHoliday) toggleHoliday(state, iso);
|
||
else toggleClosure(state, iso);
|
||
renderGrid(wrapper, exportBtn);
|
||
});
|
||
tag.appendChild(removeBtn);
|
||
cell.appendChild(tag);
|
||
} else {
|
||
// Assignee dropdown
|
||
const sel = document.createElement('select');
|
||
sel.style.cssText = 'width:100%;margin-top:4px;font-size:11px';
|
||
|
||
const emptyOpt = document.createElement('option');
|
||
emptyOpt.value = '';
|
||
emptyOpt.textContent = '— Leer —';
|
||
sel.appendChild(emptyOpt);
|
||
|
||
for (const emp of state.employees) {
|
||
const opt = document.createElement('option');
|
||
opt.value = emp.id;
|
||
opt.textContent = emp.name; // textContent: safe
|
||
if (emp.id === assigneeId) opt.selected = true;
|
||
sel.appendChild(opt);
|
||
}
|
||
|
||
sel.addEventListener('change', () => {
|
||
const empId = sel.value;
|
||
if (empId) {
|
||
const entry = { date: iso, employeeId: empId, manual: true };
|
||
addHistoryEntries(state, [entry]);
|
||
const idx = currentPlan.findIndex(a => a.date === iso);
|
||
if (idx >= 0) currentPlan[idx] = entry; else currentPlan.push(entry);
|
||
} else {
|
||
state.history = state.history.filter(h => h.date !== iso);
|
||
currentPlan = currentPlan.filter(a => a.date !== iso);
|
||
}
|
||
exportBtn.style.display = currentPlan.length > 0 ? 'inline-block' : 'none';
|
||
});
|
||
|
||
cell.appendChild(sel);
|
||
|
||
// FT / BU mark buttons
|
||
const btnRow = document.createElement('div');
|
||
btnRow.style.cssText = 'margin-top:4px;display:flex;gap:2px';
|
||
|
||
const ftBtn = document.createElement('button');
|
||
ftBtn.textContent = 'FT';
|
||
ftBtn.style.cssText = 'font-size:9px;padding:1px 4px';
|
||
ftBtn.addEventListener('click', () => { toggleHoliday(state, iso); renderGrid(wrapper, exportBtn); });
|
||
|
||
const buBtn = document.createElement('button');
|
||
buBtn.textContent = 'BU';
|
||
buBtn.style.cssText = 'font-size:9px;padding:1px 4px';
|
||
buBtn.addEventListener('click', () => { toggleClosure(state, iso); renderGrid(wrapper, exportBtn); });
|
||
|
||
btnRow.append(ftBtn, buBtn);
|
||
cell.appendChild(btnRow);
|
||
}
|
||
}
|
||
|
||
bodyGrid.appendChild(cell);
|
||
}
|
||
|
||
wrapper.appendChild(bodyGrid);
|
||
exportBtn.style.display = currentPlan.length > 0 ? 'inline-block' : 'none';
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify in browser**
|
||
|
||
1. Add 5 employees in Mitarbeiter tab (e.g. Anna, Ben, Clara, David, Eva)
|
||
2. Switch to Monatsplanung → calendar renders for current month
|
||
3. Click "FT" on a weekday → cell turns yellow; click ✕ → reverts
|
||
4. Click "BU" on a weekday → cell turns red
|
||
5. Click "Plan generieren" → dropdowns show assigned names
|
||
6. "Als Excel exportieren" appears
|
||
7. Change a day via dropdown → shows new name
|
||
8. Click export → xlsx downloads, open it: row 1 = names, row 2 = dates (dd.mm.yyyy)
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add js/calendar.js
|
||
git commit -m "feat: calendar tab with generation, holiday marking and manual override"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Data Tab
|
||
|
||
**Files:**
|
||
- Create: `js/history.js`
|
||
|
||
- [ ] **Step 1: Create `js/history.js`**
|
||
|
||
```javascript
|
||
import { state } from './app.js';
|
||
import { loadFromJSON, saveToJSON, removeHistoryEntry } from './data.js';
|
||
import { downloadBlob } from './excel.js';
|
||
|
||
export function renderDataTab() {
|
||
const container = document.getElementById('tab-data');
|
||
container.replaceChildren();
|
||
|
||
const title = document.createElement('h2');
|
||
title.className = 'section-title';
|
||
title.textContent = 'Daten';
|
||
container.appendChild(title);
|
||
|
||
const grid = document.createElement('div');
|
||
grid.style.cssText = 'display:grid;grid-template-columns:1fr 1fr;gap:32px';
|
||
|
||
// Left: save/load
|
||
const saveLoadCol = document.createElement('div');
|
||
|
||
const saveTitle = document.createElement('h3');
|
||
saveTitle.style.cssText = 'margin-bottom:12px;font-size:15px';
|
||
saveTitle.textContent = 'Speichern / Laden';
|
||
saveLoadCol.appendChild(saveTitle);
|
||
|
||
const saveRow = document.createElement('div');
|
||
saveRow.className = 'row';
|
||
const saveBtn = document.createElement('button');
|
||
saveBtn.className = 'primary';
|
||
saveBtn.textContent = 'Daten speichern (.json)';
|
||
saveRow.appendChild(saveBtn);
|
||
saveLoadCol.appendChild(saveRow);
|
||
|
||
const loadRow = document.createElement('div');
|
||
loadRow.className = 'row';
|
||
const loadBtn = document.createElement('button');
|
||
loadBtn.textContent = 'Daten laden (.json)';
|
||
const fileInput = document.createElement('input');
|
||
fileInput.type = 'file';
|
||
fileInput.accept = '.json';
|
||
fileInput.style.display = 'none';
|
||
loadRow.append(loadBtn, fileInput);
|
||
saveLoadCol.appendChild(loadRow);
|
||
|
||
const hint = document.createElement('p');
|
||
hint.style.cssText = 'color:#6b7280;font-size:12px;margin-top:8px';
|
||
hint.textContent = 'Speichert Mitarbeiterliste, Einschränkungen und Verlauf. Beim nächsten Mal diese Datei laden.';
|
||
saveLoadCol.appendChild(hint);
|
||
|
||
// Right: history
|
||
const historyCol = document.createElement('div');
|
||
const historyTitle = document.createElement('h3');
|
||
historyTitle.style.cssText = 'margin-bottom:12px;font-size:15px';
|
||
historyTitle.textContent = 'Verlauf';
|
||
historyCol.appendChild(historyTitle);
|
||
|
||
const historyTable = document.createElement('div');
|
||
historyTable.id = 'history-table';
|
||
historyCol.appendChild(historyTable);
|
||
|
||
grid.append(saveLoadCol, historyCol);
|
||
container.appendChild(grid);
|
||
|
||
renderHistoryTable();
|
||
|
||
saveBtn.addEventListener('click', () => {
|
||
const blob = new Blob([saveToJSON(state)], { type: 'application/json' });
|
||
downloadBlob(blob, 'morning-planner-daten.json');
|
||
});
|
||
|
||
loadBtn.addEventListener('click', () => fileInput.click());
|
||
|
||
fileInput.addEventListener('change', async e => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
try {
|
||
const loaded = loadFromJSON(await file.text());
|
||
state.employees = loaded.employees;
|
||
state.calendar = loaded.calendar;
|
||
state.history = loaded.history;
|
||
alert('Daten erfolgreich geladen.');
|
||
renderDataTab();
|
||
} catch (err) {
|
||
alert('Fehler beim Laden: ' + err.message);
|
||
}
|
||
e.target.value = '';
|
||
});
|
||
}
|
||
|
||
function renderHistoryTable() {
|
||
const el = document.getElementById('history-table');
|
||
el.replaceChildren();
|
||
|
||
if (state.history.length === 0) {
|
||
const msg = document.createElement('p');
|
||
msg.style.cssText = 'color:#9ca3af;font-size:13px';
|
||
msg.textContent = 'Noch keine Einträge im Verlauf.';
|
||
el.appendChild(msg);
|
||
return;
|
||
}
|
||
|
||
const nameById = Object.fromEntries(state.employees.map(e => [e.id, e.name]));
|
||
const sorted = [...state.history].sort((a, b) => b.date.localeCompare(a.date));
|
||
|
||
const table = document.createElement('table');
|
||
const thead = document.createElement('thead');
|
||
const headRow = document.createElement('tr');
|
||
['Datum','Mitarbeiter','Typ',''].forEach(txt => {
|
||
const th = document.createElement('th');
|
||
th.textContent = txt;
|
||
headRow.appendChild(th);
|
||
});
|
||
thead.appendChild(headRow);
|
||
table.appendChild(thead);
|
||
|
||
const tbody = document.createElement('tbody');
|
||
for (const h of sorted) {
|
||
const [y, m, d] = h.date.split('-');
|
||
const tr = document.createElement('tr');
|
||
|
||
const dateTd = document.createElement('td');
|
||
dateTd.textContent = d + '.' + m + '.' + y;
|
||
|
||
const nameTd = document.createElement('td');
|
||
nameTd.textContent = nameById[h.employeeId] ?? '(unbekannt)'; // textContent: safe
|
||
|
||
const typeTd = document.createElement('td');
|
||
typeTd.style.color = '#6b7280';
|
||
typeTd.textContent = h.manual ? 'Manuell' : 'Auto';
|
||
|
||
const actionTd = document.createElement('td');
|
||
const delBtn = document.createElement('button');
|
||
delBtn.textContent = '✕';
|
||
delBtn.style.cssText = 'font-size:11px;padding:2px 6px';
|
||
delBtn.addEventListener('click', () => {
|
||
removeHistoryEntry(state, h.date);
|
||
renderHistoryTable();
|
||
});
|
||
actionTd.appendChild(delBtn);
|
||
|
||
tr.append(dateTd, nameTd, typeTd, actionTd);
|
||
tbody.appendChild(tr);
|
||
}
|
||
table.appendChild(tbody);
|
||
el.appendChild(table);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify in browser**
|
||
|
||
1. Add employees, generate plan for a month
|
||
2. Switch to "Daten" tab → history table shows all assigned days
|
||
3. Click ✕ on an entry → removed from table
|
||
4. Click "Daten speichern" → `morning-planner-daten.json` downloads
|
||
5. Reload page → data gone
|
||
6. Click "Daten laden" → upload the JSON → all data restored
|
||
7. Switch to Monatsplanung → plan visible again
|
||
|
||
- [ ] **Step 3: Final test run**
|
||
|
||
```bash
|
||
cd /Users/ferdinand/coding/morning-planner
|
||
npm test
|
||
```
|
||
|
||
Expected:
|
||
```
|
||
All algorithm tests passed.
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add js/history.js
|
||
git commit -m "feat: data tab with JSON load/save and history management"
|
||
```
|
||
|
||
---
|
||
|
||
## Complete End-to-End Verification
|
||
|
||
- [ ] Follow this workflow once before calling the app done:
|
||
|
||
1. **Start fresh** — reload page, no data loaded
|
||
2. **Mitarbeiter:** Add 5 employees. Set Anna: neverDays=Friday. Set Ben: lowPriority=Friday, maxPerMonth=3. Add Clara: vacation 2026-04-20 to 2026-04-24
|
||
3. **Excel import:** Create a simple xlsx with header "Name" in A1 and 3 more names in A2:A4. Import → names appear without duplicates
|
||
4. **Monatsplanung:** Select April 2026. Mark 2026-04-10 as Feiertag. Mark 2026-04-13 as Betriebsurlaub. Click "Plan generieren"
|
||
5. **Verify:** 2026-04-10 and 2026-04-13 empty. Anna has no Friday assignments. 2026-04-20–24 not assigned to Clara
|
||
6. **Override:** Change one day manually via dropdown
|
||
7. **Export:** Click "Als Excel exportieren". Open xlsx: row 1 = names, row 2 = dates in dd.mm.yyyy. Days 10 and 13 absent
|
||
8. **Daten:** Click "Daten speichern". Reload page. Load JSON. All data restored. Plan visible in Monatsplanung
|
||
9. **Multi-month fairness:** Generate February 2026 plan. Employees with fewer April assignments get more February days
|
||
|
||
- [ ] **Final commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: complete morning meeting planner v1.0"
|
||
```
|