Files
ferdi2goandClaude Fable 5.1 95756778d8 Rotatory anlegen: Morning-Meeting-Moderation auslosen und nach Nextcloud schreiben
Express-App mit JSON-Speicher unter VCH_DATA_DIR, Monatskalender mit Fotos,
Personen mit Rollen und Teilzeit-Freitagen, Abwesenheiten, Feiertage je
Bundesland, faire Zufallsauslosung mit Fixieren und Neu-Auslosen.

Nextcloud: CalDAV-Discovery, Zielkalender wählbar, Termine je Werktag
schreiben und aktualisieren, Abwesenheiten aus Kalender importieren,
Personen samt Fotos aus dem Systemadressbuch übernehmen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 06:39:37 +00:00

152 lines
5.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}