From b5cfe5b336211ebbd41425bb1d621a13fcb709a8 Mon Sep 17 00:00:00 2001 From: Ferdinand Date: Wed, 8 Apr 2026 13:10:49 +0200 Subject: [PATCH] feat: algorithm module with unit tests Co-Authored-By: Claude Sonnet 4.6 --- js/algorithm.js | 97 +++++++++++++++++++++++++++++++++++++ package.json | 1 + tests/algorithm.test.mjs | 102 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 200 insertions(+) create mode 100644 js/algorithm.js create mode 100644 tests/algorithm.test.mjs diff --git a/js/algorithm.js b/js/algorithm.js new file mode 100644 index 0000000..63fe059 --- /dev/null +++ b/js/algorithm.js @@ -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; +} diff --git a/package.json b/package.json index 9223395..aba181b 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/tests/algorithm.test.mjs b/tests/algorithm.test.mjs new file mode 100644 index 0000000..19af5fa --- /dev/null +++ b/tests/algorithm.test.mjs @@ -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.');