fix(import): validate/normalize imported data, guard Excel I/O (#3)

- 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 <noreply@anthropic.com>
This commit is contained in:
ferdi2goandClaude Opus 4.8 committed 2026-06-03 12:20:41 +00:00
1 parent e851bb2833
commit 065876ced4
5 files changed
+113 -13

No files matched your search

+2 -1
View File
@@ -1,6 +1,7 @@
import { renderEmployees } from './employees.js'; import { renderEmployees } from './employees.js';
import { renderCalendar } from './calendar.js'; import { renderCalendar } from './calendar.js';
import { renderDataTab } from './history.js'; import { renderDataTab } from './history.js';
import { normalizeEmployees } from './data.js';
const STORAGE_KEY = 'morning-planner-state'; const STORAGE_KEY = 'morning-planner-state';
@@ -17,7 +18,7 @@ try {
const saved = localStorage.getItem(STORAGE_KEY); const saved = localStorage.getItem(STORAGE_KEY);
if (saved) { if (saved) {
const parsed = JSON.parse(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 (parsed.calendar) state.calendar = parsed.calendar;
if (Array.isArray(parsed.history)) state.history = parsed.history; if (Array.isArray(parsed.history)) state.history = parsed.history;
if (parsed.settings && Number.isInteger(parsed.settings.moderatorsPerDay) if (parsed.settings && Number.isInteger(parsed.settings.moderatorsPerDay)
+4
View File
@@ -138,6 +138,10 @@ export function renderCalendar() {
}); });
exportBtn.addEventListener('click', () => { 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); const blob = exportSchedule(currentPlan, state.employees);
downloadBlob(blob, 'Moderationsplan-' + MONTHS[currentMonth-1] + '-' + currentYear + '.xlsx'); downloadBlob(blob, 'Moderationsplan-' + MONTHS[currentMonth-1] + '-' + currentYear + '.xlsx');
}); });
+48 -9
View File
@@ -19,6 +19,52 @@ function parseSettings(raw) {
return { moderatorsPerDay: valid ? n : 1 }; 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) { export function loadFromJSON(json) {
let parsed; let parsed;
try { try {
@@ -30,7 +76,7 @@ export function loadFromJSON(json) {
throw new Error('Ungültiges Dateiformat: Objekt erwartet'); throw new Error('Ungültiges Dateiformat: Objekt erwartet');
} }
return { return {
employees: Array.isArray(parsed.employees) ? parsed.employees : [], employees: normalizeEmployees(parsed.employees),
calendar: (parsed.calendar && typeof parsed.calendar === 'object') calendar: (parsed.calendar && typeof parsed.calendar === 'object')
? { ? {
holidays: Array.isArray(parsed.calendar.holidays) ? parsed.calendar.holidays : [], holidays: Array.isArray(parsed.calendar.holidays) ? parsed.calendar.holidays : [],
@@ -57,14 +103,7 @@ export function createEmployee(name) {
return { return {
id: generateId(), id: generateId(),
name: trimmed, name: trimmed,
constraints: { constraints: defaultConstraints()
neverDays: [],
lowPriorityDays: [],
onlyDays: [],
maxPerMonth: null,
minGapDays: 14,
vacations: []
}
}; };
} }
+17 -2
View File
@@ -69,16 +69,31 @@ export function renderEmployees() {
fileInput.addEventListener('change', async e => { fileInput.addEventListener('change', async e => {
const file = e.target.files[0]; const file = e.target.files[0];
if (!file) return; if (!file) return;
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 buf = await file.arrayBuffer();
const names = readEmployeeNames(buf); const names = readEmployeeNames(buf);
if (names.length === 0) {
alert('Keine Namen in der Datei gefunden.');
} else {
let added = 0;
for (const name of names) { for (const name of names) {
if (!state.employees.some(emp => emp.name === name)) { if (!state.employees.some(emp => emp.name === name)) {
try { state.employees.push(createEmployee(name)); } catch { /* skip invalid */ } try { state.employees.push(createEmployee(name)); added++; } catch { /* skip invalid */ }
} }
} }
e.target.value = '';
saveState(); saveState();
refreshList(); refreshList();
if (added === 0) alert('Alle Namen waren bereits vorhanden.');
}
} catch (err) {
alert('Excel-Import fehlgeschlagen: ' + (err?.message || err));
}
e.target.value = '';
}); });
} }
+42 -1
View File
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict'; 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 = []) { function makeState(history = []) {
return { return {
@@ -123,4 +123,45 @@ function makeState(history = []) {
assert.deepEqual(parsed.settings, { moderatorsPerDay: 2 }, 'settings round-trip'); 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.'); console.log('All data tests passed.');