Oh my god, this feature has been like an iceberg that reaches the bottom of the Mariana Trench.
I thought I had a decent code outline before this Christmas week of development, but there was way more to it.
I didn’t want string based matching of the location search term, because nearby suburbs, and relevant matches would be excluded. The feature uses a maps API to geocode the term into a lat/long position. It then uses the spherical distance formula to return a set of cafes within a reasonable distance. As the database grows I wanted to avoid huge DB table scans, having to calculate the distance for every row in order to get a result.
So to make that more performant I’m first calculating an approximate bounding box to limit the results using indices on the lat/lng columns.
The size of the area to search, and the number of results to expect also depends on the context. For example if you’re in Melbourne CBD you’d expect to get a good number of results in a close radius, and showing cafes 4km wouldn’t be useful. However if you’re in a regional Queensland town with much lower density of specialty coffee the radius might need to expand a lot more to show any useful results.
Searching with Cafe Name
This also complicates the search logic as it affects what might be reasonable search radius when used in combination with a location. The name searches uses a full text index against a separate table help fuzzy match terms (to avoid the user having to match it precisely).
This means some irrelevant cafes might be returned and I don’t want to limit the radius too closely or we might only return the wrong cafe, when the correct one was just a bit further away.
I also discovered the complexity of different location scopes. For example if a user searches for “Foster and Black” in “Brisbane” the geolocation lookup will centre Brisbane as being the centre of the CBD. Foster and Black might have locations in various Brisbane suburbs but they might not appear if those suburbs are too far from the CBD. The users intent would be to include them though.
I’ve attempted to solve this by expanding the radius when a name is included until either the max radius is reached, or we find cafes matching the name.
It’s difficult to know until I have more users and cafes if the search logic will hold up and be robust enough. Long term I might need to migrate to a dedicate search tool like Meilisearch or Elasticsearch if they also support geography.
Progress this Week
I’ve spent about 18 hours this week on this (along with a bit of infrastructure work around servers and build tools) and am pretty happy with the progress.
I have found inconsistencies in the data format being returned to the frontend though (depending on the search criteria). I’ll need to normalize that before building out the frontend Vue components.
I’m also wary of the UX if the user clicks away to a result cafe, then comes back to an empty search state. I might look at implementing InteriaJS so this can happen in an SPA style to preserve state. There could be some edge cases there in partially implementing it into a project that also uses traditional server page loads.
This UI uses a location watcher for the browser’s geolocation API feature. I need to geocode that lat/lng too, and I found that in some situations the browser just keeps reporting either the same location, or a very subtly different location again and again.
To avoid thrashing my API endpoint that does the lookup (which itself caches/proxies the external API) I implemented a basic client side cache so that repeat requests for the same position are not sent to the server.
Unfortunately I need to refactor some of this work to be more testable. The geo lookup code a bit too embedded in other logic, so to test that would result in real external API calls.
I need to abstract it out (already used in two places in the app) and make it mockable.
I think the search logic is passable. I would like to incorporate more algorithmic sorting including the full text search ‘score’ of the cafe name, and other parameters such as rating and relevance to the user’s coffee history. That might have to wait for phase 2 though as it’ll add a lot of time to development otherwise.
The immediate step is to build out the result set frontend components so they look good and are usable. There’s some data massaging to go along with that so that individual cafe locations are combined into the parent cafe rows.
And as far as I can remember the only other main features I need to launch are:
User ability to add a new cafe (and for me to review the data and approve)
Ability to edit a reviewed coffee (e.g. fix a mistake)
Would also really like more robust testing of the search feature.
The home screen will let you search for cafes, either by location, or by cafe name (or the combination of the two).
Today I made good progress with the frontend flow of data to support this feature. The app uses Vuex for state storage. This feature has three different Vue components accessing related data to manage the users search.
The quick location search will use the browser geolocation feature as a default, and that data will flow through to the search form via Vuex. If the user is searching for a cafe by name that content will also go through Vuex for global access.
When the search event is triggered the results component can access this state, and send the API request to the server and build out the result set.
The event listening process was a little tricky, as state properties are accessed via getters, and so the normal method of capturing events and passing to component properties won’t work. A combination of Vuex getters, computed properties and watchers lets the result component listen for the search action to do its work.
I can now work on the backend logic for the API endpoint and build out the results data.
I’m a little concerned that that work could get heavy as this is almost a mini-SPA now. Possibly Livewire would have been a good solution, but I’m not going to introduce new architecture at this point in the project development.
I may have to consider how to cache the search result if the user navigates away and comes back. I’d prefer to avoid having to refetch from the server as that delay is annoying when it’s data you have already accessed. Worst case scenario I guess is to capture search criteria in localStorage, and cache the result server side so the response comes in <100ms.
Not all cafes will make white, espresso or filter coffees equally, or equally well. Some might have a focus on espresso and bit a little average for the whites, or vice versa. Rather than combining all their ratings and losing that granularity I will let users specify what sort of drink they had when rating a coffee.
I think this will be important to help give better recommendations. A cafe might do stellar cappuccinos, but if they’re ordinary at a Long Black and that’s what you’re going to order the Cap won’t help you!
The feature does add quite a bit of complexity to the development though. I need to start tracking ratings separately for the drink categories, as well as in aggregate. The scoring algorithm will also ideally weight itself towards a users history and preferences too.
It also has frontend implications with extra UI, state management and icon design requirements. The image above is still, but I am working on building subtly animated SVG icons for the drink types to enhance the UX of choosing them. I’ve got a nice little stepped animation to show the selection action.
I love the flexibility of images-as-code and being able to add simple animation effects with CSS. I am a little concerned that bundling the SVG icons into Vue components will be adding more weight to the final JS build.
I think referencing a static SVG as an image will prevent me from being able to style and animate individual shapes with CSS. It needs to be an embedded object. When I’m already deep in a Vue component structure that means the icon also needs to be a Vue component (as opposed to server side injected SVG code).
In my defence though, I think most of the audience who are into visiting specialty coffee cafes for entertainment probably err towards having high end phones that are capable of dealing with the load. The JS will be cacheable and probably even service worker cached in the future, so it’s more a CPU/parsing issue.
Most of the backend adaptations are still to be done though, and the actual search weighting will happen later as I build that feature.
Glad to be Moving to Vuex State Management
The growing amount of data stored for a rated coffee would get difficult to manage within a single parent component. I’m glad I’ve started the work of using Vuex to control the global state. Passing this many props and events up and down the component chain would be quite messy.
So much of the project framework is complete, but there are a few key features left to build.
Drink Type management described here
Reverse geocoding and caching so the app can refer to your suburb/city and not just Lat/Long
The main cafe search feature, using that geographic information and drink preferences
Ability for users to add missing cafes at the time they are entering a review. (This can use some of the existing backend code I have already written from an admin perspective)
Service Worker / PWA bundling. This will be very minimal for now. Home screen, and basic asset caching, but no real offline support.
Had to use GD for that, my install of Imagick didn’t have WebP support
File upload is sync, but resizing and cloud upload is Queued job so user doesn’t wait
Uploads to S3 and cleans up old file versions
The code that renders the image on the frontend cafe page is suuuper rough. Just gets a random size, rather than using srcset. Sniffs webp support and does cloud disk access right in the view. Definitely needs tidying up, still a good start though.
Sourcing 2000px images (wide on retina) might be difficult. A lot of photos on the web seem to be lower quality. Might have to ask users or cafes to submit a quality one!