I have been tinkering about a program that would pick the support "captain" of the day on workdays with the following rules:
- Program should only pick a captain on weekdays (Mon. to Fri.).
- Program should not pick a captain on weekends.
- Program should not pick a captain on Public Holidays.
- Program should not select the same person twice on two subsequent days, except for the designated on-call person if there is nobody else available.
- Program should run the assignation selection for the whole week that contains the given date, deterministically.
I have drafted something which seems to work:
class DateHelpers {
static get weekdayNames() {
return ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'];
}
static toIsoDateOnlyString(date) {
return date.toISOString().slice(0, 10)
}
}
class Person {
#id
#firstName
#lastName
#email
constructor(id, firstName, lastName, email) {
this.#id = id;
this.#firstName = firstName;
this.#lastName = lastName;
this.#email = email;
}
get id() {
return this.#id
}
get email() {
return this.#email;
}
get firstName() {
return this.#firstName;
}
get lastName() {
return this.#lastName;
}
get fullName() {
return `${this.firstName} ${this.lastName}`;
}
}
class WeekAssignationConfiguration {
#date
#dateWeekPublicHolidayDates
#persons
#onCallPersonId
#annualLeavesPerPersonId
constructor(date, dateWeekPublicHolidayDates, persons, onCallPersonId, annualLeavesPerPersonId) {
this.#date = date;
this.#dateWeekPublicHolidayDates = new Set(dateWeekPublicHolidayDates.map(DateHelpers.toIsoDateOnlyString));
this.#persons = persons;
this.#onCallPersonId = onCallPersonId;
this.#annualLeavesPerPersonId = annualLeavesPerPersonId;
}
get date() {
return this.#date;
}
get dateWeekPublicHolidayDates() {
return this.#dateWeekPublicHolidayDates;
}
get persons() {
return this.#persons;
}
get onCallPersonId() {
return this.#onCallPersonId;
}
get annualLeavesPerPersonId() {
return this.#annualLeavesPerPersonId;
}
}
class WeekAssignation {
static #getWeekNumber(date) {
const startOfYear = new Date(date.getFullYear(), 0, 1);
const pastDaysOfYear = (date - startOfYear) / 86400000;
return Math.ceil((pastDaysOfYear + startOfYear.getDay() + 1) / 7);
}
static #getStartOfWeek(referenceDate) {
const day = referenceDate.getDay();
const difference = (day > 0 ? day : 7) - 1; // Calculate the difference to the most recent Monday
const monday = new Date(referenceDate);
monday.setDate(referenceDate.getDate() - difference);
return monday;
}
static #getDateStringForWeekday(weekStartDate, weekdayIndex) {
const dateOfWeekday = new Date(weekStartDate);
dateOfWeekday.setDate(weekStartDate.getDate() + (weekdayIndex - 1));
return dateOfWeekday.toISOString().split('T')[0]; // return ISO format date
}
static #isOnLeave(person, date, annualLeavesPerPersonId) {
if (!annualLeavesPerPersonId[person]) return false;
for (let leaveDate of annualLeavesPerPersonId[person]) {
if (leaveDate.getFullYear() === date.getFullYear() && leaveDate.getMonth() === date.getMonth() && leaveDate.getDate() === date.getDate()) {
return true;
}
}
return false;
}
static #isPublicHoliday(date, publicHolidays) {
return publicHolidays.has(DateHelpers.toIsoDateOnlyString(date));
}
static of(configuration) {
const startOfWeek = WeekAssignation.#getStartOfWeek(new Date(configuration.date));
const currentWeekNumber = WeekAssignation.#getWeekNumber(configuration.date);
const assignments = [];
let previousPerson = null;
DateHelpers.weekdayNames.forEach((day, index) => {
const personIndex = (index + currentWeekNumber) % configuration.persons.length;
const initialPersonId = configuration.persons[personIndex].id;
const weekdayDateString = WeekAssignation.#getDateStringForWeekday(startOfWeek, index + 1);
const weekdayDate = new Date(weekdayDateString);
let finalPersonId = initialPersonId;
const peopleOnLeaves = configuration.persons.filter(p => WeekAssignation.#isOnLeave(p.id, weekdayDate, configuration.annualLeavesPerPersonId));
let heuristics = '';
if (WeekAssignation.#isPublicHoliday(weekdayDate, configuration.dateWeekPublicHolidayDates)) {
heuristics += 'Public holiday; ';
finalPersonId = 'No one';
} else if (WeekAssignation.#isOnLeave(initialPersonId, weekdayDate, configuration.annualLeavesPerPersonId)) {
heuristics += 'Initial person on leave; ';
let nextPersonIndex = (personIndex + 1) % configuration.persons.length;
let found = false;
while (nextPersonIndex !== personIndex) {
if (!WeekAssignation.#isOnLeave(configuration.persons[nextPersonIndex].id, weekdayDate, configuration.annualLeavesPerPersonId)) {
finalPersonId = configuration.persons[nextPersonIndex].id;
heuristics += `Reassign to ${configuration.persons[finalPersonId].id}; `;
found = true;
break;
}
nextPersonIndex = (nextPersonIndex + 1) % configuration.persons.length;
}
if (!found) {
finalPersonId = configuration.onCallPersonId;
heuristics += `Default to ${configuration.persons[finalPersonId].id}; `;
}
} else if (initialPersonId === previousPerson) {
heuristics += 'Initial person was previous day person; ';
let nextPersonIndex = (personIndex + 1) % configuration.persons.length;
let found = false;
while (nextPersonIndex !== personIndex) {
if (!WeekAssignation.#isOnLeave(configuration.persons[nextPersonIndex].id, weekdayDate, configuration.annualLeavesPerPersonId) && configuration.persons[nextPersonIndex] !== previousPerson) {
finalPersonId = configuration.persons[nextPersonIndex].id;
heuristics += `Reassign to ${configuration.persons[finalPersonId].id}; `;
found = true;
break;
}
nextPersonIndex = (nextPersonIndex + 1) % configuration.persons.length;
}
if (!found) {
finalPersonId = configuration.onCallPersonId;
heuristics += `Default to ${configuration.persons[finalPersonId]}; `;
}
}
if (finalPersonId === initialPersonId) {
heuristics += 'Initial person retained; ';
}
assignments.push({
day,
date: weekdayDateString,
index: index + 1,
isPublicHoliday: WeekAssignation.#isPublicHoliday(weekdayDate, configuration.dateWeekPublicHolidayDates) ? 'Yes' : 'No',
peopleOnLeaves: peopleOnLeaves.map(p => p.id).join(', '),
initialSelectedPerson: initialPersonId,
finalSelectedPerson: finalPersonId,
heuristics: heuristics.trim(),
});
previousPerson = finalPersonId;
});
return assignments;
}
static log(assignments) {
const columnHeaders = ['Day', 'Date', 'Index', 'Public Holiday', 'People on Leaves', 'Initial Selected Person', 'Final Selected Person', 'Heuristics'];
const maxColumnWidths = columnHeaders.map(header => header.length);
assignments.forEach(assignment => {
maxColumnWidths[0] = Math.max(maxColumnWidths[0], assignment.day.length);
maxColumnWidths[1] = Math.max(maxColumnWidths[1], assignment.date.length);
maxColumnWidths[2] = Math.max(maxColumnWidths[2], String(assignment.index).length);
maxColumnWidths[3] = Math.max(maxColumnWidths[3], assignment.isPublicHoliday.length);
maxColumnWidths[4] = Math.max(maxColumnWidths[4], assignment.peopleOnLeaves.length);
maxColumnWidths[5] = Math.max(maxColumnWidths[5], assignment.initialSelectedPerson.length);
maxColumnWidths[6] = Math.max(maxColumnWidths[6], assignment.finalSelectedPerson.length);
maxColumnWidths[7] = Math.max(maxColumnWidths[7], assignment.heuristics.length);
});
let markdownTable = '|';
columnHeaders.forEach((header, index) => {
markdownTable += ` ${header.padEnd(maxColumnWidths[index])} |`;
});
markdownTable += '\n|';
maxColumnWidths.forEach(width => {
markdownTable += `${'-'.repeat(width + 2)}|`;
});
markdownTable += '\n';
assignments.forEach(assignment => {
markdownTable += `| ${assignment.day.padEnd(maxColumnWidths[0])} | ${assignment.date.padEnd(maxColumnWidths[1])} | ${String(assignment.index).padEnd(maxColumnWidths[2])} | ${assignment.isPublicHoliday.padEnd(maxColumnWidths[3])} | ${assignment.peopleOnLeaves.padEnd(maxColumnWidths[4])} | ${assignment.initialSelectedPerson.padEnd(maxColumnWidths[5])} | ${assignment.finalSelectedPerson.padEnd(maxColumnWidths[6])} | ${assignment.heuristics.padEnd(maxColumnWidths[7])} |\n`;
});
console.log(markdownTable);
}
}
const weekAssignationConfiguration = new WeekAssignationConfiguration(
new Date('2023-10-10'),
[new Date('2023-10-09')],
[
new Person('1', 'Marie', 'Moore', '[email protected]'),
new Person('2', 'Bob', 'Smith', '[email protected]'),
new Person('3', 'Alice', 'Jones', '[email protected]')],
'1',
{
'1': [new Date('2023-10-11')],
'2': [new Date('2023-10-11'), new Date('2023-10-13')],
'3': [new Date('2023-10-09'), new Date('2023-10-11'), new Date('2023-10-13')],
});
const assignments = WeekAssignation.of(weekAssignationConfiguration);
WeekAssignation.log(assignments);
My issue with that implementation is that I've found it unclear/hard to follow and too verbose, I would like a way to structure the programs that would have decoupled rules (so that it needs be, I can add another rule easily).
toISOString()
. Personally I'd avoid it all together. It converts/formats the date-time to UTC timezone which can give an unexpected output:new Date('2024-01-01T00:00+01:00').toISOString()
results in2023-12-31T23:00Z
. \$\endgroup\$