From c076b5e1ced1dba451bf2505cbd72737bae8b1b5 Mon Sep 17 00:00:00 2001 From: Ferdinand Urban Date: Wed, 3 Jun 2026 12:44:45 +0000 Subject: [PATCH] perf(algorithm): precompute history count, drop per-candidate sort (#9) - generatePlan computes a histCount Map once; scoreCandidate now does an O(1) lookup instead of filtering the whole history per candidate per day. - minGap eligibility finds the latest prior date in a single pass instead of spreading history+monthAssignments into a new array and sorting it. Behaviour is unchanged (all algorithm tests pass). Co-Authored-By: Claude Opus 4.8 --- js/algorithm.js | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/js/algorithm.js b/js/algorithm.js index 589d039..088b206 100644 --- a/js/algorithm.js +++ b/js/algorithm.js @@ -47,22 +47,23 @@ function isEligible(employee, isoDate, monthAssignments, allHistory) { } if (c.minGapDays > 0) { - const allEntries = [...allHistory, ...monthAssignments]; - const lastDate = allEntries - .filter(a => a.employeeId === employee.id && a.date < isoDate) - .map(a => a.date) - .sort() - .at(-1); + // Latest prior assignment date for this employee — single pass, no sort/alloc + let lastDate = null; + const isLater = a => a.employeeId === employee.id && a.date < isoDate && + (lastDate === null || a.date > lastDate); + for (const a of allHistory) if (isLater(a)) lastDate = a.date; + for (const a of monthAssignments) if (isLater(a)) lastDate = a.date; if (lastDate && daysBetween(lastDate, isoDate) < c.minGapDays) return false; } return true; } -// randomFn injectable so tests can pass () => 0.5 for deterministic output -function scoreCandidate(employee, isoDate, allHistory, randomFn) { +// randomFn injectable so tests can pass () => 0.5 for deterministic output. +// histCount is a precomputed Map of past assignments. +function scoreCandidate(employee, isoDate, histCount, randomFn) { const wd = getWeekday(isoDate); - const myTotal = allHistory.filter(h => h.employeeId === employee.id).length; + const myTotal = histCount.get(employee.id) || 0; // Fewer past assignments → higher (less negative) score let score = -myTotal; @@ -87,6 +88,10 @@ export function generatePlan(workdays, employees, history, randomFn = Math.rando const generated = []; const skippedDates = []; + // Precompute each employee's history count once (used for fairness scoring) + const histCount = new Map(); + for (const h of history) histCount.set(h.employeeId, (histCount.get(h.employeeId) || 0) + 1); + for (const date of workdays) { const taken = monthAssignments.filter(a => a.date === date); const needed = slotsPerDay - taken.length; @@ -103,7 +108,7 @@ export function generatePlan(workdays, employees, history, randomFn = Math.rando } const scored = candidates - .map(e => ({ employee: e, score: scoreCandidate(e, date, history, randomFn) })) + .map(e => ({ employee: e, score: scoreCandidate(e, date, histCount, randomFn) })) .sort((a, b) => b.score - a.score); for (let i = 0; i < needed; i++) {