feat: algorithm module with unit tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ferdinand
2026-04-08 13:10:49 +02:00
parent b0dfd1f716
commit b5cfe5b336
3 changed files with 200 additions and 0 deletions

97
js/algorithm.js Normal file
View File

@@ -0,0 +1,97 @@
function toISO(year, month, day) {
return year + '-' + String(month).padStart(2, '0') + '-' + String(day).padStart(2, '0');
}
// Returns 1=Mo, 2=Di, 3=Mi, 4=Do, 5=Fr, 6=Sa, 7=So
function getWeekday(isoDate) {
const d = new Date(isoDate + 'T12:00:00Z');
const day = d.getUTCDay(); // 0=Sun
return day === 0 ? 7 : day;
}
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 > 0) {
const allEntries = [...allHistory, ...monthAssignments];
const lastDate = allEntries
.filter(a => a.employeeId === employee.id && a.date < isoDate)
.map(a => a.date)
.sort()
.at(-1);
if (lastDate && daysBetween(lastDate, isoDate) < c.minGapDays) return false;
}
return true;
}
// randomFn injectable so tests can pass () => 0.5 for deterministic output
function scoreCandidate(employee, isoDate, allHistory, randomFn) {
const wd = getWeekday(isoDate);
const myTotal = allHistory.filter(h => h.employeeId === employee.id).length;
// 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 -= 5;
// Jitter ±0.49
score += (randomFn() - 0.5) * 0.98;
return score;
}
// Returns [{ date, employeeId, manual: false }] for each assigned workday
export function generatePlan(workdays, employees, history, randomFn = Math.random) {
const monthAssignments = [];
for (const date of workdays) {
const candidates = employees.filter(e =>
isEligible(e, date, monthAssignments, history)
);
if (candidates.length === 0) continue;
const scored = candidates
.map(e => ({ employee: e, score: scoreCandidate(e, date, history, randomFn) }))
.sort((a, b) => b.score - a.score);
monthAssignments.push({ date, employeeId: scored[0].employee.id, manual: false });
}
return monthAssignments;
}

View File

@@ -2,6 +2,7 @@
"name": "morning-meeting-planner", "name": "morning-meeting-planner",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"type": "module",
"scripts": { "scripts": {
"start": "npx serve . -p 3000", "start": "npx serve . -p 3000",
"test": "node tests/algorithm.test.mjs" "test": "node tests/algorithm.test.mjs"

102
tests/algorithm.test.mjs Normal file
View File

@@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import { getWorkdays, generatePlan } from '../js/algorithm.js';
const fixed = () => 0.5; // deterministic random for tests
function emp(id, constraints = {}) {
return {
id, name: id,
constraints: {
neverDays: [], lowPriorityDays: [], onlyDays: [],
maxPerMonth: null, minGapDays: 0, vacations: [],
...constraints
}
};
}
// getWorkdays
{
const days = getWorkdays(2026, 4, [], []);
assert.equal(days.length, 22, 'April 2026: 22 workdays');
assert.equal(days[0], '2026-04-01', 'First workday = 2026-04-01');
assert.equal(days.at(-1), '2026-04-30', 'Last workday = 2026-04-30');
}
{
const days = getWorkdays(2026, 4, ['2026-04-01'], []);
assert.equal(days.length, 21, 'Holiday reduces count by 1');
assert.ok(!days.includes('2026-04-01'), 'Holiday excluded');
}
{
const days = getWorkdays(2026, 4, [], []);
assert.ok(!days.includes('2026-04-04'), '2026-04-04 Saturday excluded');
assert.ok(!days.includes('2026-04-05'), '2026-04-05 Sunday excluded');
}
// generatePlan — basic assignment
{
const result = generatePlan(['2026-04-01'], [emp('alice'), emp('bob')], [], fixed);
assert.equal(result.length, 1, 'One workday → one assignment');
assert.ok(['alice','bob'].includes(result[0].employeeId), 'Assigned to alice or bob');
}
// neverDays blocks employee
{
// 2026-04-01 is Wednesday (3)
const result = generatePlan(['2026-04-01'], [emp('alice', {neverDays:[3]}), emp('bob')], [], fixed);
assert.equal(result[0].employeeId, 'bob', 'alice blocked on Wednesday → bob assigned');
}
// onlyDays restricts to specific days
{
// 2026-04-01 = Wednesday (3), 2026-04-02 = Thursday (4)
const employees = [emp('alice', {onlyDays:[3]}), emp('bob')];
const result = generatePlan(['2026-04-01','2026-04-02'], employees, [], fixed);
const aliceOnThursday = result.find(r => r.date === '2026-04-02' && r.employeeId === 'alice');
assert.ok(!aliceOnThursday, 'alice with onlyDays:[3] not assigned on Thursday');
}
// vacation blocks employee
{
const employees = [
emp('alice', {vacations:[{from:'2026-04-01', to:'2026-04-03'}]}),
emp('bob')
];
const result = generatePlan(['2026-04-01'], employees, [], fixed);
assert.equal(result[0].employeeId, 'bob', 'alice on vacation → bob assigned');
}
// maxPerMonth cap
{
const days = ['2026-04-01','2026-04-02','2026-04-03'];
const employees = [emp('alice', {maxPerMonth:1}), emp('bob'), emp('clara')];
const result = generatePlan(days, employees, [], fixed);
const aliceCount = result.filter(r => r.employeeId === 'alice').length;
assert.ok(aliceCount <= 1, 'alice maxPerMonth=1 → assigned at most once');
}
// minGapDays
{
// 2026-04-06 = Monday, alice last assigned 2026-04-02 (Thu) = 4 days ago, gap=5 → blocked
const history = [{date:'2026-04-02', employeeId:'alice', manual:false}];
const employees = [emp('alice', {minGapDays:5}), emp('bob')];
const result = generatePlan(['2026-04-06'], employees, history, fixed);
assert.equal(result[0].employeeId, 'bob', 'alice within minGap (4<5) → bob assigned');
}
// No candidates → day skipped
{
// 2026-04-01 = Wednesday (3), only employee blocked
const result = generatePlan(['2026-04-01'], [emp('alice', {neverDays:[3]})], [], fixed);
assert.equal(result.length, 0, 'No candidate → day skipped');
}
// Fairness: less history = higher priority
{
const history = [1,2,3,4,5].map(i => ({
date: '2026-04-0' + i, employeeId: 'alice', manual: false
}));
const result = generatePlan(['2026-05-04'], [emp('alice'), emp('bob')], history, fixed);
assert.equal(result[0].employeeId, 'bob', 'bob (0 history) beats alice (5 history)');
}
console.log('All algorithm tests passed.');