diff --git a/public/projects/tfnsw-train-alerts/homepage.webp b/public/projects/tfnsw-train-alerts/homepage.webp new file mode 100644 index 0000000..62fcf72 Binary files /dev/null and b/public/projects/tfnsw-train-alerts/homepage.webp differ diff --git a/public/projects/tfnsw-train-alerts/manage-pause.webp b/public/projects/tfnsw-train-alerts/manage-pause.webp new file mode 100644 index 0000000..9ab605c Binary files /dev/null and b/public/projects/tfnsw-train-alerts/manage-pause.webp differ diff --git a/public/projects/tfnsw-train-alerts/station-autocomplete.webp b/public/projects/tfnsw-train-alerts/station-autocomplete.webp new file mode 100644 index 0000000..0926017 Binary files /dev/null and b/public/projects/tfnsw-train-alerts/station-autocomplete.webp differ diff --git a/public/projects/tfnsw-train-alerts/timing-and-schedule.webp b/public/projects/tfnsw-train-alerts/timing-and-schedule.webp new file mode 100644 index 0000000..c039cce Binary files /dev/null and b/public/projects/tfnsw-train-alerts/timing-and-schedule.webp differ diff --git a/src/data/projects.json b/src/data/projects.json index 5f5a1f4..2a171e8 100644 --- a/src/data/projects.json +++ b/src/data/projects.json @@ -1,4 +1,15 @@ [ + { + "slug": "tfnsw-train-alerts", + "comingSoon": false, + "title": "Train Alerts: a nudge before every Sydney train", + "company": "", + "description": "A departure alert service for Sydney trains built on Cloudflare Workers and Durable Objects. Pick a station, a lead time and your travel window and it pings you before each train actually leaves, following the live delays, with an \"I'm on board\" tap that keeps the rest of the window quiet.", + "tech": ["TypeScript", "Cloudflare Workers", "Durable Objects", "Cloudflare D1", "Workers KV", "Cloudflare Containers", "Docker", "Cache API", "Hono", "Web Push / VAPID", "WebCrypto", "Resend", "TfNSW Open Data", "GTFS", "Vitest", "Playwright"], + "period": "July 2026 — August 2026", + "order": 1, + "content": "

Overview

\n

It's been nothing short of an exceptional first two months at Gyoza Flights! Jumping into the world of aviation has been super interesting and it has shifted my thinking as both an engineer and person. A recent example of this was the other day I was standing at Wynyard Station waiting for a train to Town Hall. My train of thought immediately drifted to origins and destinations. How my destination of Cronulla was all I really cared about and knowing when the next couple of trains would be departing. Sure I could go onto one of the various apps available and check but with my engineering brain I questioned if that was actually necessary. And did it cover what I really wanted? Sometimes I work back in the office, sometimes I leave early. I'm only in the office a few days a week and some of those are fortnightly. So as I stood and waited for my train an idea started to percolate. What if I just had a website where people could just choose an origin and destination, set a lead time, a window to receive the alerts, a type of alert (email or push notification) and then set and forget. Then they'd automatically get these alerts to their device and would be able to proactively determine when they should leave their office to make a train that suits what time they were thinking of leaving on that day. And that is how sydneytrainalerts.com was born.

\n

I build on Cloudflare constantly and my tennis calendar is still quietly doing its thing from a Worker right now, so I had a fair idea of what was sitting there already. Then I remembered the alarm on Durable Objects, where a tiny stateful object can put itself to sleep and wake at an exact moment. That's precisely the shape of \"ping me two minutes before each train\", so that's what I built it on. You pick a station, how much notice you want, the days and window you travel in and whether you want the alert by push or email. From then on it watches the live departures and nudges you before each train you could catch. Once you're on, you tap \"I'm on board\" and it stays quiet until your next window.

\n\"The\n\n

How it works

\n

The data comes from Transport for NSW's Open Data platform. Their Trip Planner API has a Departure Monitor endpoint that hands back the upcoming departures from a stop as plain JSON with the realtime estimates already merged in, so there's no GTFS protobuf wrangling at all. A second endpoint, Stop Finder, powers the station autocomplete on the form. The free tier gives you 60,000 calls a day which is heaps for a personal tool like this.

\n\"The\n

The part I really wanted to get right is the timing. Every alert is its own little Cloudflare Durable Object that watches your station through your travel window, follows the live delays and nudges you before each train you could catch. Once it's pinged you about one it lists the next couple behind it too, so if you miss the 5:12 you already know the 5:22 is coming. Getting that to actually work is the most interesting thing in the whole build, so I've given it its own section below.

\n\"Choosing\n

Schedules are Sydney wall-clock time with travel days, a start and end for the window and even fortnightly days for rosters that alternate. The subscriptions themselves live in a D1 database, which stays the source of truth. A cron fires every 15 minutes purely as a safety net so nothing gets stranded after a deploy. Once you're on your train you tap \"I'm on board\" and it goes quiet for the rest of that window, then picks back up on your next travel day. That pause sits on the manage page for every alert as well as on the notification itself.

\n\"The\n

One thing I had to be careful about is staying inside that free tier once more than a handful of people are using it. TfNSW's free plan also caps you at about 5 calls a second. It starts knocking requests back the moment you push past that. So the departure lookups run through Cloudflare's cache, keyed on the station and the minute. If ten people are all watching Town Hall at 5:31 they mostly land on the same cached answer instead of making ten separate calls. If TfNSW ever does rate-limit me the alerts back off and give it room instead of hammering away. I also sprinkle a bit of random jitter on the wake-ups so a deploy doesn't fire every alert at the same second and trip the limit myself. Since then I've added a counter in D1 that tallies every call that genuinely leaves for TfNSW, keyed on the Sydney day the quota actually resets on rather than UTC. It emails me as I cross 50, 75, 90 and 100% of the daily allowance. I'd rather find out I'm running hot from my inbox in the morning than from a window of alerts that quietly didn't go out. None of it is glamorous but it's what lets one free key quietly handle a proper crowd of commuters.

\n

Delivery is push or email. Push is the default now and that swap was worth doing. An email subscriber costs a send for every matching train against a provider cap I share across both sites, where a push message costs nothing and skips the confirmation email entirely, because granting the browser permission is already proof you own the device. Push started out as the second radio button and unticked, so almost everyone was taking the expensive channel without ever thinking about it. The form still flips itself back to email on any browser where push isn't a single tap, uninstalled iPhone Safari especially, so nobody lands on an Add to Home Screen wall when they were just trying to fill in a form. Email goes through Resend with a manage link in every message so you can pause or cancel from the alert itself. If the push service ever reports a subscription as gone, the alert deactivates itself rather than pushing into the void forever. The frontend is one dependency-free page, no framework, with the autocomplete, a live preview of the alert you're building and a proper install walkthrough for iPhones, because iOS will only deliver web push to a site that's been added to the home screen. It started at about 2,000 lines and it's past 8,000 now. The Worker itself still only carries two runtime dependencies, Hono for the routing and the small Cloudflare helper that drives the rebuild container.

\n

The thing I never planned on building was a second site. Ferries turn out to be the exact same problem with different nouns, so instead of forking the lot I made the mode a property of the request. The Worker reads the Host header, picks up the config for that brand and serves sydneyferryalerts.com off the same code, the same database and the same deploy as the trains site. That mode then gets stamped onto each subscription, because a Durable Object waking up on an alarm hours later has no request sitting there to read a hostname off. Everything that differs between the two lives in one config object, the brand and the homepage copy and whether you're waiting on a platform or a wharf. A third mode is one more entry in it.

\n\n

The Cool Nerd Part

\n

Right, this is the bit I actually get excited about. Before I committed to building it this way I sat down and properly grilled the design, because \"give every single alert its own little server that never really sleeps\" sounds mad when you first say it out loud. The more I poked at it though the more Durable Objects turned out to be the perfect fit, so let me walk through why.

\n

The timing first. Your train is meant to leave at 5:12 so a two minute warning should fire at 5:10. Then it runs four minutes late and the warning needs to fire at 5:14 instead. The target keeps moving all afternoon. A Durable Object has this thing called an alarm, which is basically a single built-in timer the object can point at any exact moment in the future. Better still, it survives restarts and deploys. So every time the alert wakes up and checks the live departures it works out the new fire time from the latest estimate and re-points its own alarm at it. The 5:12 slips to 5:14 and the alarm slides right along with it. It's an alert that reschedules itself off the delay. A plain cron can't do that. The finest a cron will give you is once a minute, so you'd be waking every minute, pulling every subscription and still landing up to a minute wide of a target that won't sit still.

\n

Then there's the fact that each alert is its own object with its own name. The trick with Durable Objects is that you address one by a string. I just use the subscription's id as that string. So wherever I am in the code, whether it's the subscribe request, the cancel button, the \"I'm on board\" tap or the safety-net cron, I hand it the same id and I'm always talking to the exact same object holding that one alert's state. No lookup table, no routing, no working out which server a subscription lives on. The id is the address. And because each object only ever holds its own alert, one person's alert going sideways can't touch anyone else's.

\n

Here's the one that really sold me though. A Durable Object only ever does one thing at a time. It won't run two requests against the same object at once, it queues them up. That sounds like a small detail but it's the whole reason the tricky part of this app is even doable, which brings me to the messy bit.

\n

A single wake-up isn't instant. The object has to go out over the network to TfNSW for the live departures. Then it has to actually send your push or your email. Both of those take real time. Now picture the alert half way through that, sitting there waiting on the departures to come back. Right at that moment you tap \"cancel\" or \"I'm on board\". Because the object does one thing at a time your tap can't barge in mid-step, but it does get its turn the instant the alert pauses to wait on the network. So it flips the state out from under the rest of the run. The alert wakes back up thinking everything's fine and, if I'm not careful, cheerfully sends you a notification for an alert you just cancelled.

\n

The honest answer to that is a lot of little if-checks. The wake-up loop is peppered with early exits that all ask the same question in different spots: has this been cancelled or paused while I wasn't looking? Bail out if it has. There's one on the very first line before it does anything. There's one straight after the departures come back from TfNSW. There's one right before it sends each notification and another right after. There's even one tucked inside the final save, so if a cancel landed while the alert was mid-flight, the moment it goes to write \"all done, wake me in 45 seconds\" it checks one last time and just doesn't, because arming a fresh timer on an alert you've killed is exactly how you end up haunted by a cancelled alert that won't die. Cancelling also leaves a little durable marker behind, a tombstone, so the safety-net cron can't come past later, spot an alert with no timer set and helpfully resurrect the very thing you just deleted.

\n

It's not the most elegant code I've ever written and there are more of those guards than I'd like. But every single one of them is a real race I could sit and describe to you. And the reason they get to be simple little if-blocks instead of a knot of locks is that one-thing-at-a-time promise. Without Durable Objects I'd be running one big loop over everyone's alerts, all of them fighting over the same database rows with a proper lock bolted on so a cancel and a poll couldn't clobber each other. With a Durable Object the isolation is the lock. Each alert ends up as a little self-winding alarm clock, chasing the train's live delay, costing nothing while it's asleep between your commutes and never fighting anyone for its own state, because it's the only thing that ever touches it.

\n\n

Sticking points

\n

The API that swore every station was invalid

\n

Early on the station search flat out refused to work. Every query came back with zero locations and a \"stop invalid\" message, even for Central. The culprit was one innocent looking parameter. Stop Finder takes a type_sf field and I'd set it to stop, because I was searching for stops. It turns out type_sf=stop actually means \"the text I'm sending is a stop ID\", so the broker was taking the word Cronulla and trying to look it up as a numeric station identifier. The fix was type_sf=any and then filtering the mixed bag of streets and points of interest down to actual stops. I only caught it because I verified the client against the live API instead of trusting the docs. The mock TfNSW server my Playwright tests drive now mirrors that exact broker behaviour, so this one can never quietly come back.

\n

Daylight saving and the missing hour

\n

Active windows are Sydney local time and my first version of \"when does today's window start\" was simple minute arithmetic from the current moment. An adversarial review pass over the whole codebase caught that the simple version drifts by exactly an hour whenever the calculation straddles the 2am daylight saving changeover, which in the worst case means a whole morning of missed alerts. The fix was to stop doing arithmetic and start constructing the actual Sydney wall time, using Intl with the Australia/Sydney timezone to check the guess and correct it. I reckon DST bugs are the sneakiest kind there is, because the broken code is provably correct for 363 days of the year.

\n

The alert that sent people the wrong way

\n

The \"Towards\" box is meant to be a light direction filter. Say you're leaving Town Hall heading home to Cronulla, you pop Cronulla in and the alert only bothers you about trains actually going your way. The catch is that a train only really tells you its final stop, so a service that passes through your destination on the way to somewhere else doesn't always say your stop's name. The filter would match nothing. My first version handled that by failing open, so when nothing matched it just alerted you about everything, on the theory that too many alerts beats none. That turned out to be genuinely annoying. On a quiet poll with no Cronulla train in sight it would happily ping me about trains heading the complete opposite way, up to Berowra and out to Emu Plains. The fix was to give the filter a memory. The first time a real matching train shows up it quietly notes that this direction does work here. From then on a poll with no match means go quiet, not alert about everything. It only falls back to the noisy everything-goes behaviour for a destination that has never once matched, which is the genuine can't-tell case. I still reckon failing open was the right instinct. It just shouldn't have been what happens every time. That felt like the end of it. It really wasn't.

\n

Web push from scratch, byte for byte

\n

The web push libraries everyone reaches for are built for Node and lean on APIs a Worker doesn't have. Rather than fight that, I wrote the push layer from scratch on WebCrypto. That means two RFCs. VAPID (RFC 8292), an ES256 signed JWT that proves to Apple's or Google's push service the message really came from my server. Then the payload encryption (RFC 8291), a chain of ECDH key agreement, HKDF derivations and AES-128-GCM. Hand rolling crypto is nerve-racking because the failure mode is silence, the push service just drops anything malformed and tells you nothing. The saving grace is that the encryption RFC ships a complete worked example with every intermediate value spelled out. So I made the encrypt function accept an injectable random seed and wrote a test that reproduces that worked example byte for byte. When it went green I knew the scariest part of the codebase was actually correct rather than just plausible.

\n\n

The plot twist

\n

So I shipped that direction fix and moved on, pretty pleased with myself. A few weeks later I finally sat down to put a number on how often the filter actually matched, which I'd never once measured. I pulled the real departure board for Town Hall on a Monday afternoon, 80 trains across the shipped default window, then checked it against six destinations a normal person would genuinely type in. Here's the twist. A headsign only ever tells you where a train finishes up. It says nothing at all about the twenty odd stations it calls at on the way there. Only 4 of those 80 trains terminate at Parramatta. 20 of them actually stop at Parramatta. So a Parramatta subscriber was being told about 4 trains a window and never hearing a whisper about the other 16. Hurstville was 5 against 15. My tidy little fail-closed fix wasn't filtering trains out at all, it was silently dropping trains those people could have caught. And because going quiet looks exactly the same as there being nothing to say, they'd never have a reason to suspect it.

\n

The other branch was just as broken in the opposite direction. Redfern and Wynyard never match a headsign from Town Hall, not one of the 80, so the filter never got to learn that the direction works there and sat in the noisy everything-goes branch forever, pinging those subscribers 79 times a window. I widened the measurement out to 56 plausible Sydney destinations and depending where you start from only 30 to 45% of them are ever a headsign. Blacktown, Sutherland, Lidcombe, Bankstown, Museum, Wynyard and Redfern are all completely ordinary places to be heading home to and not one of them is a headsign from Town Hall. Both halves of my filter were wrong and they were wrong for the same reason. The signal was wrong. I'd spent two whole rounds carefully tuning what to do when the match failed. The actual bug was the question I was asking.

\n

So the fix was to stop asking whether the destination is the last stop and start asking the only thing that actually matters. Does this train stop where I'm going? TfNSW's live departure board hands back a gtfsTripId on nearly every service and the static GTFS timetable carries the full calling pattern for every trip, so if those two join I get the real list of stations instead of a guess off the front of the train. They don't join directly, which took a whole investigation to establish. That id isn't a real trip id in any feed TfNSW publishes, not even the combined one. It isn't opaque either though. The route part of it reconstructs exactly to a route_id in the complete feed. Route plus origin station plus departure minute then pins the trip right down. 18 out of 18 live departures reconciled on the first proper run. That was a very good afternoon.

\n

None of that fits on a Worker. The complete feed is 293 MB zipped and 1.45 GB extracted against a 128 MB isolate, so the heavy lifting started out on my laptop. A script pulled both feeds down, worked out the onward calling pattern for every trip and boiled the whole network into one small pattern shard per origin station, then published them into Workers KV. The first build came out at 247 stations and 18,090 trips indexed for about 7 MB all up. At alert time the Durable Object reads only its own station's shard, looks each departure up by route and minute and keeps the train if the destination is somewhere in what's left of its stop list. Everything hard gets resolved once, at build time, in code I can write proper tests against. The Worker ends up holding exactly one idea. The stations this service calls at after this one.

\n

The genuinely nasty bit was through-running. Sydney Trains sends a lot of services straight through the City and the static timetable models the approach leg as a trip that terminates at Central, while the live board is already advertising the eventual destination. Read that literally and you get a false negative, which is worse than the thing I was replacing. GTFS has a block_id that links the trips worked by the same physical train. Sure enough the continuation leg leaves Central exactly one second after the approach leg arrives. Stitch the two and a trip that looked like it stopped at Central becomes the real 49 stop run. 1,671 of 1,671 Central-terminating trips on those lines had a block mate waiting for them. Without the stitching Cronulla would have gone from 5 alerts to 16, so it was required rather than a nice to have. I keyed the block on the service id as well as the block id too, because Sydney Trains reuses block ids across weekday and Saturday patterns whose join times happen to line up. That would have stamped a weekday timetable onto a Saturday train. A confident wrong yes, which is the worst kind, because nothing at runtime can catch it.

\n

The rule I care about most in this whole thing is that absence is never a no. Destination missing from the shard, a schema version I don't recognise, the published dates running out, a KV read timing out, every one of those returns don't know and falls back to the old headsign matcher rather than deciding no. A wrong no is silent permanent non-delivery and that's the only failure in here a user can't recover from on their own. The acceptance run I was holding out for finally landed in a real weekday peak window, courtesy of a little scheduled job on my laptop taking the measurement at 4:52pm every weekday because I'm hardly ever at a keyboard at exactly the right minute. Town Hall came back with 80 out of 80 departures covered by the shards. My own Cronulla alert landed on 5 trains for the window, right where the earlier measurements said it should be. So I flipped it on. The stopping-pattern filter has been live for every train alert since early August and the rollback story is as boring as I could make it. Deleting the shard keys out of KV turns the whole thing off in seconds, no deploy needed. Ferries stay on the headsign path for now. Their wharf ids don't map into the ferry GTFS feed at all, which is a whole separate rabbit hole for another week.

\n\n

The plot twist, solved for good

\n

The laptop was never the long-term plan though. It was fine while I was proving the idea, running the build by hand in test runs and eyeballing the numbers before each publish, but I knew the whole time that if I wanted this to be properly automatic the build had to come off my machine and live in the cloud somewhere. A script that only runs when I remember to run it isn't automation.

\n

The shards even gave me a deadline, because each one deliberately carries just eight days of timetable, today plus a week, so that a stale build degrades gracefully instead of serving wrong answers forever. Sure enough the build from the 3rd of August covered up to the 10th, so on the 11th every train alert quietly aged out of its shard and spent the whole day back on the old headsign matcher. Nothing broke, which is exactly how I designed it. Honestly that almost made it worse. Alerts kept arriving, just fewer of them. The canary I'd built to spot stale shards was only writing a line into a log I wasn't reading, so production ran thin all day before I rebuilt the shards by hand that evening. That was the nudge I needed to stop putting the move off.

\n

My first thought for a cloud home was GitHub Actions and I went off it for two reasons. It would need a Cloudflare API token with write access to KV sitting in GitHub's secrets, a brand new credential to mint and then worry about forever. On top of that GitHub quietly switches off scheduled workflows when a repo goes inactive, which for a set-and-forget project is exactly the kind of silent stop I was trying to get rid of. Then I remembered Cloudflare had shipped Containers, where you attach a real Docker container to a Worker for the jobs an isolate can't handle. That was the lightbulb moment. The 1.45 GB extract that pushed this build onto my laptop in the first place stops mattering the moment the build gets a proper machine of its own.

\n

Here's the bit I love about how Containers work. You address the container through a Durable Object. The same one-object-one-address idea that runs every alert in this app also runs the build machine. It only ever exists while it's working. A second cron in the same Worker fires once a day at midday and wakes the container. The container pulls both GTFS feeds down, chews through them with the exact same build code my laptop was running and hands the finished shards back over HTTP. Then comes my favourite part. The Worker writes the shards into KV itself through the binding it already owns, so the container never touches storage and holds no credentials at all. Automating the whole pipeline added zero new secrets anywhere. The first deploy did bounce because Containers need the paid Workers plan, so this project now costs me five dollars a month plus about five cents of actual container time. For a build that runs itself every day, I reckon that's a bargain.

\n

Then the very first end-to-end run failed and honestly I couldn't have asked for a better failure. The build indexed 9,133 trips where the previous day's run had found over 20,000, so one of the assertion gates I'd wired through the pipeline looked at that number and refused to publish. The investigation turned up something I'd never have guessed. The two TfNSW feeds run on completely different clocks. The Sydney Trains operator feed regenerates at about 1am every morning like clockwork. The complete feed only republishes when something changes and I caught it nearly seven hours stale that morning, with one past incident lagging almost 44 hours. The trip ids carry a day counter that ticks up with each generation, so whenever the two feeds sit a generation apart the join between them collapses and half the trips silently fall out of the build.

\n

Two things came out of that. The daily rebuild moved from the small hours to midday, well clear of that 1am regeneration. And the gate itself went from box-ticking to being the hero of the story, because publishing that half-empty bundle would have been far worse than publishing nothing. A trip that's missing from the shard doesn't look missing at match time. It looks like a train that doesn't stop where you're going, which is the silent non-delivery I'd already ranked as the one unforgivable failure in this system. So here's a tip if you're building a pipeline whose output other code will trust. Give the build a gate on its own numbers and let it refuse loudly. Mine caught a genuine upstream problem on its very first run and the old shards simply kept serving until the feeds caught back up to each other.

\n

The last piece was making sure every failure lands in my inbox rather than a log, since a log-only canary had already burned me once. The staleness canary emails me now. A failed rebuild emails me too, naming the gate that tripped and the number it measured. Ops emails are capped at one a day so a bad week can't bury me. Best of all, every one of those emails carries the actual fix inside it, a curl command against a new authenticated admin route that kicks off a rebuild on demand. There's a fun Workers quirk in that route too. A Worker only gets about 30 seconds of background time after it responds, nowhere near enough for this build, but there's no wall-clock limit while the client stays connected. So the route just holds the connection open until the build finishes and then answers with the result.

\n

That admin route is exactly how the first real production rebuild ran. It published 239 station shards, swept up 8 stale ones an older build had left behind and came back in 67 seconds. That number was its own little lesson too, because every estimate along the way had confidently said the build takes about ten minutes. The very last pull request in this whole saga was me going back through the emails and docs to correct that claim to what production actually measured. So the timer on this feature is no longer wound by hand. I reckon that's the real finish line for a side project like this, the moment it keeps working when you're not looking at it.

\n\n

The result

\n

I couldn't be happier with how this one came out! The first build took a couple of days with Claude Code alongside me and it's grown up a fair bit since it went live. The caching that keeps it inside the free tier, the ferry site, the whole stopping-pattern rework and the daily container rebuild that keeps its timetable data fresh all landed after real people started using it, which is by far the best way to find that sort of thing. My phone buzzes two minutes before the train actually leaves, the alert already knows it's running late and one tap on \"I'm on board\" keeps everything quiet until my next travel day. I don't open the app, I don't refresh anything and I've stopped standing on platforms waiting. It's all covered end to end, Vitest across the schedule and crypto logic and Playwright driving the real Worker against a mock TfNSW upstream, so I can keep tweaking it without holding my breath. It's live at sydneytrainalerts.com, so if you're a Sydney commuter you're welcome to set one up of your own. There's a blog on each site too, nine posts apiece, to help people find them.

\n

Two things I'd pass on from this one. If you've built yourself a heuristic and you're onto your second round of tuning what it should do when it misses, stop and go measure how often the thing actually hits. Mine was landing 30 to 45% of the time and I'd never checked it once. Then if you're thinking about doing web push yourself, here's the tip I wish I'd had on day one. Don't test your encryption by firing real notifications and hoping. Make the random inputs injectable and verify your output against the worked example in RFC 8291 first, because the push services silently drop anything malformed and you'll get no clue why. Once that test passes, everything downstream is just plumbing. Happy coding!

" + }, { "slug": "album-tracker-cli", "comingSoon": false, @@ -7,7 +18,7 @@ "description": "A Node.js CLI that scrapes upcoming album releases from Wikipedia, ranks a Top 10 Suggestions block from your Spotify follows and syncs the picks you make to Google Calendar, Outlook or an .ics file as free 9am reminders on release day.", "tech": ["Node.js", "JavaScript", "Cheerio", "OAuth 2.0 / PKCE", "Spotify Web API", "Google Calendar API", "Microsoft Graph", "iCalendar", "node:test"], "period": "July 2026", - "order": 1, + "order": 2, "content": "

Overview

\n

Music has always been one of my main passions and keeping up with new releases is a big part of that. The annoying thing is I kept finding out about albums late. An artist I follow would drop a record and I'd only catch it weeks later when a mate mentioned it or the algorithm finally decided to show me. Wikipedia even keeps a properly maintained list of every album scheduled for the year, so the information is all out there. It just never reaches you at the right moment.

\n

That's when it hit me. I've built calendar tools before. My tennis Grand Slam feed is still quietly doing its job in my calendar right now. Album releases are the exact same shape of problem, known dates that you want to show up on your phone with zero effort, except this time the list is hundreds of albums a month and only a handful are mine. What if the CLI scraped the lot and let my own Spotify follows work out which ones I'd actually care about? So that's what I built! Album Tracker CLI is a Node.js command line tool that scrapes upcoming releases from Wikipedia, lets you pick the ones you want in an interactive terminal checklist and adds each one to your calendar as a 9am reminder on release day. Connect Spotify and it ranks a Top 10 Suggestions block from the artists you follow and listen to most.

\n\"Album\n\n

What I built

\n

The flow is straight forward. You run pnpm start, choose where the releases should go and start ticking albums.

\n\"The\n\n\"The\n

Every release lands as a short reminder at 9am on release day, marked free so it never blocks your actual schedule. Each event carries a Spotify search link so you're one tap from listening the moment it pops up.

\n\n

Keeping it small

\n

The whole thing is plain JavaScript running on Node, about 1,900 lines across ten small modules, with exactly four dependencies. Cheerio for the parsing, prompts for the checklist, open for popping the browser during login and dotenv for config. The tests run on Node's built-in node:test runner so there isn't even a test framework installed. I wanted a tool I could come back to in a year and read top to bottom in one sitting. Keeping the dependency list that short is most of how you get that.

\n\n

Sticking points

\n

Three OAuth providers, one dance

\n

Spotify, Google and Microsoft all speak OAuth, and all slightly differently. The first cut gave each provider its own copy of the login flow, the local callback server, the browser hand-off and the token exchange. The copies drifted exactly like you'd expect. The Google copy was missing its CSRF state check entirely and two of the providers were writing the client secret into the local auth file alongside the tokens. The fix was pulling the whole dance into one shared OAuth module. A single authorizeViaBrowser helper generates and verifies the state parameter itself so no provider can forget it, and the auth file writer strips secrets on every write so the only things that ever hit disk are tokens and non-secret app IDs.

\n\n

Spotify quietly stopped accepting localhost

\n

Partway through, Spotify logins started failing with redirect URI errors that made no sense, because the exact same value had worked before. It turns out Spotify no longer accepts the localhost hostname in redirect URIs at all and requires the loopback IP literal instead. Google and Microsoft accept 127.0.0.1 happily, so every provider now shares the one loopback host and the problem can't come back.

\n\n

Suggestions, not selections

\n

The first version auto-selected every album by an artist you followed, which sounded helpful and felt awful. I follow a lot of artists and not every record they put out is one I want a reminder for, so each run started with me un-ticking things, which is backwards. The redesign flipped it. Spotify data now only ranks and labels, surfacing the Top 10 Suggestions block at the top of the list, and every actual selection is yours. I reckon recommendation belongs in the presentation, not in the selection state.

\n\n

Reminders, not blocks

\n

The first cut created all-day events. After a couple of weeks of living with it my calendar looked booked out on release days, when what I actually wanted was a nudge. So releases became 15 minute events at 9am local time, marked transparent so they never show me as busy, with the times written as floating local values so the reminder fires at 9am wherever the calendar happens to be. The event title, description and Spotify link are all built in one shared function too, because Outlook's duplicate check matches on the event subject and the fastest way to break that is letting two providers drift apart on the title format.

\n\n

The result

\n

I was really happy with the end result. I was able to spin up this simple JavaScript CLI using AI agents in only an hour and it's turned a real world problem into a once a month two minute task. What more could I want! I run it, tick the albums I'm keen on and release day mornings sort themselves out. A quiet 9am reminder, one tap into Spotify and I'm listening to a record I would've otherwise found out about three weeks late.

\n

The full source is on GitHub if you want to see how it all fits together. If you're building anything that does OAuth from a CLI, my one bit of advice is to set up a shared auth layer before you add your second provider, not after. The login dance looks provider-specific but it's 90% identical. The 10% that differs is exactly where security bugs like a missing state check hide when every provider owns its own copy. Happy coding!

" }, { @@ -18,7 +29,7 @@ "description": "Turning HTML into a good-looking PDF sounds trivial until you actually try it. So I built a full Next.js SaaS on Vercel that does it properly, with a free drag-and-drop web tool and a paid developer API for generating PDFs from your own app.", "tech": ["Next.js", "React", "Puppeteer", "Stripe", "Resend", "MongoDB", "JWT", "Vercel", "Redis", "Material UI"], "period": "Feb 2025 — Sep 2025", - "order": 5, + "order": 6, "content": "

Overview

\n

Turning HTML into a good-looking PDF is one of those jobs that sounds trivial right up until you actually try it. I wanted to take that little feature and build a proper full-stack SaaS around it, the whole thing end to end, from a free tool anyone can use to a paid API a developer can call from their own app. So that's what I did! HTML to PDF Converter is a Next.js app running on Vercel that turns HTML files into print-ready PDFs. It comes in two halves. A free web interface for quick one-off conversions and a paid developer API for generating PDFs programmatically.

\n\"Upload\n\n

What I built

\n

The web tool lets you drag and drop up to 10 HTML files, set the page format, orientation, margins, scale and output mode, then download the result as a single PDF, one merged PDF or a ZIP of separate PDFs.

\n

The developer API is the other half. It adds accounts, JWT API keys, usage tracking, Stripe subscriptions and rate limiting. You generate a key from the dashboard and call a REST endpoint to convert HTML straight from your own application.

\n\"Developer\n\n

Technical architecture

\n

Under the hood it's Next.js App Router, React, TypeScript, MongoDB, NextAuth, Stripe, Upstash Redis, Vercel Blob and Puppeteer. The one bit worth calling out is that it runs Puppeteer two different ways. In development it uses the full Puppeteer. In production it swaps to puppeteer-core with @sparticuz/chromium so Chromium can actually run inside Vercel's serverless environment.

\n

The conversion flow itself is nice and simple:

\n
    \n
  1. You upload one or more HTML files.
  2. \n
  3. The API checks the file type, the file count and the file size.
  4. \n
  5. Puppeteer renders each HTML document to PDF using the options you picked.
  6. \n
  7. Multiple PDFs get either merged with pdf-lib or archived with archiver.
  8. \n
  9. The finished file is stored behind a session-based download route.
  10. \n
  11. The UI streams progress back to you the whole time using server-sent events.
  12. \n
\n\n

Sticking points

\n

Getting Chromium to run on serverless

\n

The hardest part by a mile was making the PDF rendering reliable in production. Running Puppeteer locally is easy. Getting Chromium to run in a serverless environment is a different story, because you need a smaller runtime binary and a specific set of launch flags to fit inside Vercel's constraints. So I split the renderer in two. Local development uses Puppeteer directly. Production uses puppeteer-core with @sparticuz/chromium.

\n\n

Keeping people in the loop on long conversions

\n

A batch conversion can take a few seconds, especially when someone uploads a bunch of chunky HTML files. Rather than leaving the screen sitting there doing nothing, the web route sends back a streaming response and reports each step as it happens, whether that's rendering, merging, archiving or getting the download ready.

\n\n

Handling the three output cases cleanly

\n

The app had to cope with three different outputs. A normal single PDF, one merged PDF when there are multiple inputs or a ZIP with separate PDFs inside. I pulled the PDF creation logic out of the route handler so the rendering, merging, zipping and download metadata could all be tested on their own.

\n\n

Keeping the rendering costs under control

\n

Browser rendering isn't cheap, so both halves of the app sit behind Upstash Redis rate limits. The public web tool is limited by IP. The API is limited per user and tied to their subscription state, which keeps the whole thing safe from someone hammering it.

\n\n

The result

\n

What I ended up with is a proper working HTML-to-PDF service. A free public converter, a paid API, account management, API key controls, Stripe billing and production-ready serverless rendering, all in the one app. It took a feature that sounds trivial and turned it into a full SaaS workflow. Upload, render, download, sign in, bill and keep the system safe from abuse.

\n

The developer docs are over at htmltopdfconverter.com.au/docs. If you're thinking of building something that renders PDFs on serverless yourself, my one bit of advice is to sort out the production Chromium setup first before you build anything on top of it. It's the part most likely to bite you, so get it solid early. Happy coding!

\n\"API" }, { @@ -29,7 +40,7 @@ "description": "A React Native strength-logging app I'd had in mind for over 10 years, finally built, tested and shipped to the App Store. Half of it was generated autonomously with Claude Code using the Ralph technique, the other half hand-crafted by me.", "tech": ["Expo", "React Native", "TypeScript", "Supabase", "NativeWind", "Tailwind", "Reanimated", "React Navigation", "Biome", "Jest"], "period": "April 2026 — June 2026", - "order": 4, + "order": 5, "content": "

Gym Log

\n

Gym Log is a React Native app for logging strength workouts. Programs, sessions, personal bests, rest timers and progress charts, all running on Expo and Supabase. It's a side project I'd had knocking around in my head for well over 10 years. I finally sat down and built it.

\n\"Gym\n

It started as a personal itch. Every gym tracking app I tried either buried the actual logging of a set under three taps or wanted a subscription for the features I really wanted. So I built my own. Something straight forward that treats a workout like what it actually is. A list of exercises, each with a list of sets, each with its reps and weight. I kept it to just that until the basics felt right.

\n

Gym Log is now live on the App Store, with more over at gym-log-app.com.

\n\n

Background: Claude Code and the Ralph technique

\n

Here's the fun part. The first eighteen commits of this project, so everything from the Expo scaffold through to a working auth flow, the CRUD services, the navigation and all five core screens, weren't written by me at the keyboard. They were generated by running Claude Code inside an autonomous loop, using a workflow Geoffrey Huntley calls the Ralph technique.

\n

Ralph, at its simplest, is a Bash loop. while :; do cat PROMPT.md | claude-code; done. You write a single prompt that points the model at a backlog, in my case a numbered list of user stories (US-001, US-002 and so on), you give it the rules of the game (one story per iteration, run the tests, commit on green, mark the story done) and then you let it run. The model picks the next unchecked story, builds it, checks its own work and commits. Then the loop fires again. You go to sleep and it keeps shipping.

\n

What I found is that this works genuinely well for the boring foundational scaffold of a project, the part where most of the decisions are mechanical and the cost of a wrong call is low. It gave me a clean, typed, tested foundation in a single overnight run that would've taken me a long weekend by hand. The tidy one-commit-per-story history it left behind made the codebase really easy to move around in afterwards. Where Ralph was less useful was the second half of the project. The redesign, the placeholder UX, the dark mode retrofit. Those needed taste, actually living with the app and the kind of \"no, that feels wrong, undo it\" judgement that just doesn't fit inside a loop. So the project is genuinely a hybrid. An autonomous agent for the bones and me by hand for everything that gives it its feel. I reckon that split is the honest answer to \"how much of this did the AI write\".

\n\n

What I built

\n

The app is built around four screens that match how I actually use it at the gym.

\n\"Programs\n\n\"Session\n\"Workout\n\"Progress\n

On top of the core loop, the app auto-detects personal bests (I renamed these from \"PR\" to \"Personal Best\" partway through after testing it on myself, because \"PR\" read as ambiguous), exports a session as a shareable image card and can fire a daily streak reminder at a time you choose to nudge you back to the gym. Auth and storage are handled by Supabase, with row-level security so each user only ever sees their own data.

\n\"Account\n\"How\n\n

Technical architecture

\n

Stack: Expo SDK 55, React Native 0.83, React 19 and TypeScript on the front. Supabase on the back for Postgres, Auth and row-level security. NativeWind v4 with Tailwind v3 for styling, React Navigation v7 for routing, Reanimated v4 for animation and Gifted Charts for the graphs. Biome handles lint and formatting. The tests run on Jest with React Native Testing Library.

\n

The data model is four tables. profiles (which extends Supabase's auth.users), workouts, workout_exercises and workout_sets, each one protected by row-level security so a user can only ever read or write their own rows. The first cut of the schema was actually only two tables. workouts carried its exercises and sets inline as a jsonb blob, on the assumption that every read was really \"fetch the whole workout\" and a single round trip would be fastest. That held up fine until the Progress screen landed. Charting weight progression for one exercise across months of sessions meant pulling every workout, hydrating the JSON in JS and then filtering, which got noticeably laggy as my own history grew. Migrating to normalised workout_exercises and workout_sets tables (with indexes on workout_id, exercise_id and the exercise name) let those queries push the filtering and joining down into Postgres where it belongs. The per-exercise history sheet went from a visible pause to instant. Well worth the migration. Would have been worth doing up front if I'm honest.

\n

The state is layered. A SupabaseClient singleton at the bottom, then thin service modules (authService, workoutService) that own all the I/O, then React Context providers (AuthContext, WorkoutContext, ThemeContext) that hand typed hooks to the screens. No Redux, no Zustand. Context was plenty because the data fan-out is shallow and the writes are all user-initiated.

\n

Auth tokens persist through expo-secure-store (it was AsyncStorage originally, swapped out early when I realised the JWTs were sitting there in plaintext). The theme preference persists locally too, with the write deferred off the interaction frame after profiling showed the toggle felt a bit sticky on older devices.

\n\n

Sticking points

\n

The \"Programs vs Workouts\" rename

\n

The first version of the app had a single concept, a Workout, that you'd either create blank or duplicate. After living with it for a few weeks I realised what I actually wanted was a clean split between the template (the program I follow week to week) and the instance (today's session). The redesign touched nearly every screen. A two-zone Workouts list, a new \"Start\" action that turns a template into a session, a source_template_id foreign key on instantiated workouts so I could pull last session's placeholders forward and a soft-delete on empty sessions so abandoned starts didn't clutter up the history. Worth every bit of it, because the app now matches the way I actually think about my training.

\n

Placeholder UX

\n

Pre-filling the set inputs with last session's values sounds simple until you ask the real question. Are those values the user's data or just a suggestion? If they're data, then saving an untouched session double-counts last week's lift. If they're a suggestion, the user needs a clear signal that's what they are. I landed on rendering them as muted placeholder text rather than real input values, tracking edited-versus-untouched state for each set and showing a banner on instantiated sessions until the first edit lands. Empty sessions get soft-deleted on save so the history stays honest.

\n

Dark mode without a rewrite

\n

The app was built dark-first with hardcoded Tailwind colour classes all over the place. Retrofitting a theme system meant either a giant search-and-replace job or something smarter. I went with CSS custom properties. I defined the colour palette as --color-* variables in global.css, redirected every Tailwind token through var(--color-*) in the config and let NativeWind's CSS interop carry the values into the native views. A single ThemeProvider swaps the variable values at the root and every styled component re-renders with the new palette. No per-component changes needed beyond a handful of imperative colour usages (the charts, the rest timer bar) that I had to migrate by hand.

\n

Email confirmation and the App Store round-trip

\n

Signup runs through Supabase email confirmation. The confirmation link deep-links straight back into the app (gymlog://auth/callback) instead of dead-ending in a browser. Profiles get created by a Postgres trigger on auth.users rather than a client-side write, so a freshly confirmed account always lands with its profile row already in place. No race between \"account exists\" and \"profile exists\". Getting that flow watertight took a couple of App Store resubmissions. The deep-link handling and the confirm-then-return path are exactly the kind of edge cases that only really show up once a real reviewer and real users are in the mix.

\n

Shipping discipline

\n

The commit log honestly reads like two different projects stuck together. A methodical first pass (US-001 through US-018, then US-101 through US-108, every commit a single user story) and a much messier \"make it real\" phase (the redesign, lint cleanup, the DB latency migration, all the polish). The structured phase got the bones right. The messy phase is where the app actually turned into something I'd happily recommend to a mate. I'd run it the same way again.

\n\n

The result

\n

What started out as \"I'll knock up a tracker in a weekend\" turned into a proper fully featured fitness app that I now use four times a week. It's live on the App Store. Building it user story by user story up front made all the later, scrappier work (the redesign, the dark mode retrofit, the placeholder UX) far cheaper than it would've been otherwise, because the foundations never had to move. If you're keen to give the Ralph technique a go yourself, my advice is to point it at the boring scaffold and keep the taste calls for yourself. That combination is where the real magic is. Happy coding!

" }, { @@ -53,7 +64,7 @@ "Claude Code" ], "period": "June 2026", - "order": 3, + "order": 4, "content": "

Overview

\n

Back in 2015 I was off on my first Europe adventure. During that first summer I was lucky enough to visit one of the best festivals in the world, in my favourite city in the world, Dekmantel Amsterdam. It was 3 days of awesomeness that I'll never forget. The people, the music and the experience were all just 10/10, and I even got lucky with the weather and had nice sunny days. Music has always been one of my main passions, and since then I've always remained on their mailing list. Each year when the lineup drops and the timetable is announced I'm always a bit jealous and wish I could be there. This year when I saw the timetable email, I had an idea. What if I built my own MCP to turn these images into a proper festival SoundCloud playlist?

\n

So that's what I did! I'm deep in AI coding techniques and methods and use them daily at work, and I'm always watching YouTube videos from the leading voices on the best Claude Code methods and AI architecting approaches. So having that in mind, and my desire to learn more about MCPs, it was all I needed to dive into it. What I ended up with is a public 105 track playlist with one best mix per artist across the whole Dekmantel 2026 lineup, and honestly it's been such a good way to get into this year's names. Underneath it is a Model Context Protocol (MCP) server running on Cloudflare's edge that an assistant like Claude can drive to search SoundCloud, sort out the artists, rank their mixes and build the playlist for me.

\n\n

What is MCP, and why build one?

\n

If you haven't come across it before, Model Context Protocol is an open standard for handing an AI assistant a set of tools it can use. The easiest way to picture it is a typed menu of actions the model can read and call directly. Instead of writing a one off script that only ever does one job, I wrote a server that hands SoundCloud over to the model as a toolbox. Things like search_tracks, resolve_artist, find_mixes, best_mix_per_artist and create_playlist. Once it's connected the model can work out the plan and run the whole job itself, and I get to reuse the same toolbox for anything else I want to do on my account down the track.

\n

It ended up being 27 tools all up, grouped into three lots. The discovery tools for searching and resolving tracks, users and playlists. The DJ mix smarts, which is the artist resolution and mix ranking logic that makes the festival idea actually work. And the engagement tools for the writes, so liking, reposting, following, commenting and the full playlist CRUD.

\n\n

How it works

\n

The server itself is a Cloudflare Worker, and if you peel it back it's really just three layers wrapped around that list of tools.

\n\n

The bit that trips people up is that there are two separate OAuth flows going on at once. First the AI client logs in against my server, so in that handshake my server is the one minting the token the assistant carries around. Then my server logs in against SoundCloud, so now my server is the client. Connecting the tool fires both of these back to back, and the user's SoundCloud tokens get stored encrypted in Workers KV and refreshed whenever they're needed.

\n\n

The actual hard part: turning a flyer into the right artists

\n

A festival lineup is a nightmare for a computer to read. The bill doesn't say \"Shed\", it says \"Shed presents Rave Echoes\". It says \"Ben UFO & Call Super & Objekt & Pariah\", and \"DJ Sprinkles' Deeperama\", and \"Sass (Moxie & Peach & Saoirse & Shanti Celeste)\". Before you can go and find anyone's best mix you first have to work out what each of these billings even means.

\n

So the first pass is a big dedup with some clear rules. Split the back to back and collective billings out into individual artists, treat \"X presents Y\" as just the headliner X, and drop anything that isn't a DJ at all like the live bands, the panel talks and the sound systems. That got the roughly 150 raw billings down to 164 unique artists.

\n

Then every one of those names has to become an actual SoundCloud profile. The resolver scores each candidate on a handful of signals like a permalink guess, an exact name match and how dominant their follower count is, with genre only counting as a bonus when it's actually there, because real DJ mixes almost never bother tagging a genre. The one rule I really cared about was that it should never silently guess wrong. It only auto picks when it's genuinely confident, and if it isn't it hands back a ranked list of candidates and an honest reason why (unresolved, low_confidence or no_mix) and leaves the final call to me. Out of the 164 artists, 105 came back with a confident best mix. The other 59 came back with honest reasons, and a few of the big names like Jeff Mills and Ricardo Villalobos genuinely just don't post long mixes on SoundCloud.

\n\n

Sticking Points

\n

A successful write that reported itself as a failure

\n

Once it was deployed I tested the repost tool against my real account and it came back with Unexpected end of JSON input, which of course looks like it failed. Except it hadn't. The repost had actually worked and was sitting right there on my profile. It turns out SoundCloud answers some of its write requests with a completely empty 204 No Content body, and my HTTP layer was blindly calling response.json() on every 2xx response, which throws the second there's no body to parse. So every successful unlike, unfollow and unrepost was getting handed back to the user as an error. The fix was tiny, just check for the empty 204 case before trying to parse it, but I only found it because I actually hit the live endpoint rather than trusting a green test suite. All my tests were mocking full JSON bodies, so the bug was completely invisible to them.

\n\n

The rate limit cascade that lied about who exists

\n

Trying to resolve all 150 odd artists in big batches kept tripping SoundCloud's rate limiter, and the way it failed had me going for a while. Each artist resolution makes a few API calls, so when a big burst hit the limit those failed lookups were getting reported back as unresolved, which of course reads as \"this artist doesn't exist\". Ben UFO, one of the most famous DJs on the whole lineup, came back \"unresolved\" in a batch of 23 and then resolved instantly when I ran him in a batch of 3. It was a burst problem dressed up to look like a data problem. The fix came down to three things. Smaller batches, a cap on how deep the per artist track pagination is allowed to go, and an abort flag so that the moment any worker hits a 429 the rest of the batch stops instead of piling on and making it worse. When I later ran the whole branch through an adversarial code review, the reviewer landed on that exact same unbounded pagination as the root cause completely on its own, which was a nice bit of confirmation I'd fixed the right thing.

\n\n

An OAuth race I designed away

\n

The SoundCloud login uses a PKCE flow, and the first version of it stashed the PKCE verifier in Cloudflare KV keyed by the OAuth state parameter. The catch is that KV is eventually consistent, so when SoundCloud redirected straight back to me (which it does, because my account was already authorised so there's no consent screen to slow things down) the callback would sometimes try to read the verifier before the write had even landed. That gave me an intermittent \"expired or unknown state\" error that would magically fix itself on a retry. Rather than just papering over it I made the whole flow stateless. I encrypt the verifier and the original request into the state parameter itself using AES-GCM, then decrypt it again on the way back. No KV round trip, no race, and I baked a 600 second freshness check into the encrypted payload to keep the same expiry behaviour I had before.

\n\n

How it was built

\n

The whole thing was built test first and ended up with 124 tests covering the ranking logic, the resolver's confidence contract, the OAuth handlers and all the fiddly HTTP edge cases. Then I put it through a full adversarial code review before merging, which is exactly where the rate limit and empty body issues got pinned down and fixed properly under TDD. It was also a genuinely agentic build, which was half the fun. I worked through the whole thing with Claude Code, and once the server was live the agent didn't just write the code, it actually drove the tools against live SoundCloud to build the playlist. It pulled the lineup out of the festival flyer images, ran the dedup, called the resolver across all 164 names, helped me recover the tricky ambiguous headliners and then created the final playlist. Building the system and running it turned into the same loop, which was a really cool thing to watch.

\n\n

Result

\n

What I've ended up with is a live MCP server running on Cloudflare's edge and, better still, a real artifact to show for it. A 105 track Dekmantel 2026 playlist with one best mix per artist, all sequenced in festival day order. It's exactly the thing I wished was waiting in my inbox when that timetable first dropped, and it's genuinely how I'm getting to know this year's lineup now.

\n

I'd also like to say that to do this it required upgrading to SoundCloud's Artist Pro account so I had access to their API. So for those other engineers thinking of building something the same, I'd advise doing this before you jump in! Happy coding!

" }, { @@ -64,7 +75,7 @@ "description": "How a missed Grand Slam on the TV turned into a self-updating iCalendar feed built on Cloudflare Workers, Workers KV and Hono, with the date-source and timezone gotchas that make it reliable.", "tech": ["TypeScript", "Cloudflare Workers", "Workers KV", "Hono", "Cron Triggers", "iCalendar"], "period": "May 2026", - "order": 2, + "order": 3, "content": "

I wasn't planning to build anything that night. I was on the couch catching up with my mum when a Roland-Garros ad came on the TV. The French Open had already started and neither of us had a clue! Two people who genuinely love their tennis. We'd missed the opening days of a Grand Slam just because nobody told us it was on. Gutted.

\"The

That's when it hit me. I've built Icals before. This would be the perfect tool to ensure we never miss a slam event again! Four tournaments a year, always on my phone, always up to date, no app to install and no newsletter to skim. I work with Cloudflare a lot and I love it, so I knew almost straight away that Workers KV and Hono would let me do exactly that. Something light and fast that I could genuinely set and forget.

The shape of set and forget

The whole thing rests on splitting the system into two paths that never touch each other. A calendar client polls a subscription URL a lot and unpredictably, so the path that answers those requests has to be cheap and boring. A separate path does the slow, fragile work of going out to the internet to figure out the actual dates. It runs on its own schedule, not on the visitor's.

In Cloudflare terms the serve path is the Worker's fetch handler. A subscriber hits /slams.ics and it returns a pre-rendered calendar string straight from Workers KV. No parsing, no outbound calls, just a key read at the edge. The refresh path is the Worker's scheduled handler, driven by a weekly Cron Trigger. It fetches the dates, checks them, renders the calendar once and writes the result into KV. Every visitor after that just reads the cached output. The expensive, breakable work happens a handful of times a month. The cheap read happens however often clients ask.

Why Cloudflare

This is exactly the kind of problem Cloudflare's primitives are made for. It's a big part of why I reach for them so often. Workers run at the edge with no server to keep alive and no cold start to wait on. Workers KV is a globally replicated key-value store that's tuned for this exact shape, read heavy and write rarely. Subscribers read constantly, but the data only changes a few times a year. Hono is a tiny, web-standard router that gives the Worker clean endpoints without dragging in a whole framework. The whole thing fits comfortably in the free tier and ships as a single deployment.

I stuck to web-standard APIs the whole way through, fetch for the network and crypto.subtle for hashing. That meant no Node compatibility flags and a smaller, faster Worker. Fewer moving parts is the entire point of something you don't want to maintain.

The data source rabbit hole

The hard part of any set-and-forget system is never the happy path. It's what happens when the source of truth quietly changes on you. My first instinct was Wikidata, which exposes structured start and end dates through a query endpoint. It turned out to be a dead end. The tournament edition records only carry the year, with no actual start or end dates, even for tournaments that had already finished. Structured, queryable and missing the one field I actually needed!

The source that worked was the English Wikipedia infobox, read through the MediaWiki API rather than scraped from raw HTML. Those articles are heavily watched, the dates get published well in advance and the date field is structured enough to parse with confidence. There are a few quirks. The US Open article needs a disambiguator and leaves the year off its date line. Next year's articles don't exist until the dates are announced. But each of those is a known case I could handle, not a nasty surprise.

Refusing to serve bad data

Unattended scraping works right up until the source changes its markup. Then it happily serves broken dates with nobody watching. The robustness here doesn't come from the parse being clever. It comes from the harness around it. Every parsed result gets validated hard. Each event has to have a sane date range, a duration in the right ballpark for a Grand Slam and a start month in the expected window for that specific tournament. That month check is the strongest guard against silently parsing the wrong field.

If a value fails, it's never allowed to overwrite good data. The refresh merges per event. For each tournament it prefers the fresh value from Wikipedia, falls back to the last-known-good value held in KV and falls back again to a small seed dataset bundled into the Worker. That seed means the feed is never empty, even on the very first request before any refresh has run. A genuinely broken source degrades gracefully instead of corrupting the calendar. The system heals itself the moment the source recovers. A health endpoint and an optional webhook alert turn a silent break into one I can actually see.

The calendar details that quietly break feeds

iCalendar is unforgiving in small ways. Lines have to be joined with CRLF endings or clients just refuse to import the file. Each event needs a stable identifier so a changed date updates the existing entry instead of creating a duplicate, plus a sequence number that only ticks up when the details actually change. I computed a content hash per event to decide when that bump is warranted.

The detail I cared about most, given my mum and I are both parked in front of Australian TV, was timezones. A Grand Slam is an all-day, multi-day block, so each event uses a date-only value rather than a timestamp. By the spec a date-only value has no timezone at all. It represents the same calendar day everywhere. The Australian Open lands on the 18th of January whether you're in Sydney, London or New York. Reaching for a midnight-UTC timestamp instead is the classic mistake that slides all-day events onto the wrong day in eastern timezones. Using the right value type avoids the problem completely.

The result

I was stoked with the end result! It was short and sweet. A simple and easy to read TypeScript app that did the job no fuss. It's live now at grandslamcalendar.com where you subscribe once and the four Grand Slams turn up on your calendar on the right days. A weekly cron keeps them current, picks up next year's dates the moment Wikipedia publishes them and falls back safely if anything upstream breaks. No app, no account, nothing to maintain. Just four tournaments that quietly show up so neither my mum nor I miss the start of another one.

\"The

The full source is on GitHub if you want to see how the pieces fit together. If you're thinking of building something similar, my one bit of advice would be to lean on your data validation harder than your parser. The parse will break eventually. The harness around it is what keeps the feed trustworthy when it does. Happy coding!

" } ]