feat: algorithm module with unit tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
97
js/algorithm.js
Normal file
97
js/algorithm.js
Normal 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;
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "morning-meeting-planner",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "npx serve . -p 3000",
|
||||
"test": "node tests/algorithm.test.mjs"
|
||||
|
||||
102
tests/algorithm.test.mjs
Normal file
102
tests/algorithm.test.mjs
Normal 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.');
|
||||
Reference in New Issue
Block a user