diff --git a/public/projects/album-tracker-cli/interactive-selection.webp b/public/projects/album-tracker-cli/interactive-selection.webp new file mode 100644 index 0000000..75d17f7 Binary files /dev/null and b/public/projects/album-tracker-cli/interactive-selection.webp differ diff --git a/public/projects/album-tracker-cli/startup-destination.webp b/public/projects/album-tracker-cli/startup-destination.webp new file mode 100644 index 0000000..2ddb8b7 Binary files /dev/null and b/public/projects/album-tracker-cli/startup-destination.webp differ diff --git a/public/projects/album-tracker-cli/top-10-suggestions.webp b/public/projects/album-tracker-cli/top-10-suggestions.webp new file mode 100644 index 0000000..fd19198 Binary files /dev/null and b/public/projects/album-tracker-cli/top-10-suggestions.webp differ diff --git a/src/data/projects.json b/src/data/projects.json index 2031a4e..fb0dde9 100644 --- a/src/data/projects.json +++ b/src/data/projects.json @@ -1,4 +1,15 @@ [ + { + "slug": "album-tracker-cli", + "comingSoon": false, + "title": "Album Tracker CLI", + "company": "", + "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, + "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 stoked with how it came out! It's short and sweet. A simple CLI that does the job no fuss. Once a month I run it, tick the albums I'm keen on and then 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!

" + }, { "slug": "htmltopdfconverter", "comingSoon": false, @@ -7,7 +18,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": 4, + "order": 5, "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" }, { @@ -18,7 +29,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": 3, + "order": 4, "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!

" }, { @@ -42,7 +53,7 @@ "Claude Code" ], "period": "June 2026", - "order": 2, + "order": 3, "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!

" }, { @@ -53,7 +64,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": 1, + "order": 2, "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. You subscribe to one URL 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 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!

" } ]