diff --git a/js/algorithm.js b/js/algorithm.js index 9a64ad3..589d039 100644 --- a/js/algorithm.js +++ b/js/algorithm.js @@ -78,16 +78,26 @@ function scoreCandidate(employee, isoDate, allHistory, randomFn) { // Returns { assignments: [{ date, employeeId, manual: false }], skippedDates: [] } // slotsPerDay controls how many top-scored candidates are picked per day (default 1). -export function generatePlan(workdays, employees, history, randomFn = Math.random, slotsPerDay = 1) { - const monthAssignments = []; +export function generatePlan(workdays, employees, history, randomFn = Math.random, slotsPerDay = 1, preAssigned = []) { + // preAssigned entries (e.g. manual picks for this month) are kept fixed: they + // occupy slots, count toward eligibility, and are never double-booked. Only + // the remaining slots per day are auto-filled. Returns only the newly + // generated assignments (not the preAssigned ones). + const monthAssignments = [...preAssigned]; + const generated = []; const skippedDates = []; for (const date of workdays) { + const taken = monthAssignments.filter(a => a.date === date); + const needed = slotsPerDay - taken.length; + if (needed <= 0) continue; // already fully staffed (e.g. by manual entries) + + const takenIds = new Set(taken.map(a => a.employeeId)); const candidates = employees.filter(e => - isEligible(e, date, monthAssignments, history) + !takenIds.has(e.id) && isEligible(e, date, monthAssignments, history) ); - if (candidates.length < slotsPerDay) { + if (candidates.length < needed) { skippedDates.push(date); continue; } @@ -96,10 +106,12 @@ export function generatePlan(workdays, employees, history, randomFn = Math.rando .map(e => ({ employee: e, score: scoreCandidate(e, date, history, randomFn) })) .sort((a, b) => b.score - a.score); - for (let i = 0; i < slotsPerDay; i++) { - monthAssignments.push({ date, employeeId: scored[i].employee.id, manual: false }); + for (let i = 0; i < needed; i++) { + const entry = { date, employeeId: scored[i].employee.id, manual: false }; + monthAssignments.push(entry); + generated.push(entry); } } - return { assignments: monthAssignments, skippedDates }; + return { assignments: generated, skippedDates }; } diff --git a/js/calendar.js b/js/calendar.js index 6b67b65..8450bcb 100644 --- a/js/calendar.js +++ b/js/calendar.js @@ -123,12 +123,15 @@ export function renderCalendar() { const workdays = getWorkdays(currentYear, currentMonth, state.calendar.holidays, state.calendar.companyClosures); const prefix = currentYear + '-' + String(currentMonth).padStart(2, '0'); + // 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)); - // Reset month entries from history so dedup-by-(date,employeeId) doesn't keep stale rows - state.history = historyWithoutMonth; - const result = generatePlan(workdays, state.employees, historyWithoutMonth, Math.random, state.settings.moderatorsPerDay); - currentPlan = result.assignments; - addHistoryEntries(state, currentPlan); + // Drop stale auto-entries for the month, keep history + manual picks + state.history = historyWithoutMonth.concat(manualEntries); + const result = generatePlan(workdays, state.employees, historyWithoutMonth, + Math.random, state.settings.moderatorsPerDay, manualEntries); + addHistoryEntries(state, result.assignments); + currentPlan = manualEntries.concat(result.assignments); saveState(); renderSkippedWarning(warnBox, result.skippedDates); renderGrid(wrapper, exportBtn); diff --git a/tests/algorithm.test.mjs b/tests/algorithm.test.mjs index 8b9c957..0e37250 100644 --- a/tests/algorithm.test.mjs +++ b/tests/algorithm.test.mjs @@ -187,4 +187,31 @@ function emp(id, constraints = {}) { assert.deepEqual(result.skippedDates, ['2026-04-02'], 'Thu in skippedDates'); } +// preAssigned: manual pick occupies a slot, only remaining slots auto-filled, no double-book +{ + const employees = [emp('alice'), emp('bob'), emp('clara')]; + const manual = [{ date: '2026-04-01', employeeId: 'alice', manual: true }]; + const result = generatePlan(['2026-04-01'], employees, [], fixed, 2, manual); + assert.equal(result.assignments.length, 1, 'slotsPerDay=2 with 1 manual → 1 auto entry'); + assert.notEqual(result.assignments[0].employeeId, 'alice', 'manual employee not double-booked'); + assert.equal(result.assignments[0].manual, false, 'auto entry has manual:false'); +} + +// preAssigned: day fully staffed by manual entries → nothing generated, not skipped +{ + const employees = [emp('alice'), emp('bob')]; + const manual = [{ date: '2026-04-01', employeeId: 'alice', manual: true }]; + const result = generatePlan(['2026-04-01'], employees, [], fixed, 1, manual); + assert.equal(result.assignments.length, 0, 'fully manual day → no auto entries'); + assert.deepEqual(result.skippedDates, [], 'fully manual day not skipped'); +} + +// preAssigned counts toward maxPerMonth eligibility +{ + const employees = [emp('alice', { maxPerMonth: 1 }), emp('bob'), emp('clara')]; + const manual = [{ date: '2026-04-01', employeeId: 'alice', manual: true }]; + const result = generatePlan(['2026-04-02'], employees, [], fixed, 1, manual); + assert.notEqual(result.assignments[0].employeeId, 'alice', 'manual entry counts toward maxPerMonth'); +} + console.log('All algorithm tests passed.');