Last updated

# Modify booking time intervals in time-based listings

This guide describes how to modify booking time intervals in hourly listings.

###### Table of Contents

In Sharetribe, listings can have either day-based or time-based availability. For listings with time-based availability, the available time slots are returned from the API as continuous stretches of time. The client application must therefore split the availability into suitable booking lengths.

The default behavior of the Sharetribe Web Template hourly listings is to split the continuous availability stretch into one hour bookable intervals. This how-to guide illustrates a handful of different cases on modifying booking lengths.

## Set booking length to 30 minutes

The simplest use case is to create uniform 30 minute booking slots. Start with adding a constant for the booking length in minutes.

`const timeSlotMinutes = 30;`

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*retrieves the sharp hours that exist within the availability time slot. It uses the*findBookingUnitBoundaries*function.*findBookingUnitBoundaries*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 as
*nextBoundaryFn*by default is*findNextBoundary*. The*findNextBoundary*function increments the current boundary by a predefined value.

```
export const findNextBoundary = (
currentMomentOrDate,
timeUnit,
timeZone
) =>
moment(currentMomentOrDate)
.clone()
.tz(timeZone)
.add(1, timeUnit)
.startOf(timeUnit)
.toDate();
```

In addition to *findBookingUnitBoundaries*, the template uses
*findNextBoundary* to handle other time increment boundaries. That is
why, instead of modifying *findNextBoundary* directly, we will create a
similar function called *findNextCustomBoundary* to be used in
*findBookingUnitBoundaries*, so we do not need to worry about side
effects.

### Add a custom rounding function for moment.js

The template hourly listing handling 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. For *findNextCustomBoundary* – 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 time slot minutes value is a factor of an hour, e.g. 15, 20 or 30 minutes. For other time slot minutes, see using a time slot longer than 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).asMilliseconds();
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, and pass the *timeSlotMinutes* value as the
addition and rounding duration values.

```
export const findNextCustomBoundary = (
currentMomentOrDate,
timeUnit,
timeZone
) => {
return moment(currentMomentOrDate)
.clone()
.tz(timeZone)
.add(timeSlotMinutes, timeUnit)
.startOfDuration(timeSlotMinutes, timeUnit)
.toDate();
};
```

Finally, we need to use the new function in *findBookingUnitBoundaries*,
and change *timeUnit* from *hour* to *minutes*:

```
return findBookingUnitBoundaries({
- currentBoundary: findNextBoundary(millisecondBeforeStartTime, 'hour', timeZone),
+ currentBoundary: findNextCustomBoundary(millisecondBeforeStartTime, 'minutes', timeZone),
startMoment: moment(startTime),
endMoment: moment(endTime),
- nextBoundaryFn: findNextBoundary,
+ nextBoundaryFn: findNextCustomBoundary,
cumulatedResults: [],
intl,
timeZone,
- timeUnit: 'hour',
+ timeUnit: 'minutes',
});
```

For listings with an hourly price, the function
*calculateQuantityFromHours* determines the correct quantity as a
decimal of full hours. However, if you want to set a price per minute,
or e.g. a price per non-hour session, you will need to modify
*calculateQuantityFromHours* as well.

## Use an irregular time slot

If your marketplace has custom booking lengths longer than (and not divisible by) 30 minutes, you will need to extend the previous steps to a more complex approach to make sure the time slots show up correctly.

### Find the rounding duration

When the booking length is not a factor of a full hour, using the
*timeslotMinutes* value might cause issues, because the start time slot
gets rounded to a multiple of the time slot in general. This means that
depending on the start time of the availability (8 AM vs 9 AM vs 10 AM),
the first time slot may show up as starting 15 minutes or half hour past
the actual desired start time.

To align the first available boundary with a sharp hour, we need to manually set the first boundary to the specified start time, and set rounding to a factor of a full hour.

To determine the correct rounding minute amount, we calculate the greatest common factor of the booking length and a full hour using the Euclidean algorithm. For instance, when using a 45 minute time slot, the greatest common divisor with an hour is 15 minutes.

```
const timeSlotMinutes = 45;
const hourMinutes = 60;
/**
* Calculate the greatest common factor (gcf) of two timeslot lengths
* to determine rounding value using the Euclidean algorithm
* (https://en.wikipedia.org/wiki/Euclidean_algorithm).
*/
const gcf = (a, b) => {
return a ? gcf(b % a, a) : b;
};
/**
* Define the rounding value.
* If the first time slot is shorter than general time slot,
* swap the parameters around so that the first parameter is the shorter one
*/
const rounding = gcf(timeSlotMinutes, hourMinutes);
```

### Manually set first boundary to start time

To manually set the first boundary to the start time, we need to pass an
*isFirst* parameter to the *findNextCustomBoundary* function. For the
first time slot, we then skip incrementing completely.

