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:
commit
95756778d8
26 files changed
+4697
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user