fix(calendar): preserve manual picks when regenerating a month (#2)

generatePlan gains a preAssigned parameter: manual entries occupy slots,
count toward eligibility (maxPerMonth/minGap) and are never double-booked;
only the remaining slots per day are auto-filled. The generate handler now
keeps manual:true entries for the month instead of wiping them.

Adds tests for slot occupation, fully-manual days, and maxPerMonth counting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ferdi2goandClaude Opus 4.8 committed 2026-06-03 12:17:22 +00:00
1 parent 700ec6de1c
commit e851bb2833
3 files changed
+54 -12

No files matched your search

+19 -7
View File
@@ -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 };
}
+8 -5
View File
@@ -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);