```
export const findNextCustomBoundary = (
currentMomentOrDate,
timeUnit,
- timeZone
+ timeZone,
+ isFirst = false
) => {
+ const increment = isFirst ? 0 : timeSlotMinutes;
return moment(currentMomentOrDate)
.clone()
.tz(timeZone)
- .add(timeSlotMinutes, timeUnit)
- .startOfDuration(timeSlotMinutes, timeUnit)
+ .add(increment, timeUnit)
+ .startOfDuration(rounding, timeUnit)
.toDate();
```

The rounding function now rounds the start time back to the rounding
boundary. However, the default start time is passed to
*findNextCustomBoundary* as one millisecond before start time, since the
default addition of 30 minutes and the *startOfDuration(...)* function
cancel each other out.

Since we want to set the first booking slot manually, we can pass the
start time directly. However, we will need to pass *true* as the
*isFirst* parameter to the very first *findBookingUnitBoundaries*
function call when calling it from *getSharpHours*.

```
- const millisecondBeforeStartTime = new Date(startTime.getTime() - 1);
return findBookingUnitBoundaries({
- currentBoundary: findNextCustomBoundary(millisecondBeforeStartTime, 'minute', timeZone),
+ // add isFirst param to determine first time slot handling
+ currentBoundary: findNextCustomBoundary(startTime, 'minute', timeZone, true),
startMoment: moment(startTime),
endMoment: moment(endTime),
```

## Add separate handling for first timeslot

Sometimes, there are cases where you want to have a basic length for a booking and then different lengths for subsequent time slots. For instance, a listing could feature a 75 minute default bike rental with the option to extend it for 30 minutes at a time. In those cases, you need to create different handling for the first time slot, i.e. the first start and end boundaries.

```
const timeSlotMinutes = 30;
const firstSlotMinutes = 75;
/**
* Define the rounding value.
* If the first time slot is shorter than general time slot,
* swap the parameters around so that the first parameter is the shorter one
*/
const rounding = gcf(timeSlotMinutes, firstSlotMinutes);
```

### Determine first time slot boundaries

In this use case, we want to determine a different behavior for the
start and end boundaries of the first time slot. For this reason, we
need to pass an *isStart* parameter to *findNextCustomBoundary* and use
it to determine the boundary timepoints in addition to the *isFirst*
parameter.

```
export const findNextCustomBoundary = (
currentMomentOrDate,
timeUnit,
timeZone,
isFirst = false,
+ isStart = false
) => {
- const increment = isFirst ? 0 : timeSlotMinutes;
+ // Use the default booking length for non-first slots
+ // Use the first booking length for first end boundary
+ // Use 0 for first start boundary
+ const increment = !isFirst
+ ? timeSlotMinutes
+ : !isStart
+ ? firstSlotMinutes
+ : 0;
return moment(currentMomentOrDate)
.clone()
...
};
```

The *getSharpHours* function is used for both start hours and end hours,
so we need to receive it as a parameter and pass the value on to
*findNextCustomBoundary*.

```
- export const getSharpHours = (startTime, endTime, timeZone, intl) => {
+ export const getSharpHours = (startTime, endTime, timeZone, intl, isStart = false) => {
if (!moment.tz.zone(timeZone)) {
...
return findBookingUnitBoundaries({
- currentBoundary: findNextCustomBoundary(startTime, 'minutes', timeZone, true),
+ // add isFirst and isStart params to determine first time slot handling
+ currentBoundary: findNextCustomBoundary(startTime, 'minutes', timeZone, true, isStart),
startMoment: moment(startTime),
```

### Customize start hour and end hour list behavior

By default, *getStartHours* and *getEndHours* basically retrieve the
same list, but *getStartHours* removes the last entry and *getEndHours*
removes the first entry. Since we have custom start and end handling in
*findNextCustomBoundary*, we also need to modify the start and end hour
lists.

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

In addition, we need to make sure that even when selecting the last start time, there is enough availability for the first timeslot. We do this by removing enough entries from the end so that the first time slot can be booked even from the last start moment.

```
export const getStartHours = (intl, timeZone, startTime, endTime) => {
- const hours = getSharpHours(intl, timeZone, startTime, endTime);
+ const hours = getSharpHours(intl, timeZone, startTime, endTime, true);
- return hours.length < 2 ? hours : hours.slice(0, -1);
+ // Remove enough start times so that the first slot length can successfully be
+ // booked also from the last start time
+ const removeCount = Math.ceil(firstSlotMinutes / timeSlotMinutes)
+ return hours.length < removeCount ? [] : hours.slice(0, -removeCount);
};
```

Finally, we can simplify the end hour handling. Since the first entry is
determined in the *findNextCustomBoundary* 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);
};
```

We can then see that after the first booking length of 75 minutes, the subsequent boundaries are 30 minutes each.