import { toISO, getWeekday } from './utils.js'; 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 != null && c.minGapDays > 0) { // Latest prior assignment date for this employee — single pass, no sort/alloc let lastDate = null; const isLater = a => a.employeeId === employee.id && a.date < isoDate && (lastDate === null || a.date > lastDate); for (const a of allHistory) if (isLater(a)) lastDate = a.date; for (const a of monthAssignments) if (isLater(a)) lastDate = a.date; if (lastDate && daysBetween(lastDate, isoDate) < c.minGapDays) return false; } return true; } // randomFn injectable so tests can pass () => 0.5 for deterministic output. // histCount is a precomputed Map of past assignments. function scoreCandidate(employee, isoDate, histCount, randomFn) { const wd = getWeekday(isoDate); const myTotal = histCount.get(employee.id) || 0; // 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 { 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, 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 = []; // Precompute each employee's history count once (used for fairness scoring) const histCount = new Map(); for (const h of history) histCount.set(h.employeeId, (histCount.get(h.employeeId) || 0) + 1); 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 => !takenIds.has(e.id) && isEligible(e, date, monthAssignments, history) ); if (candidates.length < needed) { skippedDates.push(date); continue; } const scored = candidates .map(e => ({ employee: e, score: scoreCandidate(e, date, histCount, randomFn) })) .sort((a, b) => b.score - a.score); 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: generated, skippedDates }; }