My action-packed 9 months at Treebo

Rahulpunase
Treebo Tech Blog
Published in
8 min readJan 12, 2023

--

I joined Treebo Hotels as a Senior Frontend Engineer in April and since then, I have been working on a number of exciting features and implementations for Treebo msite. These have had a direct impact on the user experience and lead to better booking conversations for us.

Map Based Navigation

Many of our customers use Google maps as a tool to navigate and make decisions about where to stay while on vacation/travel. They find these maps particularly useful when deciding between different hotels and tourist destinations.

It is by far the most intriguing feature I’ve worked on.

The library I used in implementation is google-map-react. It is a higher-order component written over a set of multiple Google Maps API.

On clicking on View On Map, a Modal opens up, which renders the three major components all working together. The actual Full Screen map, Searchbar, and list of Nearby places (to the selected hotel) are at the bottom.

Let me start with the FullScreenMap component. The library’s documentation is quite clear and detailed, but I needed to push its limit on the feature we needed. We needed it to render routes, reset bounds, render custom markers and search new places within 50 km of selected hotel.

import GoogleMapReact from 'google-map-react';
...
<GoogleMapReact
bootstrapURLKeys={{
key: __CONFIG__.googleMapsApiKey,
}}
defaultCenter={defaultProps.center}
defaultZoom={defaultProps.zoom}
yesIWantToUseGoogleMapApiInternals
onGoogleApiLoaded={({ map, maps }) => handleApiLoaded(map, maps)}
fullscreenControl={false}
zoomControl={false}
options={mapOptions}
onClick={() => {
setShowSearchedResults(false);
setSearchQuery('');
setVisiblePopupIndex(null);
}}
>
...
//Render markers
...
</GoogleMapReact>
const handleApiLoaded = (map, maps) =>
setGoogleMapApi({
map,
maps,
directionService: new maps.DirectionsService(),
renderService: new maps.DirectionsRenderer({ suppressMarkers: true }),
});

It was necessary to save those services in a state as I wanted the same reference multiple times. So, choose to save it in a state.

The Direction Service is for generating the route from the center of the hotel to any other location selected.

The Render Service to render routes on the map.

For Autocomplete, we used our own gql written api which internally call the AutoComplete API of google

The feature also includes the rendering of the places from the bottom list. As the list is categorised, when the user selected any of the categories, all the places in that category have to be rendered on the map. That I achieved this by creating a Markers array and passing those markers their lat and lng.

const resetBounds = () => {
const bounds = new googleMapApi.maps.LatLngBounds();
bounds.extend(new googleMapApi.maps.LatLng(center));
markersOnMap.forEach((place) => {
bounds.extend(
new googleMapApi.maps.LatLng({
lat: place.latitude,
lng: place.longitude,
})
);
});
googleMapApi.map.fitBounds(bounds);
};

fitBounds function from google api is used to make sure all the markers are visible within the screen no matter how far they are from each other.

And that’s how the route is being rendered.

const renderRoute = (destination) => {
const { map, maps, directionService, renderService } = googleMapApi;
renderService.setOptions({
polylineOptions: {
strokeColor: theme.color.primary,
strokeWeight: 2,
},
});

directionService.route(
{
origin: center,
destination,
travelMode: maps.TravelMode.DRIVING,
},
(result, status) => {
if (status === maps.DirectionsStatus.OK) {
renderService.setMap(map);
renderService.setDirections(result);
if (isFirstTime) {
resetBounds();
setFirstTime(false);
}
} else {
console.error(`error fetching directions ${result}`);
}
setIsRouteLoading(false);
}
);
};

And to make all the components interact together, I created a Context, and a Reducer to manage all the states.

Popular Cities

Popular City section provides easy access to the cities that are of most
interest to users and is particularly useful for people who are looking for
tourist-friendly places to visit.

The requirement was when the user scrolls, show a corresponding callout text with the arrow pointing from the callout to the city.

For the cities in positions, 2 to 4 (position 1 being ‘Near Me’) — one should be able to enter a callout.

If more than 1 city in these 3 positions has a callout, priority would be given to position 2 > position 3 > position 4.

