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; }