Skip to content

Instantly share code, notes, and snippets.

@davidvandusen
Last active April 8, 2018 02:21
Show Gist options
  • Save davidvandusen/d26bd6529698583ecdbc956fecbb6e90 to your computer and use it in GitHub Desktop.
Save davidvandusen/d26bd6529698583ecdbc956fecbb6e90 to your computer and use it in GitHub Desktop.
// 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();
{
"dependencies": {
"moment": "^2.14.1"
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment