Warning
You are browsing a legacy documentation for FTW (daily, hourly and product) which is no longer maintained.

Last updated

Add buffer time to bookings in FTW-hourly

This guide describes how to modify booking times in FTW-hourly to have a buffer after the time slots

Table of Contents

For some services booked by the hour, the provider may want to reserve a buffer time between bookings. For instance a yoga teacher may want to have a break between private customers, or a massage therapist may need to clear up and restock their therapy space before the next customer arrives. You can set your marketplace to add these kinds of buffers by default, so providers do not need to add availability exceptions manually.

Add a 15 minute buffer between one hour bookings

The simplest use case for buffered bookings is having one hour slots that are bookable at the top of the hour, and adding 15 minutes unbookable time after each slot. To achieve that, we will use the booking's display time attribute.

First, we will add a helper function in src/util/dates.js file to add the correct buffer time.

└── src
    └── util
        └── dates.js
+ const bufferMinutes = 15;

+ export const addBuffer = (date) => moment(date).add(bufferMinutes, 'minutes').toDate();

Add display end time handling

The user can select one hour booking slots on the listing page. When an order gets initiated, we want to set the selected end time as the display time, and a new buffered end time as the actual booking time. This way, the booking extends over both the customer's booking time and the buffer, so that other customers can not book over the buffer. We use the newly created addBuffer function to extend the display end time.

└── src
    └── containers
        └── CheckoutPage
            └── CheckoutPage.duck.js
+ import { addBuffer } from '../../util/dates';
 ...

