From 8cd8908f38c7c417a7ee814b77b34d0d19e6251a Mon Sep 17 00:00:00 2001 From: Ferdinand Urban Date: Wed, 3 Jun 2026 12:51:16 +0000 Subject: [PATCH] perf(calendar): re-render only the changed day cell, not the whole grid (#10) Extracts the day-cell construction into a self-contained makeDayCell(d). Slot changes and holiday/closure toggles now replace just that one cell (cell.replaceWith) plus update export-button visibility, instead of rebuilding the entire month grid and re-attaching every listener on each interaction. Structural changes (month/year/slot-count) still do a full render. Faithful extraction; verified headless that the page loads without JS errors and tests pass. Interactive render coverage to follow in the test-layer issue. Co-Authored-By: Claude Opus 4.8 --- js/calendar.js | 100 ++++++++++++++++++++++++------------------------- 1 file changed, 49 insertions(+), 51 deletions(-) diff --git a/js/calendar.js b/js/calendar.js index c17712f..afbba95 100644 --- a/js/calendar.js +++ b/js/calendar.js @@ -159,44 +159,13 @@ function renderGrid(wrapper, exportBtn) { const daysInMonth = new Date(currentYear, currentMonth, 0).getDate(); const empNameById = Object.fromEntries(state.employees.map(e => [e.id, e.name])); - const entriesByDate = {}; - for (const a of currentPlan) { - if (!entriesByDate[a.date]) entriesByDate[a.date] = []; - entriesByDate[a.date].push(a); - } - // Sort each date's entries alphabetically by employee name (stable slot order) - for (const date of Object.keys(entriesByDate)) { - entriesByDate[date].sort((x, y) => - (empNameById[x.employeeId] ?? '').localeCompare(empNameById[y.employeeId] ?? '', 'de') - ); - } + const updateExportVisibility = () => { + exportBtn.style.display = currentPlan.length > 0 ? 'inline-block' : 'none'; + }; - // 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.className = 'cal-header-cell'; - 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++) { + // 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 isWeekend = wdNum >= 6; @@ -209,7 +178,6 @@ function renderGrid(wrapper, exportBtn) { 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 + ' '; @@ -219,9 +187,11 @@ function renderGrid(wrapper, exportBtn) { dayNum.appendChild(wdSpan); cell.appendChild(dayNum); + // Re-render only this cell after a change affecting this day + const refresh = () => { cell.replaceWith(makeDayCell(d)); updateExportVisibility(); }; + if (!isWeekend) { if (isHoliday || isClosure) { - // Show label + remove button const tag = document.createElement('div'); tag.className = 'cal-day-tag-label'; tag.textContent = isHoliday ? 'Feiertag' : 'Betriebsurlaub'; @@ -232,18 +202,18 @@ function renderGrid(wrapper, exportBtn) { if (isHoliday) toggleHoliday(state, iso); else toggleClosure(state, iso); saveState(); - renderGrid(wrapper, exportBtn); + refresh(); }); tag.appendChild(removeBtn); cell.appendChild(tag); } else { - // N slots per day const slotCount = state.settings.moderatorsPerDay; - const dayEntries = entriesByDate[iso] ?? []; // already alphabetically sorted + const dayEntries = currentPlan + .filter(a => a.date === iso) + .sort((x, y) => + (empNameById[x.employeeId] ?? '').localeCompare(empNameById[y.employeeId] ?? '', 'de')); const slotValues = []; // employeeId per slot index, '' if empty - for (let s = 0; s < slotCount; s++) { - slotValues.push(dayEntries[s]?.employeeId ?? ''); - } + for (let s = 0; s < slotCount; s++) slotValues.push(dayEntries[s]?.employeeId ?? ''); for (let slotIdx = 0; slotIdx < slotCount; slotIdx++) { const sel = document.createElement('select'); @@ -278,36 +248,64 @@ function renderGrid(wrapper, exportBtn) { currentPlan.push(entry); } saveState(); - renderGrid(wrapper, exportBtn); + refresh(); }); cell.appendChild(sel); } - // FT / BU mark buttons const btnRow = document.createElement('div'); btnRow.className = 'cal-btn-row'; const ftBtn = document.createElement('button'); ftBtn.type = 'button'; ftBtn.textContent = 'Feiertag'; - ftBtn.addEventListener('click', () => { toggleHoliday(state, iso); saveState(); renderGrid(wrapper, exportBtn); }); + ftBtn.addEventListener('click', () => { toggleHoliday(state, iso); saveState(); refresh(); }); const buBtn = document.createElement('button'); buBtn.type = 'button'; buBtn.textContent = 'Betriebsurl.'; - buBtn.addEventListener('click', () => { toggleClosure(state, iso); saveState(); renderGrid(wrapper, exportBtn); }); + buBtn.addEventListener('click', () => { toggleClosure(state, iso); saveState(); refresh(); }); btnRow.append(ftBtn, buBtn); cell.appendChild(btnRow); } } - bodyGrid.appendChild(cell); + return cell; + } + + // 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.className = 'cal-header-cell'; + 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 — each cell can re-render itself; no full-grid rebuild per click + for (let d = 1; d <= daysInMonth; d++) { + bodyGrid.appendChild(makeDayCell(d)); } wrapper.appendChild(bodyGrid); - exportBtn.style.display = currentPlan.length > 0 ? 'inline-block' : 'none'; + updateExportVisibility(); } function renderSkippedWarning(box, skippedDates) {