5
\$\begingroup\$

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).

\$\endgroup\$
1
  • 2
    \$\begingroup\$ Be very careful using 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 in 2023-12-31T23:00Z. \$\endgroup\$
    – RoToRa
    Commented Oct 28 at 11:50

1 Answer 1

3
\$\begingroup\$

would like a way to structure the programs that would have decoupled rules

Start with getting all those calculations and utility operations into object methods. With clear, short method calls, the larger existing structure becomes more apparent.

unclear

You want to be able to write code like this:

if(thisPerson.onLeave(thisDate)

if (thisPerson.is(thatPerson))  

if (today.isHoliday() || today.isWeekend())

onLeaveToday = #persons.onLeave(thisDate)

if(overLeaveLimit(onLeaveToday.count()))
  alertOnCall(); 

Make useful functions for the objects they work on.

Do not pull out individual properties and work with them disconnected from their objects. Reference onCallPerson.Id(for example) later, when needed

constructor(date, dateWeekPublicHolidayDates, persons, onCallPerson) {  //pass person objects, not the extracted property
    this.#date = date;
    this.#dateWeekPublicHolidayDates = new Set(dateWeekPublicHolidayDates.map(DateHelpers.toIsoDateOnlyString));
    this.#persons = persons;
    this.#onCallPerson = onCallPerson;
// removed annualLeavesPerPerson - this goes in person object.

person object

class Person {
  #id
  #firstName
  #lastName
  #email
  #holidays  // an array of discrete dates

  constructor(id, firstName, lastName, email, holidays = []) {
    this.#id        = id;
    this.#firstName = firstName;
    this.#lastName  = lastName;
    this.#email     = email;
    this.#holidays  = holidays;
}

// identity equality on 'id'
is (otherPerson) {
  return this.id === otherPerson.id;
}

onLeave (thisDate) {
  return holidays.includes(thisDate);
}

// we know 'holidays' is not null
hasLeave() {
  return #holidays.length > 0;
}

persons collection

I cannot say enough about how custom collections simplifies the hell out of client code. I am humbly gratified how this has contributed so much to a professional reputation for clear, concise, reliable code.

class persons {
  Array people;  // use Set if you want. Maybe better

  count() {return people.length}

  constructor (allCaptains = []) { 
    people = allCaptains
  }

  add(newPerson) {
    if(newPerson == null) return;
    if(people.some(e -> newPerson.is(e)) return;
    people.push(newPerson);
  }

  onLeave(onDate) {
    allOnLeave = new persons();

    for (const peep of this.people) {
      if (peep.onLeave(onDate))
         allOnLeave.Add(peep);
    }
    return allOnLeave;
  }

  onLeaveCount(onDate) {
    return onLeave(onDate).count();
  }

  notOnLeave(onDate) { 
    return people.filter(peep => !peep.onLeave(onDate));
  }
} //persons

Date Helpers

class DateHelpers(holidays = []) {

/* 
   I suspect accounting for the entire week will be best for
   long term maintenance. And the indexing now matches
   JS Date.
*/

  static get weekdayNames() {
    return ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
  }

   #publicHolidays = holidays;

   isHoliday(thisDate) { 
     return #publicHolidays.contains(thisDate)) ;
   }

   isWeekend(thisDate) { 
     return thisDate.getDay() == weekdayNames['Sunday'] ||
                                 weekdayNames['Saturday'] ;
   }

// not all holidays are on a work day
   isWeekendHoliday(thisDate) {
     return isHoliday(thisDate) && isWeekend(thisDate);
   }
} // DateHelpers

hard to understand

inversion of control (IOC) construction

new WeekAssignationConfiguration() is difficult with all that inline data creation.

==> Build the smallest objects and pass them into the container's constructor.

Avoid "newing" dependencies directly inside their containers; inlining constructor parameters is the same thing. It is HARD to work with. The WAC constructor speaks for itself.

In general, take an IOC approach; by design this explicitly demonstrates layered data dependencies structures

captains = new Persons();
marie = new Person('1', 'Marie', 'Moore', '[email protected]');
bob   = new Person('2', 'Bob', 'Smith', '[email protected]');
alice = new Person('3', 'Alice', 'Jones', '[email protected]');

// spread operator would allow one method to handle 0, 1, many arguments
// so always pass a date array, even with only one date or even none.
marie.addHoliday(new Date('2023-10-11')]
  bob.addHolidays([new Date('2023-10-11'), new Date('2023-10-13')])
alice.addHolidays([new Date('2023-10-09'), new Date('2023-10-11'), new Date('2023-10-13')])

captains.add(marie);
captains.add(bob);
captains.add(alice);

const weekAssignationConfiguration = new WeekAssignationConfiguration(
        new Date('2023-10-10'),
        [new Date('2023-10-09')],
        captains, marie
      );

Note "spread operator" comment above. Always pass collection objects, even if there is only one member in it. This avoids duplicate methods with different names; JS doesn't do method overloading well; i.e. same method name but different parameter types.

Do not leave collection properties null or undefined. Make empty arrays in constructors.



Add method to JS Date object

Given RoToRa warning about toISOString() I thought of making a custom date formatting function for all JS date objects.

Date.prototype.ymd = function() {
   return `${this.year}-${this.month}-${this.day}`
}

Put in Date's inheritance chain as shown all dates in your program have this function.

Put this code in the global level of your code file. It will be in effect only for your program when it is executing.

\$\endgroup\$
6
  • 1
    \$\begingroup\$ For readability use default parameter assignment. Example constructor(holidays = []) { rather than constructor(holidays) { this.holidays = holidays || []; Also don't use short circuit assignment, rather use safer nullish coalescing operator ?? example this.holidays = holidays ?? []; \$\endgroup\$
    – Blindman67
    Commented Oct 31 at 10:24
  • 1
    \$\begingroup\$ ++ default parameter asgnmt. Informative, documenty parameter lists are all the more so with functionally coherent Objects. || default => Oh holy Crockford forgive us our sins! The old falsey fakeout; Who Knew? That's why everyone should get his book.. AFAIK newer ecmascripts hide and swerve but do not eliminate JS's infamously interesting features. \$\endgroup\$
    – radarbob
    Commented Oct 31 at 18:28
  • 1
    \$\begingroup\$ nullish coalescing operator came with ES 2020. The PO tagged [tag: ecmascript-6] but the code is not explicitly restricted. I say go with the latest features available. \$\endgroup\$
    – radarbob
    Commented Oct 31 at 18:45
  • 1
    \$\begingroup\$ "OP" or "PO" (Post Owner)? \$\endgroup\$
    – radarbob
    Commented Nov 2 at 20:28
  • 1
    \$\begingroup\$ OP = Original Poster. \$\endgroup\$
    – Blindman67
    Commented Nov 2 at 21:30

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.