Differences
This shows you the differences between two versions of the page.
Next revision | Previous revision | ||
wiki:facility:software:scheduling_documentation [2025/04/16 11:48] – created Max Breitmeyer | wiki:facility:software:scheduling_documentation [2025/05/04 16:22] (current) – Shanmukha Doddipatla | ||
---|---|---|---|
Line 1: | Line 1: | ||
- | START HERE | + | **Overview** |
+ | |||
+ | The Observatory Shift Scheduler is a Google Apps Script that automatically assigns staff to different shift types based on: | ||
+ | Staff availability submissions | ||
+ | Seasonal shift timeframes | ||
+ | Fair distribution of shifts among operators | ||
+ | Specific scheduling constraints | ||
+ | The script reads data from multiple sheets, performs the scheduling algorithm, and outputs the results to a " | ||
+ | |||
+ | **How It Works** | ||
+ | |||
+ | Data Sources | ||
+ | The script uses three key data sources: | ||
+ | Shift Timeframes sheet: Contains seasonal information about shift start times | ||
+ | Start Date: Date when this timeframe becomes active | ||
+ | Sunset Time: Time of sunset for this period | ||
+ | Sunrise Time: Time of sunrise for this period | ||
+ | Night Duration: Duration of night in hours | ||
+ | ES 1 Start Time: The start time for Evening Shift 1 in this period | ||
+ | Availability sheet: Contains staff availability submissions for different shifts | ||
+ | First column: Staff names | ||
+ | Second column: Staff email addresses | ||
+ | Subsequent columns: Boolean values (TRUE/ | ||
+ | Column headers format: "Day ShiftType" | ||
+ | Current date + 7 days: Used to determine which seasonal timeframe to use | ||
+ | The script adds 7 days to the current date to calculate shifts for the upcoming week | ||
+ | This allows scheduling to be done one week in advance | ||
+ | |||
+ | **Workflow** | ||
+ | |||
+ | The script determines the correct shift timeframe based on the current date + 7 days | ||
+ | It looks up the most recent applicable row in the Shift Timeframes sheet | ||
+ | The timeframe determines the ES1 start time which affects other shifts | ||
+ | It identifies which shift types are available (ES 1, ES 2, GS 1, GS 2, and sometimes MS 1 and MS 2) | ||
+ | ES and GS shifts are always included | ||
+ | MS shifts are only included when ES1 starts between 16:00 and 17:30 | ||
+ | This logic is based on observatory-specific requirements | ||
+ | It retrieves staff availability from the " | ||
+ | Maps column headers to full shift names | ||
+ | Creates a data structure tracking which employees are available for each shift | ||
+ | Excludes shifts that aren't applicable for the current timeframe | ||
+ | It assigns shifts based on availability, | ||
+ | First tries to give everyone at least one shift | ||
+ | Then fills remaining shifts prioritizing those with fewer assignments | ||
+ | Ensures employees who work GS shifts get the next day off | ||
+ | Tries to distribute different shift types among employees | ||
+ | Uses randomization to ensure fairness among equally-qualified staff | ||
+ | Limits employees to a maximum of 3 shifts each | ||
+ | It outputs the schedule to the " | ||
+ | Organized by day of the week | ||
+ | Shows which employee is assigned to each shift | ||
+ | Includes a summary of filled vs. unfilled shifts | ||
+ | Lists any shifts that couldn' | ||
+ | If not in TEST_MODE, it sends email notifications to staff | ||
+ | Emails contain personalized schedules for each employee | ||
+ | The email format is clean and professional | ||
+ | |||
+ | |||
+ | **Script Components** | ||
+ | |||
+ | Key Functions | ||
+ | |||
+ | // | ||
+ | |||
+ | This function determines the shift timeframe to use based on date. | ||
+ | Reads the "Shift Timeframes" | ||
+ | Uses current date + 7 days to find the appropriate timeframe | ||
+ | Iterates through rows in the Shift Timeframes sheet to find the latest applicable row | ||
+ | Returns start time for ES1 and other timeframe info (sunset, sunrise, etc.) | ||
+ | Returns null if no valid timeframe is found | ||
+ | // | ||
+ | |||
+ | This function determines which shift types to include based on the ES1 start time. | ||
+ | Calls getShiftDuration() to get the ES1 start time | ||
+ | Always includes ES 1, ES 2, GS 1, and GS 2 shifts | ||
+ | Calculates total minutes from ES1 start time (converts HH:MM to minutes) | ||
+ | Includes MS 1 and MS 2 shifts only if ES1 starts between 16:00 (960 minutes) and 17:30 (1050 minutes) | ||
+ | Returns a mapping of shift types to their start times | ||
+ | // | ||
+ | |||
+ | This is the main entry point function that: | ||
+ | Clears/ | ||
+ | Calls scheduleAllShifts() to get assignments | ||
+ | Groups shift assignments by day for readability | ||
+ | Formats the results for display with a specific output format | ||
+ | Creates a summary of filled/ | ||
+ | Handles email notifications if not in TEST_MODE | ||
+ | Autoformats the output sheet for readability | ||
+ | // | ||
+ | |||
+ | This is the core scheduling algorithm that: | ||
+ | Reads the " | ||
+ | Gets shift timings from getShiftTimings() | ||
+ | Creates a mapping between availability column headers and full shift names | ||
+ | Initializes tracking variables for shift assignments and employee shift counts | ||
+ | Gathers employee availability for each possible shift | ||
+ | Prioritizes employees with fewer shifts for fair distribution | ||
+ | Applies logic to ensure no one works more than the maximum allowed shifts | ||
+ | Implements special rules for different shift types (like rest days after GS shifts) | ||
+ | Returns a detailed object with assignment results and statistics | ||
+ | sendEmailNotifications(employeeShifts) | ||
+ | This function sends email notifications to staff about their assigned shifts. | ||
+ | Checks if TEST_MODE is active (skips emails if true) | ||
+ | Retrieves email addresses using getEmployeeEmail() | ||
+ | Formats a personalized email for each employee with their shift assignments | ||
+ | Uses MailApp.sendEmail to send the notifications | ||
+ | Logs confirmation of sent emails | ||
+ | // | ||
+ | |||
+ | Helper function that randomly shuffles arrays using the Fisher-Yates algorithm for fair distribution. | ||
+ | Takes an array as input | ||
+ | Randomly reorders the elements in place | ||
+ | Returns the shuffled array | ||
+ | |||
+ | // | ||
+ | |||
+ | Helper function that retrieves email addresses from the availability data. | ||
+ | Searches for the employee name in the first column of data values | ||
+ | Returns the corresponding email from the second column | ||
+ | Returns null if no match is found | ||
+ | **Configuration Options** | ||
+ | |||
+ | //Global Settings// | ||
+ | |||
+ | TEST_MODE (boolean): When true, email notifications are disabled | ||
+ | Future date offset (7 days): Added to the current date for timeframe selection | ||
+ | Shift Assignment Rules | ||
+ | Maximum shifts per employee: 3 (can be adjusted in the scheduleAllShifts() function) | ||
+ | Rest day rule: Employees who work a GS shift can't work the next day | ||
+ | Shift variety: The algorithm tries to ensure employees get different shift types | ||
+ | **Scheduling Logic** | ||
+ | |||
+ | The scheduling algorithm is implemented through multiple phases and constraints to ensure fair and effective shift distribution. | ||
+ | Prioritization Phases | ||
+ | The algorithm uses two prioritization phases defined in the assignmentPhases array: | ||
+ | Phase 1: Give everyone at least one shift | ||
+ | function(availableEmployees, | ||
+ | return availableEmployees[shiftKey].filter(emp => | ||
+ | employeesWithNoShift.indexOf(emp) >= 0 && | ||
+ | !employeeDayAssignments[emp][shiftKey.split(" | ||
+ | ); | ||
+ | } | ||
+ | Filters employees who have zero shifts assigned | ||
+ | Checks that the employee isn't already assigned to another shift on the same day | ||
+ | This ensures every operator gets at least one shift before anyone gets multiple | ||
+ | Phase 2: Fair distribution | ||
+ | function(availableEmployees, | ||
+ | var shiftDay = shiftKey.split(" | ||
+ | var currentDayIndex = fullDayNames.indexOf(shiftDay); | ||
+ | var isGSShift = shiftKey.includes(" | ||
+ | |||
+ | return availableEmployees[shiftKey].filter(emp => { | ||
+ | // Various filtering criteria... | ||
+ | }); | ||
+ | } | ||
+ | Applies more complex filtering based on several criteria | ||
+ | Prioritizes employees with fewer shifts | ||
+ | Enforces rest days after GS shifts | ||
+ | Promotes shift type variety | ||
+ | Prevents exceeding maximum shifts per employee | ||
+ | Specific Assignment Criteria | ||
+ | |||
+ | The algorithm considers these factors when assigning shifts: | ||
+ | Availability Check | ||
+ | |||
+ | Employee must have indicated availability for the specific shift | ||
+ | Only employees with TRUE in the corresponding availability column are considered | ||
+ | Maximum Shift Limit | ||
+ | if (employeeShiftCount[emp] >= maxShiftsPerEmployee) return false; | ||
+ | No employee gets more than 3 shifts (configurable via maxShiftsPerEmployee) | ||
+ | Once an employee reaches this limit, they' | ||
+ | Day Assignment Tracking | ||
+ | |||
+ | if (employeeDayAssignments[emp][shiftDay]) return false; | ||
+ | Prevents multiple shifts on the same day for an employee | ||
+ | Uses the employeeDayAssignments object to track which days each employee is assigned | ||
+ | GS Shift Rest Day Rule | ||
+ | |||
+ | // Check if employee worked a GS shift yesterday | ||
+ | var prevDayIndex = (currentDayIndex - 1 + 7) % 7; | ||
+ | var prevDay = fullDayNames[prevDayIndex]; | ||
+ | |||
+ | |||
+ | for (var i = 0; i < employeeShifts[emp].length; | ||
+ | var pastShift = employeeShifts[emp][i]; | ||
+ | if (pastShift.includes(prevDay) && (pastShift.includes(" | ||
+ | return false; // Needs rest day after GS shift | ||
+ | } | ||
+ | } | ||
+ | If an employee works a GS shift, they cannot work the next day | ||
+ | This is enforced by checking previous day assignments for GS shifts | ||
+ | Shift Type Variety Promotion | ||
+ | // For GS shifts, check if they already have one | ||
+ | if (isGSShift) { | ||
+ | var hasGSShift = employeeShifts[emp].some(shift => | ||
+ | shift.includes(" | ||
+ | ); | ||
+ | |||
+ | if (hasGSShift && employeeShiftCount[emp] > 0) { | ||
+ | return false; | ||
+ | } | ||
+ | } | ||
+ | |||
+ | |||
+ | // For non-GS shifts, prioritize those without ES/MS shifts | ||
+ | if (!isGSShift && employeeShiftCount[emp] > 0) { | ||
+ | var hasESorMSShift = employeeShifts[emp].some(shift => | ||
+ | shift.includes(" | ||
+ | shift.includes(" | ||
+ | ); | ||
+ | |||
+ | if (!hasESorMSShift) { | ||
+ | return true; // Prioritize | ||
+ | } | ||
+ | } | ||
+ | Promotes shift type variety by avoiding assigning the same type repeatedly | ||
+ | For GS shifts: deprioritizes employees who already have a GS shift | ||
+ | For ES/MS shifts: prioritizes employees who don't yet have an ES or MS shift | ||
+ | Fairness Through Randomization | ||
+ | |||
+ | // Shuffle employees with the same shift count | ||
+ | if (i > startIndex) { | ||
+ | var group = eligibleEmployees.slice(startIndex, | ||
+ | group = shuffleArray(group); | ||
+ | for (var j = 0; j < group.length; | ||
+ | eligibleEmployees[startIndex + j] = group[j]; | ||
+ | } | ||
+ | } | ||
+ | After prioritizing by shift count and type, adds randomness within priority groups | ||
+ | Uses the shuffleArray() function to randomly reorder employees with the same priority | ||
+ | This prevents the same employees from always getting preferred shifts | ||
+ | The scheduling logic combines these rules to create a fair, balanced schedule that respects constraints while maximizing shift coverage. | ||
+ | Output Format | ||
+ | |||
+ | The script outputs the schedule to the " | ||
+ | Monday Evening --> Tuesday Morning | ||
+ | Shift: Prep + ES 1, Assigned to: Employee1 | ||
+ | Shift: ES 2, Assigned to: Employee2 | ||
+ | Shift: GS 1, Assigned to: Employee3 | ||
+ | Shift: GS 2, Assigned to: Employee4 | ||
+ | |||
+ | Tuesday Evening --> Wednesday Morning | ||
+ | ... | ||
+ | |||
+ | Shifts Summary: 20/28 filled | ||
+ | |||
+ | Unassigned Shifts: | ||
+ | Monday MS 1 | ||
+ | Monday MS 2 | ||
+ | ... | ||
+ | **Troubleshooting** | ||
+ | |||
+ | Common Issues | ||
+ | |||
+ | No Results from Scheduling | ||
+ | |||
+ | Cause: Could be missing data in the Shift Timeframes or Availability sheets | ||
+ | Solution: | ||
+ | |||
+ | Check that both sheets exist with exactly these names: "Shift Timeframes" | ||
+ | Verify the Shift Timeframes sheet has data in the expected format | ||
+ | Check if any dates in the Shift Timeframes sheet are applicable to current date + 7 days | ||
+ | Look for error messages in the script execution logs | ||
+ | Missing Shift Types (MS Shifts) | ||
+ | Cause: MS shifts only appear when ES1 start time is between 16: | ||
+ | |||
+ | Solution: | ||
+ | |||
+ | This is expected behavior based on the condition: if (totalMinutes >= 960 && totalMinutes <= 1050) | ||
+ | If MS shifts should be included regardless of time, modify the getShiftTimings() function | ||
+ | To debug, add a log statement: `Logger.log(" | ||
+ | |||
+ | |||
+ | Making Updates | ||
+ | |||
+ | This section provides detailed guidance on common modifications your team might need to make. | ||
+ | Modifying the Date Offset | ||
+ | Currently, the script adds 7 days to the current date to determine the shift timeframe. To change this: | ||
+ | // In getShiftDuration() function | ||
+ | |||
+ | var today = new Date(); | ||
+ | today.setDate(today.getDate() + 7); // Change 7 to your desired days(based on when we schedule) | ||
+ | If you want to make this configurable at the top of the script, add: | ||
+ | var DAYS_TO_ADD = 7; // Configure how many days to look ahead | ||
+ | // Then in getShiftDuration() | ||
+ | |||
+ | var today = new Date(); | ||
+ | today.setDate(today.getDate() + DAYS_TO_ADD); | ||
+ | Changing Maximum Shifts Per Employee | ||
+ | To change the maximum number of shifts per employee: | ||
+ | // In scheduleAllShifts() function | ||
+ | |||
+ | var maxShiftsPerEmployee = 3; // Change to desired number | ||
+ | Consider adding this as a global constant at the top of the script for easier configuration: | ||
+ | var MAX_SHIFTS_PER_EMPLOYEE = 3; // Configure maximum shifts | ||
+ | |||
+ | // Then update the reference in scheduleAllShifts() | ||
+ | var maxShiftsPerEmployee = MAX_SHIFTS_PER_EMPLOYEE; | ||
+ | |||
+ | |||
+ | Modifying Shift Types | ||
+ | |||
+ | If you need to add or remove shift types, update the checks in the getShiftTimings() function. | ||
+ | For example, to always include MS shifts regardless of ES1 start time: | ||
+ | // Always include these shift types | ||
+ | shiftTimings[" | ||
+ | shiftTimings[" | ||
+ | shiftTimings[" | ||
+ | shiftTimings[" | ||
+ | shiftTimings[" | ||
+ | shiftTimings[" | ||
+ | |||
+ | // Remove or comment out the conditional MS shift logic | ||
+ | // if (totalMinutes >= 960 && totalMinutes <= 1050) { ... } | ||
+ | To add a new shift type: | ||
+ | // Add new shift type | ||
+ | shiftTimings[" | ||
+ | |||
+ | // Don't forget to update the shifts array in scheduleShifts() | ||
+ | var shifts = ["Prep + ES 1", "ES 2", "GS 1", "GS 2", "MS 1", "MS 2", " | ||
+ | Changing MS Shift Inclusion Criteria | ||
+ | To modify when MS shifts are included: | ||
+ | // Change the time range for MS shifts | ||
+ | // Current: 16:00 (960 min) to 17:30 (1050 min) | ||
+ | if (totalMinutes >= 900 && totalMinutes <= 1080) { // Changed to 15: | ||
+ | Logger.log(" | ||
+ | shiftTimings[" | ||
+ | shiftTimings[" | ||
+ | } else { | ||
+ | Logger.log(" | ||
+ | } | ||
+ | Changing Email Notification Format | ||
+ | |||
+ | To modify the email content, update the sendEmailNotifications() function: | ||
+ | var subject = "Your Observatory Shift Assignment"; | ||
+ | |||
+ | // Change email body format | ||
+ | |||
+ | var body = "Hello " + employee + ", | ||
+ | " | ||
+ | | ||
+ | " | ||
+ | " | ||
+ | Adding Different Constraints | ||
+ | |||
+ | To add new scheduling constraints, | ||
+ | Example - Adding a constraint to prevent working more than 2 consecutive days: | ||
+ | // Check if employee is already working the previous two days | ||
+ | var prevDay1Index = (currentDayIndex - 1 + 7) % 7; | ||
+ | var prevDay2Index = (currentDayIndex - 2 + 7) % 7; | ||
+ | var prevDay1 = fullDayNames[prevDay1Index]; | ||
+ | var prevDay2 = fullDayNames[prevDay2Index]; | ||
+ | |||
+ | var workingPrevDay1 = employeeDayAssignments[emp][prevDay1]; | ||
+ | var workingPrevDay2 = employeeDayAssignments[emp][prevDay2]; | ||
+ | |||
+ | // If working both previous days, don't assign this day | ||
+ | if (workingPrevDay1 && workingPrevDay2) return false; | ||
+ | Manually Setting a Specific Date | ||
+ | |||
+ | If you need to run the script for a specific date instead of current date + 7 days, you can replace: | ||
+ | var today = new Date(); | ||
+ | today.setDate(today.getDate() + 7); | ||
+ | With: | ||
+ | var today = new Date(" | ||
+ | Implementing a Date Selection Dialog | ||
+ | For more flexibility, | ||
+ | |||
+ | function showDatePickerDialog() { | ||
+ | var ui = SpreadsheetApp.getUi(); | ||
+ | var response = ui.prompt( | ||
+ | ' | ||
+ | 'Enter the date to schedule for (YYYY-MM-DD):', | ||
+ | ui.ButtonSet.OK_CANCEL | ||
+ | ); | ||
+ | |||
+ | if (response.getSelectedButton() == ui.Button.OK) { | ||
+ | var dateString = response.getResponseText(); | ||
+ | // Validate date format | ||
+ | var dateRegex = / | ||
+ | if (dateRegex.test(dateString)) { | ||
+ | // Override the current date | ||
+ | var schedulingDate = new Date(dateString); | ||
+ | // Call scheduling function with this date | ||
+ | scheduleShiftsForDate(schedulingDate); | ||
+ | } else { | ||
+ | ui.alert(' | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | |||
+ | // Modified scheduling function that accepts a date parameter | ||
+ | function scheduleShiftsForDate(targetDate) { | ||
+ | // Store the original date calculation | ||
+ | var originalGetShiftDuration = getShiftDuration; | ||
+ | |||
+ | // Override getShiftDuration temporarily | ||
+ | getShiftDuration = function() { | ||
+ | var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(" | ||
+ | if (!sheet) { | ||
+ | Logger.log(" | ||
+ | return null; | ||
+ | } | ||
+ | |||
+ | var data = sheet.getDataRange().getValues(); | ||
+ | var today = targetDate; // Use the provided date | ||
+ | // Rest of function remains the same... | ||
+ | }; | ||
+ | |||
+ | // Call the regular scheduling function | ||
+ | scheduleShifts(); | ||
+ | |||
+ | // Restore the original function | ||
+ | getShiftDuration = originalGetShiftDuration; | ||
+ | } | ||
+ | Adding Custom Menu Items | ||
+ | You can add menu items to make the script more user-friendly: | ||
+ | function onOpen() { | ||
+ | var ui = SpreadsheetApp.getUi(); | ||
+ | ui.createMenu(' | ||
+ | .addItem(' | ||
+ | .addItem(' | ||
+ | .addSeparator() | ||
+ | .addItem(' | ||
+ | .addToUi(); | ||
+ | } | ||
+ | |||
+ | function toggleEmailNotifications() { | ||
+ | TEST_MODE = !TEST_MODE; | ||
+ | var ui = SpreadsheetApp.getUi(); | ||
+ | ui.alert(' | ||
+ | | ||
+ | } | ||
+ | |||
+ |