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:
1 parent
e851bb2833
commit
065876ced4
5 files changed
+113
-13
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
+48
-9
@@ -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()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+17
-2
@@ -69,16 +69,31 @@ export function renderEmployees() {
|
||||
fileInput.addEventListener('change', async e => {
|
||||
const file = e.target.files[0];
|
||||
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 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)); } catch { /* skip invalid */ }
|
||||
try { state.employees.push(createEmployee(name)); added++; } catch { /* skip invalid */ }
|
||||
}
|
||||
}
|
||||
e.target.value = '';
|
||||
saveState();
|
||||
refreshList();
|
||||
if (added === 0) alert('Alle Namen waren bereits vorhanden.');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Excel-Import fehlgeschlagen: ' + (err?.message || err));
|
||||
}
|
||||
e.target.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+42
-1
@@ -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.');
|
||||
Reference in new issue
Block a user