My initial approach was to use the Swiper library and get the index position of the items after the scroll. But, it didn’t work out due to not-so-great UX and
boundary conditions not being met.

So, I simply used the logic to check if the user is scrolling and has stopped scrolling.

useEffect(() => {
const scrollableContainer = scrollableContainerRef.current;
const scrollHandler = (event) => {
const timeOut = 150;
setIsScrolling(true);
const { target } = event;
clearTimeout(target.scrollTimeout);
target.scrollTimeout = setTimeout(() => {
const children = Array.from(scrollableContainer.children);
for (let i = 0; i < children.length; i += 1) {
const rect = children[i].getBoundingClientRect();
if (Math.floor(rect.right) > 0) {
onPosition(Number(children[i].dataset.index));
setIsScrolling(false);
break;
}
}
}, timeOut);
};
scrollableContainer.addEventListener('scroll', scrollHandler);
onPosition(0);
return () => {
scrollableContainer.removeEventListener('scroll', scrollHandler);
};
}, []);

Above code gives the correct index of the item which has snapped at the rightmost position inside the scrollableContainer. I’ll pass that index to a bunch of state updates on my Popular city component to render the correct callout with the correct arrow position.

const onPosition = (activeSlideIndex) => {
const cities = [...citiesToRender].map((city, index) => ({
...city,
solidIndex: index,
}));
const ind = cities
.slice(activeSlideIndex + 1, activeSlideIndex + 4)
.find((city) => !!city.call_out);
setShowOnIndex(ind ? ind.solidIndex : -1);
setActiveIndex(activeSlideIndex);
};

Treebo Hotel Search Filters

Treebo Hotel Search page allows our users to find hotels based on many criteria. Users can find hotels that offer certain amenities, are in their price range, are near a landmark or locality, etc. It’s a feature pack list of filters. Here are all the filters Treebo offers.

What’s worth noting is we do filtering on the client side and this resulted in challenges related to performance, optimized component rendering, memoizing the heavy operations, UI lags which, for the first time ever in my life, forced me to use web-workers. And I gotta say, I loved it.

I used worker-loader. It’s an amazing plugin that, with a little bit of configuration, lets you bundle the worker.js files within your module.

{
test: /\.worker\.js$/,
use: { loader: 'worker-loader', options: { inline: 'fallback' } },
},
// a simple configuration has to be added at your client side webpack configuration
// and you are done.

Now you can create a worker file with the naming convention mentioned in the test. I named the file filter.worker.js. This file will have one onmessage function that the worker will interact with to perform the heavy operation in the background threads.

// filter.worker.js
import { FilterWorkerActions } from '../filter/filterConstants';
import { getMappedFiltersWithCount, filterResults, sortResults } from '../filter/filterService';

