feat: algorithm module with unit tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
97
js/algorithm.js
Normal file
97
js/algorithm.js
Normal file
@@ -0,0 +1,97 @@
|
||||
function toISO(year, month, day) {
|
||||
return year + '-' + String(month).padStart(2, '0') + '-' + String(day).padStart(2, '0');
|
||||
}
|
||||
|
||||
// Returns 1=Mo, 2=Di, 3=Mi, 4=Do, 5=Fr, 6=Sa, 7=So
|
||||
function getWeekday(isoDate) {
|
||||
const d = new Date(isoDate + 'T12:00:00Z');
|
||||
const day = d.getUTCDay(); // 0=Sun
|
||||
return day === 0 ? 7 : day;
|
||||
}
|
||||
|
||||
function isOnVacation(employee, isoDate) {
|
||||
return employee.constraints.vacations.some(v => v.from <= isoDate && isoDate <= v.to);
|
||||
}
|
||||
|
||||
function daysBetween(isoA, isoB) {
|
||||
return Math.abs(
|
||||
(new Date(isoA + 'T12:00:00Z') - new Date(isoB + 'T12:00:00Z')) / 86400000
|
||||
);
|
||||
}
|
||||
|
||||
// Returns sorted array of ISO date strings that are workdays in the given month
|
||||
export function getWorkdays(year, month, holidays, companyClosures) {
|
||||
const days = [];
|
||||
const daysInMonth = new Date(year, month, 0).getDate();
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const iso = toISO(year, month, d);
|
||||
const wd = getWeekday(iso);
|
||||
if (wd >= 6) continue; // weekend
|
||||
if (holidays.includes(iso) || companyClosures.includes(iso)) continue;
|
||||
days.push(iso);
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
function isEligible(employee, isoDate, monthAssignments, allHistory) {
|
||||
const c = employee.constraints;
|
||||
const wd = getWeekday(isoDate);
|
||||
|
||||
if (c.neverDays.includes(wd)) return false;
|
||||
if (c.onlyDays.length > 0 && !c.onlyDays.includes(wd)) return false;
|
||||
if (isOnVacation(employee, isoDate)) return false;
|
||||
|
||||
if (c.maxPerMonth !== null) {
|
||||
const count = monthAssignments.filter(a => a.employeeId === employee.id).length;
|
||||
if (count >= c.maxPerMonth) return false;
|
||||
}
|
||||
|
||||
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);
|
||||
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) {
|
||||
const wd = getWeekday(isoDate);
|
||||
const myTotal = allHistory.filter(h => h.employeeId === employee.id).length;
|
||||
|
||||
// Fewer past assignments → higher (less negative) score
|
||||
let score = -myTotal;
|
||||
|
||||
// Low priority: increase negative score (less likely to be picked)
|
||||
if (employee.constraints.lowPriorityDays.includes(wd)) score -= 5;
|
||||
|
||||
// Jitter ±0.49
|
||||
score += (randomFn() - 0.5) * 0.98;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
// Returns [{ date, employeeId, manual: false }] for each assigned workday
|
||||
export function generatePlan(workdays, employees, history, randomFn = Math.random) {
|
||||
const monthAssignments = [];
|
||||
|
||||
for (const date of workdays) {
|
||||
const candidates = employees.filter(e =>
|
||||
isEligible(e, date, monthAssignments, history)
|
||||
);
|
||||
if (candidates.length === 0) continue;
|
||||
|
||||
const scored = candidates
|
||||
.map(e => ({ employee: e, score: scoreCandidate(e, date, history, randomFn) }))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
monthAssignments.push({ date, employeeId: scored[0].employee.id, manual: false });
|
||||
}
|
||||
|
||||
return monthAssignments;
|
||||
}
|
||||
Reference in New Issue
Block a user