// Reine Planungslogik ohne Abhängigkeiten. Datumsangaben sind immer ISO-Strings (YYYY-MM-DD). export function addDays(iso, n) { const d = new Date(iso + 'T00:00:00Z'); d.setUTCDate(d.getUTCDate() + n); return d.toISOString().slice(0, 10); } export function isWeekend(iso) { const day = new Date(iso + 'T00:00:00Z').getUTCDay(); return day === 0 || day === 6; } /** ISO-Wochentag 1 (Mo) … 7 (So). */ export function isoWeekday(iso) { const day = new Date(iso + 'T00:00:00Z').getUTCDay(); return day === 0 ? 7 : day; } export function isValidDate(iso) { return typeof iso === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(iso) && !Number.isNaN(Date.parse(iso + 'T00:00:00Z')) && new Date(iso + 'T00:00:00Z').toISOString().slice(0, 10) === iso; } /** Alle Werktage (Mo–Fr) im Bereich, ohne die blockierten Tage (Feiertage, betriebsfrei). */ export function workingDays(from, to, blocked = new Set()) { const out = []; for (let d = from; d <= to; d = addDays(d, 1)) { if (!isWeekend(d) && !blocked.has(d)) out.push(d); } return out; } export function isAbsent(personId, date, absences) { return absences.some((a) => a.personId === personId && a.from <= date && a.to >= date); } /** Verfügbar = aktiv, nicht abwesend und kein fester freier Wochentag (Teilzeit). */ export function isAvailable(person, date, absences) { if (person.active === false) return false; if (Array.isArray(person.freeDays) && person.freeDays.includes(isoWeekday(date))) return false; return !isAbsent(person.id, date, absences); } /** * Erzeugt Zuteilungen für die angegebenen Tage. * * - persons: [{ id, canMain, canCo, active, freeDays }] * - absences: [{ personId, from, to }] * - existing: { date: { main, co, locked, off, keep, fill } } – locked/off/keep bleiben unverändert, * fill: vorhandene Slots behalten, nur leere auslosen * - blocked: Set blockierter Tage (Feiertage), damit „nicht zwei Werktage in Folge" darüber hinweg gilt * - history: { date: { main, co, off } } – frühere Zuteilungen für die Fairness-Zählung * - rng: Zufallsfunktion [0,1), austauschbar für Tests * * Fairness: Wer im Betrachtungsfenster am seltensten dran war, wird bevorzugt; unter Gleichen * entscheidet die Rollenverteilung, danach der Zufall. Niemand moderiert an zwei Werktagen * hintereinander, wenn es sich vermeiden lässt, und dasselbe Paar wiederholt sich ungern. */ export function generateSchedule({ dates, persons, absences = [], existing = {}, history = {}, blocked = new Set(), rng = Math.random, historyWindowDays = 60 }) { const result = {}; const counts = new Map(); const stat = (id) => { if (!counts.has(id)) counts.set(id, { total: 0, main: 0, co: 0, lastPartner: null }); return counts.get(id); }; const bump = (id, role, partner) => { const s = stat(id); s.total += 1; s[role] += 1; s.lastPartner = partner || null; }; const first = dates[0]; if (first) { const windowStart = addDays(first, -historyWindowDays); const past = Object.entries(history).filter(([date, a]) => date < first && date >= windowStart && a && !a.off).sort(); for (const [, a] of past) { if (a.main) bump(a.main, 'main', a.co); if (a.co) bump(a.co, 'co', a.main); } } const assignedOn = (date) => { const a = result[date] || history[date]; return a && !a.off ? [a.main, a.co].filter(Boolean) : []; }; const previousWorkingDay = (date) => { let d = addDays(date, -1); while (isWeekend(d) || blocked.has(d)) d = addDays(d, -1); return d; }; const active = persons.filter((p) => p.active !== false); for (const date of dates) { const ex = existing[date]; if (ex && (ex.locked || ex.off || ex.keep)) { const { keep, fill, ...rest } = ex; result[date] = { ...rest }; if (!ex.off) { if (ex.main) bump(ex.main, 'main', ex.co); if (ex.co) bump(ex.co, 'co', ex.main); } continue; } const fixedMain = ex && ex.fill ? ex.main || null : null; const fixedCo = ex && ex.fill ? ex.co || null : null; const yesterday = assignedOn(previousWorkingDay(date)); const available = active.filter((p) => isAvailable(p, date, absences)); const main = fixedMain || pick(available.filter((p) => p.canMain && p.id !== fixedCo), 'main', yesterday, null, stat, rng); const co = fixedCo || pick(available.filter((p) => p.canCo && p.id !== main), 'co', yesterday, main, stat, rng); result[date] = { main, co, locked: false }; if (main) bump(main, 'main', co); if (co) bump(co, 'co', main); } return result; } function pick(candidates, role, yesterday, partner, stat, rng) { if (candidates.length === 0) return null; let pool = candidates.filter((p) => !yesterday.includes(p.id)); if (pool.length === 0) pool = candidates; const minTotal = Math.min(...pool.map((p) => stat(p.id).total)); pool = pool.filter((p) => stat(p.id).total === minTotal); const minRole = Math.min(...pool.map((p) => stat(p.id)[role])); pool = pool.filter((p) => stat(p.id)[role] === minRole); if (partner) { const fresh = pool.filter((p) => stat(p.id).lastPartner !== partner); if (fresh.length > 0) pool = fresh; } return pool[Math.floor(rng() * pool.length)].id; } /** Zählt Einsätze je Person im Bereich – für die Statistik in der Oberfläche. */ export function countAssignments(assignments, from, to) { const out = {}; for (const [date, a] of Object.entries(assignments)) { if (date < from || date > to || !a || a.off) continue; for (const [role, id] of [['main', a.main], ['co', a.co]]) { if (!id) continue; out[id] = out[id] || { main: 0, co: 0, total: 0 }; out[id][role] += 1; out[id].total += 1; } } return out; }