onmessage = (oEvent) => {
const { type, payload } = JSON.parse(oEvent.data);
switch (type) {
case FilterWorkerActions.UPDATE_FILTER: {
const { filters, mapped, filterKey, item, checked } = payload;
filters[filterKey] = mapped;
const { result, cachedFilterResults } = filterResults({
...payload,
filters,
});
const countPayload = getMappedFiltersWithCount({
...payload,
filters,
cachedFilterResults,
shouldSort: false,
});
postMessage({
type,
payload: {
result,
countPayload,
filterKey,
item,
checked,
},
});
break;
}
// ...
default:
break;
}

We are going to need one more file which will consist of all the functions that we are going to call wherever we are going to need to run the filters task in the background.

// FilterWorkerService.js
export default {
worker: null,
dispatch: null,
getState: () => {},
init(store) {
this.dispatch = store.dispatch;
this.getState = store.getState;
import('./filter.worker.js').then(({ default: FilterWorker }) => {
this.worker = new FilterWorker();
this.worker.onmessage = (event) => {
const { type, payload } = event.data;
switch (type) {
case FilterWorkerActions.UPDATE_FILTER: {
const { result, countPayload, filterKey, item, checked } = payload;
this.dispatch(updateCountAndPreRefinedIds(result, countPayload));
break;
}
// ...
default:
break;
}
};
});
},

updateFilteredResultsOnFilterChange(filterKey, actualIndex, checked, item) {
if (!this.worker) return;
const state = this.getState();
const {
filter: { filters },
} = state;
const mapped = getUpdatedStateForFilter(filters, filterKey, actualIndex, checked);
const paramPayload = {
filterKey,
actualIndex,
checked,
mapped,
item,
...this.getParamsToStringyfy(state),
};
this.worker.postMessage(
JSON.stringify({
type: FilterWorkerActions.UPDATE_FILTER,
payload: paramPayload,
})
);
},
};

FilterWorkerService.js file is responsible to import the filter.worker.js dynamically and subscribing to onmessage event. Whenever updateFilteredResultsOnFilterChange is called, it sends the data to onmessage event of filter.worker.js which performs the heavy operation in the background, hence freeing the main thread, which solved the UI lag issue.

Once the operation is done, I dispatch the result to the redux store, which updates the filtered results and their count also.

The major challenge in filters was to make them performant. Since this development was for mobile browsers, which usually perform slower than a desktop browsers. And hence, I have to come up with the most efficient solution possible and the abundance number of filters did not make it easy.

The logic is simple, if the filter is checked, filter the result just for that particular checked item and do not merge them until the very end like I am doing in cachedFilterResults. This cachedFilterResults is what I am going to use later to get the count of each individual filter.

export const filterResults = ({ hotelIds, priceByHotelIds, hotelByIds, datePicker, filters }) => {
const idsToFilter = [...hotelIds];
const filterByPrice = () => {
const hasPrices = Object.keys(priceByHotelIds).length > 1;
const checkedPriceRanges = filters.priceRanges.filter((priceRange) => priceRange.checked);
const shouldFilterPriceRanges = checkedPriceRanges.length && hasPrices;
if (shouldFilterPriceRanges) {
const noOfNights = getNoOfNights(datePicker);
return idsToFilter.filter((hotelId) => {
const sellingprice = priceByHotelIds[hotelId];
return inPriceRange(checkedPriceRanges, sellingprice, noOfNights);
});
}
return idsToFilter;
};
const filterByLocality = () => {
const checkedLocalities = filters.localities.filter((locality) => locality.checked);
const shouldFilterLocalities = checkedLocalities.length;
const filtered = shouldFilterLocalities
? idsToFilter.filter((hotelId) => inLocalities(checkedLocalities, hotelByIds[hotelId]))
: idsToFilter;
return filtered;
};
// a lot of other filter functions...
const cachedFilterResults = {
[FilterKeys.PRICE_RANGES]: filterByPrice(),
[FilterKeys.LOCALITIES]: filterByLocality(),
[FilterKeys.BRANDS]: filterByBrand(),
[FilterKeys.USER_RATING]: filterByUserRating(),
[FilterKeys.ROOM_AMENITIES]: filterByRoomAmenities(),
[FilterKeys.OTHER_AMENITIES]: filterByOtherAmenities(),
[FilterKeys.PAYMENT_AND_POLICIES]: filterByPolicy(),
[FilterKeys.POPULAR_COLLECTIONS]: filterByPopularCollections(),
[FilterKeys.BED_TYPES]: filterByBed(),
};
const checkedFilteredData = [];
Object.keys(filters).forEach((key) => {
checkedFilteredData.push(cachedFilterResults[key]);
});
const allFilteredData = intersectionBy.apply(this, checkedFilteredData);
const withPriceOnly = idsToFilter.filter((hotelId) => {
const sellingPrice = priceByHotelIds[hotelId];
if (sellingPrice) {
return sellingPrice > 0;
}
return false;
});
const result = getSelectedFiltersCount(filters)
? intersectionBy(allFilteredData, withPriceOnly)
: allFilteredData;
cachedFilterResults.withPriceOnly = withPriceOnly;
return { result, cachedFilterResults };
}

And now each filter needs to be mapped with its correct count too. This many rendering and re-rendering of children can run into serious performance issues which need to be addressed. I also ran into such issues. And I solved them by memoizing the components.

Last but not least

Redesigning the date picker which is using https://airbnb.io/projects/react-dates/

And a lot more….

--

--