Skip to main content

Lagoon

·1643 words·8 mins
David Smith
Author
David Smith

So I wakeboard at Hove Lagoon, have done for a few years now (only with any level of dedication since last year when I became a member), and the booking system they have is one of those websites that works perfectly well but was clearly built for a desktop browser sometime around 2015, which means that checking availability from my phone doesn’t work so well especially when standing in a car park wondering if there’s a spot on Saturday. I’d been doing this long enough that at some point it crossed the line from mild annoyance into “I could probably just build something” which is always a dangerous thought when you have access to a laptop and some time.

What started as “just show me the free sessions on a phone-sized screen” is now a proper little PWA that lives at dave-smith.co.uk/lagoon and does rather more than I originally intended, which is a sentence I suspect most developers have written at some point. It pulls live availability from the Lagoon booking API, shows what’s free for the next few weeks, overlays weather data from Open-Meteo so I can see if Saturday’s session is going to be 22 degrees and sunny or 14 degrees with horizontal rain, tracks my bookings and ride-pass tokens, shows my ride history and streak, and as of recently lets me book membership-covered sessions right there in the app without bouncing out to the full website. It does push notifications too, so if a spot opens up on a day and session type I ride it pings my phone even with the app closed, which has genuinely got me onto the water on days I wouldn’t have otherwise known were available.

The whole thing is vanilla JavaScript, no framework, no build step, no dependencies, just plain JS files that the browser runs as-is. That was a deliberate decision from the start and one I’ve stuck to despite the occasional temptation, mostly because keeping it tiny means the service worker caches the whole app shell in about 200 milliseconds and the thing opens instantly even on flaky signal at the lagoon. It talks directly to the public Lagoon booking API and Open-Meteo for weather, there’s no server of mine in the middle for the browsing side of things, although there is a fair bit of AWS behind it for the bits that need to happen when the app isn’t open.

The push notification system is a Python Lambda on an EventBridge cron that polls the Lagoon API every five minutes between 06:00 and midnight, compares each session’s free count against the last run stored in S3, and sends Web Push when a slot count goes up. Subscriptions live in DynamoDB keyed on a hash of the push endpoint, registration is a separate Lambda behind a Function URL where the opaque endpoint itself is the capability so there’s no auth layer to build, and the VAPID key sits in SSM. The whole thing runs for essentially nothing — Lambda free tier covers it, and even at a hundred subscribers it would be well under a pound a month, which given what I spend on the membership itself feels like a rounding error.

The notifications have a few touches I’m quietly pleased with. There’s a reachability filter so it won’t ping you about a session you can’t physically get to — you set your travel time to the lagoon in Settings and it checks that the session start minus now is greater than your travel time plus a fifteen minute buffer, because there’s no point getting excited about a slot that opens twenty minutes before the session if you’re forty minutes away. There’s self-cancel suppression too, so if you free a slot it pings everyone else but not you, which would otherwise be annoying. And there’s a five-per-day cap, quiet hours between nine at night and eight in the morning, and coalescing so a single poll run that finds multiple openings sends one push rather than five. The restraint side of notifications is genuinely more work than the sending side, which probably tells you something about push notifications in general.

The analytics system shares the same CDK stack, a dependency-free ingest Lambda behind a Function URL fronted by CloudFront at the same origin as the app so ad blockers don’t kill the beacon, writing NDJSON to S3 with Athena on top for querying. The cookieless part is by construction rather than by policy — the identifier is a SHA-256 of a salt plus the UTC date plus the IP and user agent, hashed on arrival with the raw values never stored, and the ID re-rolls every day so there’s no persistent identifier and no cookie banner needed, which was rather the point of building it that way.

One thing that caught me out on the push side was the pywebpush library needing native crypto, which means the Lambda bundle has to be built inside a Docker image matching the Lambda runtime — a pip install on a Mac produces something that looks perfectly fine and then fails silently when the Lambda actually tries to sign a push payload. It’s the kind of thing that costs you an evening the first time and a shrug every time after that, but it’s also the kind of thing I should probably have expected given that I’ve been doing this long enough to know that native dependencies and Lambda are not natural friends.

The booking feature was the interesting one to build. The Lagoon API has no documented “create a booking” endpoint, or at least nothing I could find, so the way we cracked it was about as low-tech as it gets — I clicked through a real booking on the website while Claude Code watched the network traffic in Chrome, and between us we worked out it’s a two-call sequence, create a pending booking then a gift-voucher payment call to complete it, reusing the auth token the app already had. Turns out the website is just another client of the same API, which once you see it is obvious but wasn’t obvious until we’d seen it.

The one iron rule for the whole booking design is “no payments, ever” and everything bends around that. The app is structurally incapable of completing a paid booking — it creates the pending order, reads it back, and only completes if it can positively confirm the total is genuinely zero, which for a membership-covered session it is. Anything else, anything ambiguous, anything that isn’t definitively free, it aborts and sends you to the website to do it properly. I didn’t want to be anywhere near handling someone’s money through a reverse-engineered API, and the design reflects that pretty firmly.

Which brings me to the afternoon it went live and immediately broke, twice, in ways that were embarrassing but at least not dangerous. The first bug was a cap check — the system limits you to four upcoming bookings, and the code was checking that against your entire booking history rather than just the upcoming ones. I’ve been a member since last year, but ridden a few times in previous years. I’ve got 131 lifetime bookings across my account, so as far as the app was concerned I was permanently and hilariously over cap and every session was silently bouncing me to the website. It took inspecting my own live app data in the browser to spot that it was counting all-time bookings instead of upcoming, which is a special kind of irony — I’d used my own app so much that I broke it for myself ;-)

The second bug appeared once the cap was fixed and the booking sheet actually opened for the first time — a global CSS rule that was meant for the login form was turning the rider selection checkboxes into full-width boxes and shoving everyone’s name off the edge of the card, which wasn’t ideal. Both bugs were fixed live in quick succession and the reassuring part through all of it was that the safety gate did exactly what it was supposed to, when it couldn’t confirm a booking was free it just bounced to the website, never once unsafe. The bugs were “feature won’t appear” rather than “charged someone”, which is the right failure mode to have.

Then a real multi-rider booking went through and worked flawlessly, which was a relief.

The in-app booking sheet

The session that was supposed to be “just add in-app booking” ended up being seven releases in one sitting, from v109 to v115, because once the booking worked we polished the UI, promoted it straight from behind a feature flag to live for everyone skipping the beta tier entirely (living life on the edge), cleaned up the flag, and flipped on a whole separate cookieless analytics system that had been sitting dormant for weeks waiting for a good moment. A few hundred people use the app each month, shared through a WhatsApp group of Lagoon members, and the next thing I’m curious about is whether any of them actually book through it now that they can.

The whole thing was built collaboratively with Claude Code, PR by PR with CI-gated reviews, and the booking feature in particular went through a proper process — brainstorm, written spec, written plan, subagent-reviewed tasks with a review gate after each one and a whole-branch review at the end. It’s a good example of what you can build with an evening here and there, a public API that someone left the door open on, and a stubborn refusal to install React.


Next up is getting the analytics data into something I can actually look at, and figuring out if the push notification subscribers are the same three people or if it’s spreading. Also I should probably go wakeboarding at some point rather than just building tools around it.