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:
1 parent
700ec6de1c
commit
e851bb2833
3 files changed
+54
-12
No files matched your search
+19
-7
@@ -78,16 +78,26 @@ function scoreCandidate(employee, isoDate, allHistory, randomFn) {
|
|||||||
|
|
||||||
// Returns { assignments: [{ date, employeeId, manual: false }], skippedDates: [] }
|
// Returns { assignments: [{ date, employeeId, manual: false }], skippedDates: [] }
|
||||||
// slotsPerDay controls how many top-scored candidates are picked per day (default 1).
|
// slotsPerDay controls how many top-scored candidates are picked per day (default 1).
|
||||||
export function generatePlan(workdays, employees, history, randomFn = Math.random, slotsPerDay = 1) {
|
export function generatePlan(workdays, employees, history, randomFn = Math.random, slotsPerDay = 1, preAssigned = []) {
|
||||||
const monthAssignments = [];
|
// 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 = [];
|
const skippedDates = [];
|
||||||
|
|
||||||
for (const date of workdays) {
|
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 =>
|
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);
|
skippedDates.push(date);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -96,10 +106,12 @@ export function generatePlan(workdays, employees, history, randomFn = Math.rando
|
|||||||
.map(e => ({ employee: e, score: scoreCandidate(e, date, history, randomFn) }))
|
.map(e => ({ employee: e, score: scoreCandidate(e, date, history, randomFn) }))
|
||||||
.sort((a, b) => b.score - a.score);
|
.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
for (let i = 0; i < slotsPerDay; i++) {
|
for (let i = 0; i < needed; i++) {
|
||||||
monthAssignments.push({ date, employeeId: scored[i].employee.id, manual: false });
|
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
@@ -123,12 +123,15 @@ export function renderCalendar() {
|
|||||||
const workdays = getWorkdays(currentYear, currentMonth,
|
const workdays = getWorkdays(currentYear, currentMonth,
|
||||||
state.calendar.holidays, state.calendar.companyClosures);
|
state.calendar.holidays, state.calendar.companyClosures);
|
||||||
const prefix = currentYear + '-' + String(currentMonth).padStart(2, '0');
|
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));
|
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
|
// Drop stale auto-entries for the month, keep history + manual picks
|
||||||
state.history = historyWithoutMonth;
|
state.history = historyWithoutMonth.concat(manualEntries);
|
||||||
const result = generatePlan(workdays, state.employees, historyWithoutMonth, Math.random, state.settings.moderatorsPerDay);
|
const result = generatePlan(workdays, state.employees, historyWithoutMonth,
|
||||||
currentPlan = result.assignments;
|
Math.random, state.settings.moderatorsPerDay, manualEntries);
|
||||||
addHistoryEntries(state, currentPlan);
|
addHistoryEntries(state, result.assignments);
|
||||||
|
currentPlan = manualEntries.concat(result.assignments);
|
||||||
saveState();
|
saveState();
|
||||||
renderSkippedWarning(warnBox, result.skippedDates);
|
renderSkippedWarning(warnBox, result.skippedDates);
|
||||||
renderGrid(wrapper, exportBtn);
|
renderGrid(wrapper, exportBtn);
|
||||||
|
|||||||
@@ -187,4 +187,31 @@ function emp(id, constraints = {}) {
|
|||||||
assert.deepEqual(result.skippedDates, ['2026-04-02'], 'Thu in skippedDates');
|
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.');
|
console.log('All algorithm tests passed.');
|
||||||
Reference in new issue
Block a user