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",
|
"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
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