From fd9870871bf661ce7518594c2070829d38cd08d3 Mon Sep 17 00:00:00 2001 From: Ferdinand Urban Date: Wed, 3 Jun 2026 13:01:28 +0000 Subject: [PATCH] refactor: centralize shared date/name helpers in utils.js (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New js/utils.js exports toISO, monthPrefix, getWeekday, formatDateDE and buildNameMap. algorithm.js, calendar.js, excel.js and history.js now import these instead of re-implementing ISO construction, weekday math, DD.MM.YYYY formatting and the id→name map inline. Adds tests/utils.test.mjs. Co-Authored-By: Claude Opus 4.8 --- js/algorithm.js | 11 +---------- js/calendar.js | 15 ++++++++------- js/excel.js | 9 ++++----- js/history.js | 6 +++--- js/utils.js | 28 ++++++++++++++++++++++++++++ package.json | 2 +- tests/utils.test.mjs | 26 ++++++++++++++++++++++++++ 7 files changed, 71 insertions(+), 26 deletions(-) create mode 100644 js/utils.js create mode 100644 tests/utils.test.mjs diff --git a/js/algorithm.js b/js/algorithm.js index 088b206..9401509 100644 --- a/js/algorithm.js +++ b/js/algorithm.js @@ -1,13 +1,4 @@ -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; -} +import { toISO, getWeekday } from './utils.js'; function isOnVacation(employee, isoDate) { return employee.constraints.vacations.some(v => v.from <= isoDate && isoDate <= v.to); diff --git a/js/calendar.js b/js/calendar.js index afbba95..bee6e4e 100644 --- a/js/calendar.js +++ b/js/calendar.js @@ -2,6 +2,7 @@ import { state, saveState } from './app.js'; import { getWorkdays, generatePlan } from './algorithm.js'; import { toggleHoliday, toggleClosure, addHistoryEntries, removeHistoryEntry } from './data.js'; import { exportSchedule, downloadBlob } from './excel.js'; +import { toISO, monthPrefix, getWeekday, buildNameMap } from './utils.js'; const MONTHS = ['Januar','Februar','März','April','Mai','Juni', 'Juli','August','September','Oktober','November','Dezember']; @@ -122,7 +123,7 @@ export function renderCalendar() { } const workdays = getWorkdays(currentYear, currentMonth, state.calendar.holidays, state.calendar.companyClosures); - const prefix = currentYear + '-' + String(currentMonth).padStart(2, '0'); + const prefix = monthPrefix(currentYear, currentMonth); // Preserve manual picks for this month; only auto-entries get regenerated. const manualEntries = state.history.filter(h => h.date.startsWith(prefix) && h.manual); const historyWithoutMonth = state.history.filter(h => !h.date.startsWith(prefix)); @@ -148,7 +149,7 @@ export function renderCalendar() { } function loadPlanFromHistory() { - const prefix = currentYear + '-' + String(currentMonth).padStart(2,'0'); + const prefix = monthPrefix(currentYear, currentMonth); currentPlan = state.history .filter(h => h.date.startsWith(prefix)) .map(h => ({ ...h })); @@ -158,7 +159,7 @@ function renderGrid(wrapper, exportBtn) { wrapper.replaceChildren(); const daysInMonth = new Date(currentYear, currentMonth, 0).getDate(); - const empNameById = Object.fromEntries(state.employees.map(e => [e.id, e.name])); + const empNameById = buildNameMap(state.employees); const updateExportVisibility = () => { exportBtn.style.display = currentPlan.length > 0 ? 'inline-block' : 'none'; }; @@ -166,8 +167,8 @@ function renderGrid(wrapper, exportBtn) { // Build one day cell. Self-contained so a change to a single day re-renders // just that cell (cell.replaceWith) instead of rebuilding the whole grid. function makeDayCell(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 iso = toISO(currentYear, currentMonth, d); + const wdNum = getWeekday(iso); const isWeekend = wdNum >= 6; const isHoliday = state.calendar.holidays.includes(iso); const isClosure = state.calendar.companyClosures.includes(iso); @@ -291,8 +292,8 @@ function renderGrid(wrapper, exportBtn) { 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; })(); + const firstISO = toISO(currentYear, currentMonth, 1); + const firstWD = getWeekday(firstISO); for (let i = 1; i < firstWD; i++) { const empty = document.createElement('div'); empty.className = 'cal-day empty-cell'; diff --git a/js/excel.js b/js/excel.js index e2fd4ad..5643cf1 100644 --- a/js/excel.js +++ b/js/excel.js @@ -1,3 +1,5 @@ +import { formatDateDE, buildNameMap } from './utils.js'; + // Reads employee names from first column of xlsx (skips header row 0) export function readEmployeeNames(arrayBuffer) { const wb = XLSX.read(arrayBuffer, { type: 'array' }); @@ -19,7 +21,7 @@ export function sanitizeCell(value) { // Pure helper: returns { names: [...], dates: [...] } columns for a schedule. // Multiple employees per date are joined alphabetically with " & ". export function buildScheduleColumns(assignments, employees) { - const nameById = Object.fromEntries(employees.map(e => [e.id, e.name])); + const nameById = buildNameMap(employees); const byDate = new Map(); for (const a of assignments) { if (!byDate.has(a.date)) byDate.set(a.date, []); @@ -29,10 +31,7 @@ export function buildScheduleColumns(assignments, employees) { const names = sortedDates.map(d => sanitizeCell(byDate.get(d).slice().sort((x, y) => x.localeCompare(y, 'de')).join(' & ')) ); - const dates = sortedDates.map(d => { - const [y, m, day] = d.split('-'); - return day + '.' + m + '.' + y; - }); + const dates = sortedDates.map(formatDateDE); return { names, dates }; } diff --git a/js/history.js b/js/history.js index 4868ad9..7750710 100644 --- a/js/history.js +++ b/js/history.js @@ -1,6 +1,7 @@ import { state, saveState } from './app.js'; import { loadFromJSON, saveToJSON, removeHistoryEntry } from './data.js'; import { downloadBlob } from './excel.js'; +import { formatDateDE, buildNameMap } from './utils.js'; export function renderDataTab() { const container = document.getElementById('tab-data'); @@ -102,7 +103,7 @@ function renderHistoryTable() { return; } - const nameById = Object.fromEntries(state.employees.map(e => [e.id, e.name])); + const nameById = buildNameMap(state.employees); const sorted = [...state.history].sort((a, b) => { const dateCmp = b.date.localeCompare(a.date); if (dateCmp !== 0) return dateCmp; @@ -124,11 +125,10 @@ function renderHistoryTable() { 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; + dateTd.textContent = formatDateDE(h.date); const nameTd = document.createElement('td'); nameTd.textContent = nameById[h.employeeId] ?? '(unbekannt)'; // textContent: safe diff --git a/js/utils.js b/js/utils.js new file mode 100644 index 0000000..bd5da79 --- /dev/null +++ b/js/utils.js @@ -0,0 +1,28 @@ +// Shared pure helpers used across modules. + +// "YYYY-MM-DD" from numeric parts. +export function toISO(year, month, day) { + return year + '-' + String(month).padStart(2, '0') + '-' + String(day).padStart(2, '0'); +} + +// "YYYY-MM" month prefix (used to filter history by month). +export function monthPrefix(year, month) { + return year + '-' + String(month).padStart(2, '0'); +} + +// Weekday for an ISO date string: 1=Mo, 2=Di, 3=Mi, 4=Do, 5=Fr, 6=Sa, 7=So. +export function getWeekday(isoDate) { + const d = new Date(isoDate + 'T12:00:00Z').getUTCDay(); // 0=Sun + return d === 0 ? 7 : d; +} + +// "DD.MM.YYYY" from an ISO date string. +export function formatDateDE(isoDate) { + const [y, m, d] = isoDate.split('-'); + return d + '.' + m + '.' + y; +} + +// { [id]: name } lookup map from an employees array. +export function buildNameMap(employees) { + return Object.fromEntries(employees.map(e => [e.id, e.name])); +} diff --git a/package.json b/package.json index c4a0e0d..124ec8d 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,6 @@ "type": "module", "scripts": { "start": "npx serve . -p 3001", - "test": "node tests/algorithm.test.mjs && node tests/data.test.mjs && node tests/excel.test.mjs" + "test": "node tests/utils.test.mjs && node tests/algorithm.test.mjs && node tests/data.test.mjs && node tests/excel.test.mjs" } } diff --git a/tests/utils.test.mjs b/tests/utils.test.mjs new file mode 100644 index 0000000..a5337c4 --- /dev/null +++ b/tests/utils.test.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import { toISO, monthPrefix, getWeekday, formatDateDE, buildNameMap } from '../js/utils.js'; + +// toISO: zero-pads month and day +assert.equal(toISO(2026, 4, 1), '2026-04-01', 'toISO pads single digits'); +assert.equal(toISO(2026, 12, 31), '2026-12-31', 'toISO keeps two-digit parts'); + +// monthPrefix: YYYY-MM +assert.equal(monthPrefix(2026, 5), '2026-05', 'monthPrefix pads month'); + +// getWeekday: 1=Mon … 7=Sun (2026-04-01 is a Wednesday → 3; 2026-04-05 Sunday → 7) +assert.equal(getWeekday('2026-04-01'), 3, 'Wednesday → 3'); +assert.equal(getWeekday('2026-04-05'), 7, 'Sunday → 7'); +assert.equal(getWeekday('2026-04-06'), 1, 'Monday → 1'); + +// formatDateDE: ISO → DD.MM.YYYY +assert.equal(formatDateDE('2026-04-01'), '01.04.2026', 'formatDateDE DD.MM.YYYY'); + +// buildNameMap: id → name lookup +assert.deepEqual( + buildNameMap([{ id: 'a', name: 'Alice' }, { id: 'b', name: 'Bob' }]), + { a: 'Alice', b: 'Bob' }, + 'buildNameMap maps id to name' +); + +console.log('All utils tests passed.');