Dispatch system and customer app for a taxi cooperative
Everything a thirteen-driver Schiphol taxi cooperative runs on: the website, the app, the booking form, the dispatch board, and a Google Calendar that had to stay the boss of all of it.
Taxi Haarlem Service is a cooperative of thirteen drivers who mostly drive people from Haarlem and the villages around it to Schiphol, and back. They compete with Uber on the same road, and they win on price: Haarlem to the airport is a fixed €39, in a car that was booked the night before and turns up at the door at four in the morning. I have been their only developer since the beginning of 2021. Over five years that became a website, a customer app, a booking form shared by both, a backend that prices and books rides, a dispatch board for the planner, and a robot that watches Schiphol.
None of it was designed in one go. Each piece was built when the previous one started to hurt, which is why some of the decisions below look odd until you know what they were reacting to.
The calendar had to stay
When I arrived the whole company ran on one shared Google Calendar. A reservation was an event. The planner typed it in from the phone, moved it when a customer called back, and coloured it when it was confirmed. The drivers looked at that calendar on their own phones. It worked, everyone knew it, and the planner made it clear that whatever I built, the calendar was not going anywhere.
So I did not replace it. The calendar is still the operational truth for every ride: which driver has it, what colour it is, whether it is cancelled. What I built sits around it and reads and writes calendar events like a very disciplined colleague.
That meant squeezing structured data into fields Google meant for something else. The driver is an attendee: each driver has a numbered Google account, and inviting that account to an event is the assignment. Google sends the invite for free, and the driver accepting it is the acknowledgement, so the board shows a tick, a cross or an hourglass per ride without a line of notification code. Status is the event colour, four of them. The location field carries a pipe-separated row with the town, the price, the vehicle type and the flight number. The description is a block of labelled lines that a person can read in the Google app and a regex can parse, and the last line is the reservation number that points back to the full record in Firestore.
The cost of this is that Google Calendar is slow to read and has no useful change feed. The first version of the board fetched everything from June 2022 on every load. The fix was to chunk history into months and cache them for a very long time, the current month for a few days, and the coming week for an hour. Later I found a cheaper trick: ask the API only for events updated since the last look, with the deleted ones included, and refetch just those days. That poll runs every minute and is what makes an edit typed into Google on the planner’s phone appear on the board.
What I never solved: the calendar and the database can disagree when someone edits the wrong one. I chose which side wins per field rather than reconciling, and the flight sync will overwrite a manually moved pickup time on its next pass. The planner knows this. It is on the list.
The dispatch board
The board is a SvelteKit progressive web app for the planner and the drivers. It shows the day as a list, a map or a timeline. Assigning a ride opens a sheet that sorts the drivers into available, recently available, starting soon and off, from their shift schedules sampled in ten-minute slots, and shows how many rides each already has around that time. Several rides can be selected and given to one driver in one go, which is how a Saturday morning of forty Schiphol runs gets divided in a couple of minutes.
The map replays a day: every car moves along its rides at the speed you choose, so the planner can scrub through yesterday and see where the gaps were. Drivers can share their live position while they work, which on iOS means keeping the screen awake and nagging the phone every few minutes, because Safari does not do background location and I am not going to pretend it does. The statistics tab counts the day by source, by direction and by driver, and adds up the money.
Everything is cached in IndexedDB on the device, so the board opens on the motorway with no signal and shows what it knew.
The robot that watches Schiphol
About a third of the rides are pickups at the airport, and a pickup at the airport is only as good as your knowledge of when the plane lands. Before, the planner refreshed a flight tracker and moved events by hand. Now a Cloud Function runs every two minutes. It reads every event from five hours ago to sixteen hours ahead, finds a flight number in the location field or on a line that says vluchtnummer, and asks Schiphol’s public flight API about it.
If the flight has moved, the event moves with it. The pickup time is set to the landing time, actual if there is one, else estimated, else scheduled. The description gets the gate, the arrival hall, the baggage belt, the aircraft type, and whether the passenger has to pass passport control, which is just whether the origin is outside Schengen. When the first and the last bag hit the belt, that is recorded too, and never overwritten afterwards, so the driver can see how long the passenger has been standing there. A cancelled flight puts a red cross in the title rather than deleting anything, because the customer still needs a call.
Two things about this were harder than they look. Schiphol revoked access to the operational API halfway through, so everything now goes through the public one, which wants the flight name in its own format and does not know about codeshares. The function tries the number with and without leading zeros, and if that fails it pages through every arrival of that airline that day, up to a hundred pages with a fifty-millisecond pause between them, and matches on the digits. The other is idempotence: the function hashes the fields it writes, and only calls the calendar when the hash changes. Without that, every run rewrote every event, Google sent every driver a notification every two minutes, and I heard about it.
One booking form, four homes
The website, the customer app and a white-label version for partners all use the same form, a package in one Turborepo. It is the fourth version of that form. The first was Svelte, the second React, the third React again but tangled into the website, and only the fourth is a package that knows nothing about where it is mounted.
It is one screen, not a wizard. You type a street, it finds the house with Google Places and a postcode lookup, and the price is already there before you have picked a date. For any ride that touches an airport, the price comes from a table of fixed fares per town and vehicle type, which the cooperative edits in a CMS. For everything else it is a start rate plus a per-kilometre rate that drops after 25 kilometres, with a flat surcharge for a stop on the way. Special luggage upgrades the car: a bicycle or a wheelchair forces a van, and the form says so instead of letting you book a car that will not fit. A flight number is required exactly when it is needed, which is when the airport is the first stop or the ride has a return leg.
The app that is a website in a coat
There was a React Native app once. It worked, and every change meant a build, a review and a wait. It has been replaced by eleven hundred lines of Swift that open a WKWebView on the app’s URL and do the four things a web page cannot: push notifications, a native print dialog, keeping navigation inside the app’s own domain, and telling the page it is running in the store version by way of a cookie. The app itself is a Next.js site with a service worker. A deploy is an update. Nobody has waited for App Store review since, and the app and the website have not been out of step once.
In the app, a logged-in customer sees their rides, edits or cancels one within the rules the cooperative set, adds it to their own calendar, and calls the driver. Rides are kept locally, so the list is there in the tunnel under Schiphol.
What it added up to
On the February day in the screenshots above the company did 65 rides: 28 booked in the app, 37 on the website, none of them by phone, and the planner had divided them before breakfast. The prices are lower than the ride-hailing apps and the experience is, honestly, better, because the car is booked and the driver knows your flight number. The stack behind that is unglamorous: a Google Calendar, a Firestore, a few Cloud Functions and two small web apps. That was the point. A small company should not run on software that needs a team to keep alive.
The pieces and how they talk. Firestore holds the record, Google Calendar holds the operation, and the dispatch app joins the two on the event id. The Schiphol function only ever touches the calendar.