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 <noreply@anthropic.com>
This commit is contained in:
ferdi2goandClaude Opus 4.8 committed 2026-06-03 12:44:45 +00:00
1 parent 074635c037
commit c076b5e1ce
1 file changed
+15 -10
+15 -10
View File
@@ -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<employeeId, number> 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++) {