export const initiateOrder = (orderParams, transactionId) => (dispatch, getState, sdk) => {
   dispatch(initiateOrderRequest());
 ...
+  const bufferEnd = addBuffer(orderParams.bookingEnd);
+
+  const bufferedParams = {
+    ...orderParams,
+    bookingEnd: bufferEnd,
+    bookingDisplayEnd: orderParams.bookingEnd,
+    bookingDisplayStart: orderParams.bookingStart,
+  };

   const bookingData = {
     startDate: orderParams.bookingStart,
-    endDate: orderParams.bookingEnd,
+    endDate: bufferedParams.bookingEnd,
+    displayEnd: bufferedParams.bookingDisplayEnd,
   };

   const bodyParams = isTransition
     ? {
         id: transactionId,
         transition,
-        params: orderParams,
+        params: bufferedParams,
       }
     : {
         processAlias: config.bookingProcessAlias,
         transition,
-        params: orderParams,
+        params: bufferedParams,
       };

We also need to modify line item calculation. By default, the transaction's price is calculated based on the booking's start and end moments, however in this case we want to use the display end attribute for the price calculation.

└── server
    └── api-util
        └── lineItems.js

We have already passed displayEnd in bookingData, so we just need to add handling for it in lineItems.js. Since the function is used both with and without display end time, e.g. when calling api/transaction-line-items, we need to accommodate both use cases.

exports.transactionLineItems = (listing, bookingData) => {
  const unitPrice = listing.attributes.price;
- const { startDate, endDate } = bookingData;
+ const { startDate, endDate, displayEnd } = bookingData;
 ...
  const booking = {
    code: bookingUnitType,
    unitPrice,
-   quantity: calculateQuantityFromHours(startDate, endDate),
+   quantity: calculateQuantityFromHours(startDate, (displayEnd || endDate)),
    includeFor: ['customer', 'provider'],
  };

Set getStartHours to return correct available start times

Finally, we need to make sure that the start time slots only show valid start times including the buffer – if someone has booked 6pm to 7pm, then another customer cannot book 5pm to 6pm because there is not enough availability for both the time slot and the buffer.

└── src
    └── util
        └── dates.js

First, add a constant for a full hour in addition to the buffer time.

  const bufferMinutes = 15;
+ const hourMinutes = 60;

Then, fix getStartHours handling. By default, start hours and end hours are determined from the same list, so getStartHours removes the final list item to allow for a single hour between the final start and end times.

However, when the actual booking slot is longer than the displayed one, we need to make sure that the interval between the final start and end times can fit a buffered booking. Therefore, instead of removing a single start time to fit a one hour booking slot, we remove as many start times as it takes to fit a buffered hour before the final end time.

export const getStartHours = (intl, timeZone, startTime, endTime) => {
  const hours = getSharpHours(intl, timeZone, startTime, endTime);
- return hours.length < 2 ? hours : hours.slice(0, -1);
+ const removeCount = Math.ceil((hourMinutes + bufferMinutes) / hourMinutes);
+ return hours.length < removeCount ? [] : hours.slice(0, -removeCount);
};

Allow users to book buffered appointments at non-sharp hours

Having this kind of setup then means that the slots are, in practice, bookable every two hours. If we do not want to leave extra gaps between bookings beyond the buffer, we can set start times to repeat on the buffer time cadence instead of hourly. That way, if someone books the 8 AM - 9 AM slot, the next available start time is 9.15 AM i.e. right after the buffer.

In other words, we need to have start and end times at a different cycle:

  • start times at 15 minute increments i.e. customer can start their booking at any quarter hour, instead of at the top of the hour only
  • end times at one hour increments, i.e. the customer can book one or more full hours only.

Time slot handling is done using a few helper functions in src/util/dates.js

└── src
    └── util
        └── dates.js
  • getStartHours and getEndHours return a list of timepoints that are displayed as the booking's possible start and end moments, respectively. They both use the same helper function getSharpHours
  • getSharpHours, in turn, by default retrieves the sharp hours that exist within the availability time slot. It uses the findBookingUnitBoundaries function, which is a recursive function that checks whether the current boundary (e.g. sharp hour) passed to it falls within the availability time slot.
    • If the current boundary is within the availability time slot, the function calls itself with the next boundary and cumulates the boundary results into an array.
    • If the current boundary does not fall within the availability time slot, the function returns the cumulated results from the previous iterations.
  • findBookingUnitBoundaries takes a nextBoundaryFn parameter that it uses to determine the next boundary value to pass to itself.
  • the function passed to findBookingUnitBoundaries as nextBoundaryFn by default is findNextBoundary, which is what we need to modify first. The findNextBoundary function increments the current boundary by a predefined value.
export const findNextBoundary = (timeZone, currentMomentOrDate) =>
  moment(currentMomentOrDate)
    .clone()
    .tz(timeZone)
    .add(1, 'hour') // The default handling uses hours
    .startOf('hour') // By default, the time slot is rounded to the start of the hour
    .toDate();

Add a custom rounding function for moment.js

FTW-hourly uses the moment-timezone library to modify times and dates and convert them between the listing's time zone and the user's time zone.

By default, the findNextBoundary function uses moment.startOf('hour') to round the booking slots to the top of each hour. However, since we are now dealing with minutes, we need to create a custom rounding function to replace the startOf('hour') function call. When we add it to moment.js using the prototype exposed through moment.fn, we can chain it in the same place as the default startOf('hour') function.

This rounding function rounds to sharp hours when the buffer minutes value is a factor of an hour, e.g. 15, 20 or 30 minutes.

/**
 * Rounding function for moment.js. Rounds the Moment provided by the context
 * to the start of the specified time value in the specified units.
 * @param {*} value the rounding value
 * @param {*} timeUnit time units to specify the value
 * @returns Moment rounded to the start of the specified time value
 */
moment.fn.startOfDuration = function(value, timeUnit) {
  const getMs = (val, unit) => moment.duration(val, unit)._milliseconds;
  const ms = getMs(value, timeUnit);

  // Get UTC offset to account for potential time zone difference between
  // customer and listing
  const offsetMs = this._isUTC ? 0 : getMs(this.utcOffset(), 'minute');
  return moment(Math.floor((this.valueOf() + offsetMs) / ms) * ms);
};

You will then need to use the new function to replace the built-in startOf() function.

We also need to calculate the increment of time to add to each time boundary, i.e. how long are the stretches of time delineated by the boundaries. To do that, we need an isStart attribute, i.e. whether we're dealing with start times or end times.

In addition we need isFirst, i.e. whether the boundary in question is the very first one in the list. Since we're rounding to the buffer time (here: 15 minutes), we'll need to manually set the first time slot to correspond to the start of the available time slot.

- export const findNextBoundary = (timeZone, currentMomentOrDate) =>
-   moment(currentMomentOrDate)
+ export const findNextBoundary = (
+   timeZone,
+   currentMomentOrDate,
+   isFirst,
+   isStart
+ ) => {
+
+   const isEndTimeSlot = !isStart;
+
+   // For end time slots, add a full hour.
+   // For the first start slot, use the actual start time.
+   // For other start slots, use the buffer time.
+   const increment = isEndTimeSlot
+     ? hourMinutes
+     : isFirst
+     ? 0
+     : bufferMinutes;
+
+   return moment(currentMomentOrDate)
      .clone()
      .tz(timeZone)
-     .add(1, 'hour')
-     .startOf('hour')
+     .add(increment, 'minute')
+     .startOfDuration(bufferMinutes, 'minute')
      .toDate();
+  };
The start of the time slot is in fact determined with the same findNextBoundary function in ListingPage.duck.js.
└── src
    └── containers
        └── ListingPage
            └── ListingPage.duck.js

If we don't make any changes to this function call, the time slots for the current day get fetched with the logic of the end hours, i.e. adding one hour and rounding back to the previous 15 minutes. This would mean that providers would have at least 45 minutes of warning in case someone books an appointment for the same day. If you want to fetch the time slot based on the start time cadence i.e. on the next 15 minutes, you will need to pass the correct isFirst and isStart parameters.

// Helper function for loadData call.
const fetchMonthlyTimeSlots = (dispatch, listing) => {
  ...
    const tz = listing.attributes.availabilityPlan.timezone;
    const nextBoundary = findNextBoundary(tz, new Date());
    ...
    return Promise.all([
      dispatch(fetchTimeSlots(listing.id, nextBoundary, nextMonth, tz)),
      ...

Add new parameters to helper functions

The findNextBoundary function is called from the findBookingUnitBoundaries function, so we need to make sure the isStart and isFirst parameters are passed correctly. The function gets findNextBoundary function as the nextBoundaryFn parameter.

const findBookingUnitBoundaries = params => {
  const {
    cumulatedResults,
    currentBoundary,
    startMoment,
    endMoment,
    nextBoundaryFn,
    intl,
    timeZone,
+   isStart,
  } = params;

  if (moment(currentBoundary).isBetween(startMoment, endMoment, null, '[]')) {
  ...

+   // The nextBoundaryFn by definition cannot determine the first timepoint, since it
+   // is always based on a previous boundary, we pass 'false' as the 'isFirst' param
+   const isFirst = false;
+
    return findBookingUnitBoundaries({
      ...params,
      cumulatedResults: [...cumulatedResults, ...newBoundary],
-     currentBoundary: moment(nextBoundaryFn(timeZone, currentBoundary)),
+     currentBoundary: moment(nextBoundaryFn(timeZone, currentBoundary, isFirst, isStart)),
    });
  }
  return cumulatedResults;
};

The findBookingUnitBoundaries, in turn, is called from getSharpHours. We need to pass the isStart and isFirst parameters with the first currentBoundary definition in findBookingUnitBoundaries, as well as add the isStart parameter to the findBookingUnitBoundaries function call.

In addition, we need to use the actual start time instead of the one millisecond before, which is used by default. This is necessary because instead of adding an hour and rounding off an hour as in the default implementation, we are now manually setting the start time to the beginning of the available time slot.

- export const getSharpHours = (intl, timeZone, startTime, endTime) => {
+ export const getSharpHours = (intl, timeZone, startTime, endTime, isStart = false) => {
    if (!moment.tz.zone(timeZone)) {
  ...

-   const millisecondBeforeStartTime = new Date(startTime.getTime() - 1);
+   // For the first currentBoundary, we pass isFirst as true
+   const isFirst = true;

    return findBookingUnitBoundaries({
-     currentBoundary: findNextBoundary(timeZone, millisecondBeforeStartTime),
+     currentBoundary: findNextBoundary(timeZone, startTime, isFirst, isStart),
      startMoment: moment(startTime),
      endMoment: moment(endTime),
      nextBoundaryFn: findNextBoundary,
      cumulatedResults: [],
      intl,
      timeZone,
+     isStart,
    });
};

Fix getStartHours and getEndHours handling

To get correct start times, we need to first pass true as the isStart parameter from getStartHours to getSharpHours.

In addition, we again need to make sure that even when selecting the last start time, there is enough availability for the first timeslot. Since the first time slots are now set at the buffer minute interval, we divide the full booking time by bufferMinutes to get the correct removeCount value.

export const getStartHours = (intl, timeZone, startTime, endTime) => {
- const hours = getSharpHours(intl, timeZone, startTime, endTime);
- const removeCount = Math.ceil((hourMinutes + bufferMinutes) / hourMinutes)
+ const hours = getSharpHours(intl, timeZone, startTime, endTime, true);
+ const removeCount = Math.ceil((hourMinutes + bufferMinutes) / bufferMinutes)
  return hours.length < removeCount ? [] : hours.slice(0, -removeCount);
};

Finally, we can simplify the end hour handling. Since the first entry is determined in the findNextBoundary function, we do not need to remove it. Instead, we can just return the full list from getSharpHours.

  export const getEndHours = (intl, timeZone, startTime, endTime) => {
-   const hours = getSharpHours(intl, timeZone, startTime, endTime);
-   return hours.length < 2 ? [] : hours.slice(1);
+   return getSharpHours(intl, timeZone, startTime, endTime);
  };

Now, even if we have a booking from 6 AM to 7 AM with a 15 minute buffer at the end, the next customer can start their booking at 7:15 AM. Conversely, the previous booking can begin 4:45 AM and no later, so that the buffered time slot can fit in before the already booked session.

Booking start options buffered time slots