Last active
April 8, 2018 02:21
-
-
Save davidvandusen/d26bd6529698583ecdbc956fecbb6e90 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Moment.js is a great library because of its shortcut methods for setting various properties on | |
// date-like Objects. These methods are used heavily in the function used to set the "sleep time" | |
// boundaries that bookend the calendar events correctly based on the time zone of the client. | |
const moment = require('moment'); | |
// This function is the code entry point. Start following the code from here. | |
function main() { | |
// These calendar events should look similar to the ones that come back from the API. Their start | |
// and end dates are ISO Strings. These String values will have to be converted into Date Objects | |
// in order to be compared. They will also be converted into moment Objects in order to be | |
// formatted for output. Moment.js' `.format` method should *only* be used when outputting a date | |
// or time to a user. It should not be used to create Strings for use internally by JavaScript | |
// for comparison. | |
const calendarEvents = [{ | |
// An event_type field indicates that this is a Google Calendar event | |
event_type: 'GCAL', | |
// Commute events exist to show the functions dealing with multiple events that are contiguous | |
// with subsequent events. Commutes happen right before and after Work. | |
summary: 'Commute', | |
starts_at: '2016-08-14T08:30:00.000-07:00', | |
ends_at: '2016-08-14T09:00:00.000-07:00' | |
}, { | |
event_type: 'GCAL', | |
summary: 'Work', | |
starts_at: '2016-08-14T09:00:00.000-07:00', | |
ends_at: '2016-08-14T12:00:00.000-07:00' | |
}, { | |
event_type: 'GCAL', | |
summary: 'Work', | |
starts_at: '2016-08-14T13:00:00.000-07:00', | |
ends_at: '2016-08-14T17:00:00.000-07:00' | |
}, { | |
event_type: 'GCAL', | |
summary: 'Commute', | |
starts_at: '2016-08-14T17:00:00.000-07:00', | |
ends_at: '2016-08-14T17:30:00.000-07:00' | |
}, { | |
event_type: 'GCAL', | |
summary: 'Dinner', | |
starts_at: '2016-08-14T19:00:00.000-07:00', | |
ends_at: '2016-08-14T19:45:00.000-07:00' | |
}, { | |
event_type: 'GCAL', | |
// Bedtime Story is added with a time range that overlaps the sleep time to show the functions | |
// dealing with overlapping events. | |
summary: 'Bedtime Story', | |
starts_at: '2016-08-14T20:45:00.000-07:00', | |
ends_at: '2016-08-14T21:15:00.000-07:00' | |
}, { | |
event_type: 'GCAL', | |
summary: 'Lunch', | |
// An event on a subsequent day is added to show that these functions are robust enough to | |
// handle the whole week's worth of events instead of having to separate events by day first. | |
starts_at: '2016-08-15T12:00:00.000-07:00', | |
ends_at: '2016-08-15T13:00:00.000-07:00' | |
}, { | |
event_type: 'GCAL', | |
summary: 'Movie', | |
// A day is skipped, showing that the user has a "Free Time" event that lasts a whole waking | |
// day. Also, this event spans midnight, but the functions know to ignore the day that the | |
// event ends on. | |
starts_at: '2016-08-17T22:30:00.000-07:00', | |
ends_at: '2016-08-18T01:00:00.000-07:00' | |
}]; | |
// The `getFreeTimeEvents` function is the core of this program. It creates events that sit | |
// between the contiguous other calendar events. Because the app needs to have "Free Time" events | |
// that sit between waking up and the first event as well as the last event and going to sleep, | |
// a function is created to generate these "Sleeping" events. It takes the events from the API as | |
// input and returns an Array of event Objects. | |
const sleepEvents = getSleepEvents(calendarEvents); | |
// The "Sleeping" events are concatenated onto the Google Calendar events Array. That will create | |
// appropriate gaps at the beginning and ending of the day so that a "Free Time" event will be | |
// created there. | |
const eventsWithSleep = calendarEvents.concat(sleepEvents); | |
// This is the function call that creates the "Free Time" events. It takes an Array of event | |
// Objects and returns an Array of event Objects that represent the free time in between the input | |
// events. It accomplishes this by first creating an Array of "Busy" events that represent all the | |
// time in the calendar taken up by the input events. Then it creates new events that fill the | |
// spaces in between the busy times. | |
const freeTimeEvents = getFreeTimeEvents(eventsWithSleep); | |
// Because `getFreeTimeEvents` only returns the "Free Time" events, they need to be combined into | |
// the original set of Google Calendar events by using concatenation. | |
const eventsWithFreeTime = calendarEvents.concat(freeTimeEvents); | |
// The events are not in order, though, because all the "Free Time" events are just tacked on at | |
// the end by the `.concat` method. The final touch before printing the output is to make sure the | |
// list of events is sorted. Up until this point, it didn't matter whether the events that came | |
// back from the API were sorted or not. By ensuring they're sorted in the client code, a lot of | |
// bugs and errors can be avoided. | |
eventsWithFreeTime.sort(function (a, b) { | |
return new Date(a.starts_at) - new Date(b.starts_at); | |
}); | |
// This output shows the finished schedule. You'll notice that the "Sleeping" events have not been | |
// included. They were generated in order for the other functions to work purely while the main | |
// code follows the business logic pertaining to the feature. | |
const timeFormat = 'HH:mm'; | |
console.log(eventsWithFreeTime.map(function (event) { | |
return `[${event.event_type}] ${moment(event.starts_at).format(timeFormat)} - ${moment(event.ends_at).format(timeFormat)} | ${event.summary}`; | |
}).join('\n')); | |
} | |
/** | |
* Returns an Array of event Objects that represent the sleep from the beginning of the day to the | |
* provided wakeHour and from the provided sleepHour to the end of the day for each day that the | |
* input events span. | |
* | |
* @param events Array of event objects | |
* @param wakeHour Integer (optional) hour of the day that the event attendee wakes up at | |
* @param sleepHour Integer (optional) hour of the day that the event attendee goes to sleep at | |
* @returns Array of event Objects | |
*/ | |
// This function uses Moment.js heavily. All of these operations could have been done with plain | |
// old JavaScript Date Objects, but it would be far more verbose. | |
function getSleepEvents(events, wakeHour, sleepHour) { | |
// Guard against empty event lists. Can't create sleep events without at least one day. | |
if (events.length === 0) return []; | |
// Defaults wakeHour to 6 if it is not provided | |
wakeHour = typeof wakeHour === 'undefined' ? 6 : wakeHour; | |
// Defaults sleepHour to 21 if it is not provided | |
sleepHour = typeof sleepHour === 'undefined' ? 21 : sleepHour; | |
// Find the event with the earliest end time. Using end time here instead of start time in case | |
// there is an event that starts before the beginning of the day and spans across midnight. This | |
// prevents creating events for a day where there is only the beginning of an event on it. | |
const earliestEvent = events.reduce(function (earliestEvent, event) { | |
return moment(event.ends_at).isBefore(earliestEvent.ends_at) ? event : earliestEvent; | |
}); | |
// Create a moment representing the earliest day that an event ends on | |
const day = moment(earliestEvent.ends_at).startOf('day'); | |
// Find the event with the latest start time. Similar to above, using start time in case there is | |
// an event that spans across midnight into the next day. | |
const latestEvent = events.reduce(function (latestEvent, event) { | |
return moment(event.starts_at).isAfter(latestEvent.starts_at) ? event : latestEvent; | |
}); | |
// Create a moment representing the latest day that an event starts on | |
const endDay = moment(latestEvent.starts_at).startOf('day'); | |
// Prepare to return the Array of sleep events | |
const sleepEvents = []; | |
// Loop through the values for `day` until all the days present in the input range have been seen | |
while (day.isSameOrBefore(endDay)) { | |
// Create events that represent sleeping at the beginning and ending of the day | |
sleepEvents.push({ | |
// A special event_type is specified to identify these "Sleeping" events | |
event_type: 'REST', | |
summary: 'Sleeping', | |
// All events have date properties as ISO Strings to keep function inputs and outputs | |
// consistent. The `day` moment has been set to the start of the day, midnight, so is used as | |
// is for the start of the first sleep event for this day. | |
starts_at: day.toISOString(), | |
// Clone the day and set its hour to the hour specified to wake up at. | |
ends_at: day.clone().hour(wakeHour).toISOString() | |
}); | |
sleepEvents.push({ | |
event_type: 'REST', | |
summary: 'Sleeping', | |
// Clone the day and set its hour to the hour specified to go to sleep at. | |
starts_at: day.clone().hour(sleepHour).toISOString(), | |
// The sleep event for the end of the day ends at midnight the following day in order to make | |
// sure it is contiguous with the sleep at the beginning of the following day. | |
ends_at: day.clone().add(1, 'day').toISOString() | |
}); | |
// Move day forward by 1 to advance the while loop condition | |
day.add(1, 'day'); | |
} | |
// Return the array of sleep events | |
return sleepEvents; | |
} | |
/** | |
* Returns an Array of event Objects whose start and end times fit in between the events in the | |
* input Array. | |
* | |
* @param events Array of event Objects | |
* @returns Array of event Objects | |
*/ | |
function getFreeTimeEvents(events) { | |
const busyEvents = getBusyEvents(events); | |
const freeTimeEvents = []; | |
// This loop starts at index 1. It refers to the previous events by index by subtracting 1. This | |
// ensures that it doesn't try to access index -1 on the first iteration. | |
for (var i = 1, l = busyEvents.length; i < l; i++) { | |
freeTimeEvents.push({ | |
// An special event_type is specified to identify "Free Time" events | |
event_type: 'FREE', | |
summary: 'Free Time', | |
// This event starts the moment that the previous one ends. | |
starts_at: busyEvents[i - 1].ends_at, | |
// This event goes until the start of current event in the loop. | |
ends_at: busyEvents[i].starts_at | |
}); | |
} | |
return freeTimeEvents; | |
} | |
/** | |
* Returns an Array of event Objects representing the combination of the overlapping and contiguous | |
* events in the input Array. | |
* | |
* The returned objects has an additional property `events` which is an Array of the event Objects | |
* that were merged together to create the "Busy" event. | |
* | |
* @param events Array of event Objects | |
* @returns Array of event Objects | |
*/ | |
function getBusyEvents(events) { | |
// The only properties that are needed from the input events are the start and end times. Because | |
// these properties are going to be repeatedly compared using inequality operators, it is much | |
// more convenient to map the String dates from the input events to JavaScript Date Objects. | |
// This variable represents all the times that should be considered "Busy", but they might overlap | |
// or be unordered. | |
const busyTimes = events.map(function (event) { | |
return { | |
starts_at: new Date(event.starts_at), | |
ends_at: new Date(event.ends_at), | |
// Keep the original event to be added to the grouped "Busy" event. This could be very useful | |
// for future features. | |
originalEvent: event | |
}; | |
}); | |
// Grouping the busy times together can be done in one pass to produce the desired output if the | |
// time ranges are order by start time, and by end time for two events with the same start time. | |
busyTimes.sort(function (a, b) { | |
// Subtracting Date Objects in JavaScript produces Numbers. If the different between the start | |
// times is non-zero, it can be returned from the compare function. | |
const startsAtDifference = a.starts_at - b.starts_at; | |
if (startsAtDifference) return startsAtDifference; | |
// If the difference between the start times is zero, then the compare function can return the | |
// difference between the end times. | |
return a.ends_at - b.ends_at; | |
}); | |
// This loop over busyTimes starts with an empty Array and uses it to build up a new list of time | |
// ranges where overlapping and contiguous ranges are merged. It takes in each event's time range | |
// and either adds it to an existing group, or starts a new group if it doesn't overlap with any | |
// existing one. | |
const groupedTimes = []; | |
busyTimes.forEach(function (event) { | |
// Determine whether the event's time range overlaps with the time range of an existing event. | |
const overlappingGroup = groupedTimes.find(function (group) { | |
// More about this approach to determining overlapping ranges here: | |
// http://stackoverflow.com/questions/325933/determine-whether-two-date-ranges-overlap | |
return group.starts_at <= event.ends_at && group.ends_at >= event.starts_at; | |
}); | |
if (overlappingGroup) { | |
// If an overlap was found, expand the group's end time to accommodate this event. Because | |
// the events are sorted by start time, there's no need to check whether the start time is | |
// before the group's start time. | |
if (event.ends_at > overlappingGroup.ends_at) { | |
overlappingGroup.ends_at = event.ends_at; | |
} | |
// Add the original event into this group's Array of events. This could be used for other | |
// features. | |
overlappingGroup.events.push(event.originalEvent); | |
} else { | |
// If there is no overlap between this event and any existing group, start a new group with | |
// the time range of this event. | |
groupedTimes.push({ | |
starts_at: event.starts_at, | |
ends_at: event.ends_at, | |
// Create an events Array to contain the original events that went into this group. | |
events: [event.originalEvent] | |
}) | |
} | |
}); | |
// Because this function returns an Array of event Objects, the grouped time ranges are turned | |
// into proper event Objects with event_type and summary properties, as well as time properties | |
// with ISO Strings as values. | |
return groupedTimes.map(function (group) { | |
return { | |
// A special event_type is specified to identify "Busy" events | |
event_type: 'BUSY', | |
summary: 'Busy', | |
starts_at: group.starts_at.toISOString(), | |
ends_at: group.ends_at.toISOString(), | |
// These are all the events that got merged together to create this "Busy" event. | |
events: group.events | |
}; | |
}); | |
} | |
main(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"dependencies": { | |
"moment": "^2.14.1" | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment