From 065876ced4b44ea9752331e134a1360a28272cd0 Mon Sep 17 00:00:00 2001 From: Ferdinand Urban Date: Wed, 3 Jun 2026 12:20:41 +0000 Subject: [PATCH] fix(import): validate/normalize imported data, guard Excel I/O (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - loadFromJSON and the localStorage startup load now run employees through normalizeEmployees(): fills missing constraints, drops unusable rows, de-duplicates ids, and discards invalid vacation ranges — preventing later crashes (e.g. c.neverDays on undefined) and same-day double-books. - Excel import is wrapped in try/catch with a typeof XLSX guard and user feedback; the Excel export button gets the same guard. - createEmployee reuses the shared defaultConstraints() factory. Adds normalizeEmployees tests (defaults, dropping, dedup, vacations) and a loadFromJSON normalization test. Co-Authored-By: Claude Opus 4.8 --- js/app.js | 3 ++- js/calendar.js | 4 ++++ js/data.js | 57 ++++++++++++++++++++++++++++++++++++++------- js/employees.js | 29 +++++++++++++++++------ tests/data.test.mjs | 43 +++++++++++++++++++++++++++++++++- 5 files changed, 118 insertions(+), 18 deletions(-) diff --git a/js/app.js b/js/app.js index c6dedd5..6e9bc26 100644 --- a/js/app.js +++ b/js/app.js @@ -1,6 +1,7 @@ import { renderEmployees } from './employees.js'; import { renderCalendar } from './calendar.js'; import { renderDataTab } from './history.js'; +import { normalizeEmployees } from './data.js'; const STORAGE_KEY = 'morning-planner-state'; @@ -17,7 +18,7 @@ try { const saved = localStorage.getItem(STORAGE_KEY); if (saved) { const parsed = JSON.parse(saved); - if (Array.isArray(parsed.employees)) state.employees = parsed.employees; + if (Array.isArray(parsed.employees)) state.employees = normalizeEmployees(parsed.employees); if (parsed.calendar) state.calendar = parsed.calendar; if (Array.isArray(parsed.history)) state.history = parsed.history; if (parsed.settings && Number.isInteger(parsed.settings.moderatorsPerDay) diff --git a/js/calendar.js b/js/calendar.js index 8450bcb..c17712f 100644 --- a/js/calendar.js +++ b/js/calendar.js @@ -138,6 +138,10 @@ export function renderCalendar() { }); exportBtn.addEventListener('click', () => { + if (typeof XLSX === 'undefined') { + alert('Excel-Export nicht verfügbar (SheetJS konnte nicht geladen werden).'); + return; + } const blob = exportSchedule(currentPlan, state.employees); downloadBlob(blob, 'Moderationsplan-' + MONTHS[currentMonth-1] + '-' + currentYear + '.xlsx'); }); diff --git a/js/data.js b/js/data.js index 8564a9e..d8c29f9 100644 --- a/js/data.js +++ b/js/data.js @@ -19,6 +19,52 @@ function parseSettings(raw) { return { moderatorsPerDay: valid ? n : 1 }; } +function defaultConstraints() { + return { + neverDays: [], + lowPriorityDays: [], + onlyDays: [], + maxPerMonth: null, + minGapDays: 14, + vacations: [] + }; +} + +// Coerce an arbitrary constraints object into a valid, fully-populated one. +function normalizeConstraints(c) { + const raw = (c && typeof c === 'object') ? c : {}; + const arr = v => (Array.isArray(v) ? v : []); + return { + neverDays: arr(raw.neverDays), + lowPriorityDays: arr(raw.lowPriorityDays), + onlyDays: arr(raw.onlyDays), + maxPerMonth: Number.isFinite(raw.maxPerMonth) ? raw.maxPerMonth : null, + minGapDays: Number.isFinite(raw.minGapDays) ? raw.minGapDays : 14, + vacations: arr(raw.vacations).filter(v => + v && typeof v.from === 'string' && typeof v.to === 'string' && v.from <= v.to + ) + }; +} + +// Validate/repair an imported employee list: drop unusable rows, fill missing +// constraints, and deduplicate ids so downstream code never crashes or +// double-books. Returns a fresh, safe array. +export function normalizeEmployees(rawList) { + if (!Array.isArray(rawList)) return []; + const seen = new Set(); + const out = []; + for (const e of rawList) { + if (!e || typeof e !== 'object') continue; + const name = String(e.name ?? '').trim(); + if (!name) continue; // unusable without a name + let id = (typeof e.id === 'string' && e.id) ? e.id : generateId(); + if (seen.has(id)) id = generateId(); // resolve duplicate ids + seen.add(id); + out.push({ id, name, constraints: normalizeConstraints(e.constraints) }); + } + return out; +} + export function loadFromJSON(json) { let parsed; try { @@ -30,7 +76,7 @@ export function loadFromJSON(json) { throw new Error('Ungültiges Dateiformat: Objekt erwartet'); } return { - employees: Array.isArray(parsed.employees) ? parsed.employees : [], + employees: normalizeEmployees(parsed.employees), calendar: (parsed.calendar && typeof parsed.calendar === 'object') ? { holidays: Array.isArray(parsed.calendar.holidays) ? parsed.calendar.holidays : [], @@ -57,14 +103,7 @@ export function createEmployee(name) { return { id: generateId(), name: trimmed, - constraints: { - neverDays: [], - lowPriorityDays: [], - onlyDays: [], - maxPerMonth: null, - minGapDays: 14, - vacations: [] - } + constraints: defaultConstraints() }; } diff --git a/js/employees.js b/js/employees.js index e9700b0..94bf31d 100644 --- a/js/employees.js +++ b/js/employees.js @@ -69,16 +69,31 @@ export function renderEmployees() { 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)) { - try { state.employees.push(createEmployee(name)); } catch { /* skip invalid */ } + if (typeof XLSX === 'undefined') { + alert('Excel-Funktion nicht verfügbar (SheetJS konnte nicht geladen werden).'); + e.target.value = ''; + return; + } + try { + const buf = await file.arrayBuffer(); + const names = readEmployeeNames(buf); + if (names.length === 0) { + alert('Keine Namen in der Datei gefunden.'); + } else { + let added = 0; + for (const name of names) { + if (!state.employees.some(emp => emp.name === name)) { + try { state.employees.push(createEmployee(name)); added++; } catch { /* skip invalid */ } + } + } + saveState(); + refreshList(); + if (added === 0) alert('Alle Namen waren bereits vorhanden.'); } + } catch (err) { + alert('Excel-Import fehlgeschlagen: ' + (err?.message || err)); } e.target.value = ''; - saveState(); - refreshList(); }); } diff --git a/tests/data.test.mjs b/tests/data.test.mjs index 13d6404..998f9a4 100644 --- a/tests/data.test.mjs +++ b/tests/data.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { addHistoryEntries, removeHistoryEntry, loadFromJSON, saveToJSON } from '../js/data.js'; +import { addHistoryEntries, removeHistoryEntry, loadFromJSON, saveToJSON, normalizeEmployees } from '../js/data.js'; function makeState(history = []) { return { @@ -123,4 +123,45 @@ function makeState(history = []) { assert.deepEqual(parsed.settings, { moderatorsPerDay: 2 }, 'settings round-trip'); } +// normalizeEmployees: missing constraints are filled with defaults +{ + const out = normalizeEmployees([{ id: 'a', name: 'Alice' }]); + assert.equal(out.length, 1, 'employee kept'); + assert.deepEqual(out[0].constraints.neverDays, [], 'neverDays defaulted'); + assert.equal(out[0].constraints.minGapDays, 14, 'minGapDays defaulted to 14'); + assert.equal(out[0].constraints.maxPerMonth, null, 'maxPerMonth defaulted to null'); +} + +// normalizeEmployees: rows without a usable name are dropped +{ + const out = normalizeEmployees([{ id: 'a', name: ' ' }, { id: 'b' }, null, 42]); + assert.equal(out.length, 0, 'nameless/invalid rows dropped'); +} + +// normalizeEmployees: duplicate ids are de-duplicated to distinct ids +{ + const out = normalizeEmployees([{ id: 'dup', name: 'A' }, { id: 'dup', name: 'B' }]); + assert.equal(out.length, 2, 'both kept'); + assert.notEqual(out[0].id, out[1].id, 'duplicate id resolved'); +} + +// normalizeEmployees: invalid vacation ranges dropped, valid kept +{ + const out = normalizeEmployees([{ id: 'a', name: 'A', constraints: { vacations: [ + { from: '2026-05-10', to: '2026-05-05' }, // reversed → dropped + { from: '2026-06-01', to: '2026-06-07' } // valid → kept + ] } }]); + assert.equal(out[0].constraints.vacations.length, 1, 'only valid vacation kept'); + assert.equal(out[0].constraints.vacations[0].from, '2026-06-01', 'valid vacation preserved'); +} + +// loadFromJSON: employees without constraints are normalized (no later crash) +{ + const loaded = loadFromJSON(JSON.stringify({ + employees: [{ id: 'x', name: 'NoConstraints' }], + calendar: { holidays: [], companyClosures: [] }, history: [] + })); + assert.ok(Array.isArray(loaded.employees[0].constraints.neverDays), 'constraints present after load'); +} + console.log('All data tests passed.');