Files
Meeting-Mixer/js/algorithm.js
T
ferdi2goandClaude Opus 4.8 836d0eeda5 chore: unify name to "Meeting Mixer", README tech stack, name magic numbers (#16)
- Canonical name "Meeting Mixer" everywhere: <title>, README, .vch-description
  (now non-technical per convention) and package.json (meeting-mixer).
- README gains a Tech-Stack section and an accurate, non-misleading port note.
- Magic numbers replaced by named constants: LOW_PRIORITY_PENALTY in
  algorithm.js; MIN/MAX_MODERATORS_PER_DAY in utils.js, used by data.js,
  calendar.js and app.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:44:45 +00:00

117 lines
4.3 KiB
JavaScript

import { toISO, getWeekday } from './utils.js';
// Score penalty applied when a candidate is low-priority on the given weekday.
const LOW_PRIORITY_PENALTY = 5;
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<employeeId, number> 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 -= LOW_PRIORITY_PENALTY;
// 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 };
}