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>
This commit is contained in:
ferdi2goandClaude Fable 5.1 committed 2026-09-16 06:39:37 +00:00
commit 95756778d8
26 files changed
+4697

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
// 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;
}