<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Alistair</title>
    <description>The latest articles on DEV Community by Alistair (@alistairjcbrown).</description>
    <link>https://dev.to/alistairjcbrown</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3756419%2F8b8f7758-c6f0-4fd7-8547-14c43909cd4e.png</url>
      <title>DEV Community: Alistair</title>
      <link>https://dev.to/alistairjcbrown</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alistairjcbrown"/>
    <language>en</language>
    <item>
      <title>Trying Jev: What Happens When the Model Only Picks</title>
      <dc:creator>Alistair</dc:creator>
      <pubDate>Mon, 21 Sep 2026 09:20:00 +0000</pubDate>
      <link>https://dev.to/alistairjcbrown/trying-jev-what-happens-when-the-model-only-picks-2ip5</link>
      <guid>https://dev.to/alistairjcbrown/trying-jev-what-happens-when-the-model-only-picks-2ip5</guid>
      <description>&lt;p&gt;A listing arrives from the &lt;a href="https://clusterflick.com/venues/the-cinema-museum/" rel="noopener noreferrer"&gt;Cinema Museum&lt;/a&gt; in Kennington. It's the quarterly meeting of the UK Buster Keaton Society, who are also known as The Blinking Buzzards, it runs from 4pm to 7pm, and members will be requesting beloved gems to watch together.&lt;/p&gt;

&lt;p&gt;So what is it? It's a club meeting, so is it an &lt;code&gt;event&lt;/code&gt;? It's an appreciation society discussing a comedian, so is it a &lt;code&gt;talk&lt;/code&gt;? It's a programme of silent shorts, so is it a &lt;code&gt;shorts&lt;/code&gt; programme? When I reviewed those listings myself, I marked one as &lt;code&gt;multiple-movies&lt;/code&gt; and gave up on the next, recording it as having no single right answer. Even for a human this is a hard problem to solve.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/clusterflick" rel="noopener noreferrer"&gt;Clusterflick&lt;/a&gt; pulls listings from 400+ venues across London, and most of them match a film in &lt;a href="https://www.themoviedb.org/" rel="noopener noreferrer"&gt;The Movie DB&lt;/a&gt; so there's nothing to decide; they have to be movies because they matched a movie. The leftovers need sorting into one of ten categories (&lt;code&gt;movie&lt;/code&gt;, &lt;code&gt;tv&lt;/code&gt;, &lt;code&gt;quiz&lt;/code&gt;, &lt;code&gt;comedy&lt;/code&gt;, &lt;code&gt;music&lt;/code&gt;, &lt;code&gt;talk&lt;/code&gt;, &lt;code&gt;workshop&lt;/code&gt;, &lt;code&gt;shorts&lt;/code&gt;, &lt;code&gt;multiple-movies&lt;/code&gt;, &lt;code&gt;event&lt;/code&gt;), which is &lt;a href="https://dev.to/alistairjcbrown/getting-the-data-model-right-movie-showings-performances-25pm#what-changed"&gt;the enum I settled on a while back&lt;/a&gt; when it became obvious that modern independent cinemas don't just show movies. Somewhere between 450 and 600 listings go through that decision every day.&lt;/p&gt;

&lt;p&gt;For the last year the job has belonged to a language model: title, runtime and description go in wrapped in a prompt, and JSON comes back with a category, a one-line reason and a confidence score.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"movie"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"This is a single feature film screening."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Star Wars: Episode VI Return of the Jedi (1983)"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;💡 The title on TMDB is "&lt;a href="https://www.themoviedb.org/movie/1892-return-of-the-jedi" rel="noopener noreferrer"&gt;Return of the Jedi&lt;/a&gt;", which is why this longer title didn't match and we needed categorisation. &lt;a href="https://dev.to/alistairjcbrown/cleaning-cinema-titles-before-you-can-even-search-1463"&gt;Just another fun task for the normaliser to deal with&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Then I came across &lt;a href="https://docs.typesafe.ai/models.md" rel="noopener noreferrer"&gt;Jev&lt;/a&gt;, which isn't a language model. You hand it structured state and a set of typed questions, and it returns a probability distribution over the possible answers, with no text generation anywhere in the loop. Given that my problem is picking one of ten options and I throw the prose away unread, it seemed worth an evening to find out whether it could do better than a prompt I'd been hardening for a year.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed when I moved to Jev
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The reason field is no longer needed.&lt;/strong&gt; &lt;a href="https://dev.to/alistairjcbrown/three-things-i-learned-using-llms-in-a-data-pipeline-51c3"&gt;I've written before&lt;/a&gt; about asking the model to explain itself, which made it noticeably more cautious: the difference between blurting out an answer and having to show your working. That improvement was real and it still holds for the LLM path. It has nothing to attach to here though, because Jev doesn't produce prose in the first place, so there's no explanation to demand and no fluent wrong answer for it to talk itself out of.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The things it used to reason out loud about can be asked directly.&lt;/strong&gt; On its way to a category, the old prompt is really working out a handful of underlying facts: is there a feature-length film here, is there more than one, is this television, is a film actually screened rather than just discussed. Jev lets me ask those as four separate yes/no questions alongside the category question, and they come back in the same round trip at no extra cost, because it evaluates every question in a request in parallel. They also land on exactly what it's documented as weakest at, which is counting and runtime arithmetic, so asking each one narrowly is the recommended way to handle those.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Category descriptions became structured objects.&lt;/strong&gt; Several of my categories are defined mostly by what they exclude, since a concert film is a &lt;code&gt;movie&lt;/code&gt; rather than &lt;code&gt;music&lt;/code&gt;, a screening of a comedy is a &lt;code&gt;movie&lt;/code&gt; rather than &lt;code&gt;comedy&lt;/code&gt;, and a film with a Q&amp;amp;A afterwards is still a &lt;code&gt;movie&lt;/code&gt; rather than a &lt;code&gt;talk&lt;/code&gt;. The docs are blunt about why that matters, because Jev "answers the question you wrote, not the one you meant", with scoping words and negations read at face value. A language model will usually infer what I was driving at from a loosely worded description, and Jev won't, so a "not a comedy film screening" clause buried in a sentence does less work than the same exclusion given a field of its own. Each option now carries an explicit &lt;code&gt;excludes&lt;/code&gt; list, and the advice is to reach for that only once two options are similar enough that the model keeps confusing them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The arithmetic moved into my code.&lt;/strong&gt; Jev is documented as not being a calculator, and it does better on semantic language than on numeric formats. So instead of handing over &lt;code&gt;Duration: 73 minutes&lt;/code&gt; and hoping, I do the comparison myself and pass the result as a fact. It's phrased deliberately without naming a category, so it states the relationship and leaves the conclusion alone.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The whole programme runs 73 minutes, shorter than the 80
minutes a single feature film usually runs.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Measuring it properly
&lt;/h2&gt;

&lt;p&gt;I hand-labelled listings until I had 142 of them, including two held-out sets that hadn't been used to tune anything, then ran both categorisers across a full release and adjudicated by hand every case where they differed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where they disagreed, Jev won comfortably.&lt;/strong&gt; Out of 434 listings across 111 venues, the two gave different answers 102 times. Jev was right on 73 of those, the old path on 18, and 11 were genuinely ambiguous.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the hand-labelled set, Jev got 128 of 142 right&lt;/strong&gt;, or 90%. Adjusting for how often each confidence level actually turns up across a release, that works out at roughly &lt;strong&gt;93% for Jev against 82%&lt;/strong&gt; for the LLM path. It also runs at a 277ms median and costs about 2.4x less.&lt;/p&gt;

&lt;p&gt;I kept the hand-labelled listings. Every row carries its own copy of the title, runtime and description, and records the answer I judged correct rather than what either model said. When this is iterated on in the future, it can be used to score whatever I try next.&lt;/p&gt;

&lt;h2&gt;
  
  
  The threshold I didn't carry over
&lt;/h2&gt;

&lt;p&gt;The old categoriser gated on self-reported confidence: the model returned a 0-9 score and anything at 7 or below got thrown away and filed as &lt;code&gt;event&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This came out of a real problem I'd hit and &lt;a href="https://dev.to/alistairjcbrown/three-things-i-learned-using-llms-in-a-data-pipeline-51c3#asking-for-a-reason-made-the-model-more-honest"&gt;written up before&lt;/a&gt;, which was too many confident wrong answers, where the model picks something and reports high confidence even when it's clearly a stretch. If the score is unreliable at the top, leaning on it at the bottom is a reasonable thing to try.&lt;/p&gt;

&lt;p&gt;So the obvious move was to do the same with Jev, whose confidence is a calibrated probability rather than a number the model picks for itself. Measuring it first is the only reason I didn't:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;gate&lt;/th&gt;
&lt;th&gt;correct&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;no gate&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;128 / 142&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.60&lt;/td&gt;
&lt;td&gt;117 / 142&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.85&lt;/td&gt;
&lt;td&gt;85 / 142&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The flaw is in what happens to a discarded answer. Throwing one away doesn't leave the listing unclassified, because it still has to go on the website under something, and that something was &lt;code&gt;event&lt;/code&gt;. So a low-confidence guess of &lt;code&gt;shorts&lt;/code&gt; gets replaced by a confident published claim that this is not a film at all, and that claim is wrong more often than the guess it replaced.&lt;/p&gt;

&lt;p&gt;That measures gating Jev, not the old LLM threshold, which I never measured and have now retired without ever finding out what it cost. There is a hint in the prompt though, which had quietly grown the lines &lt;em&gt;"Do not let combined runtimes reduce your confidence"&lt;/em&gt; and &lt;em&gt;"score it 8 or 9 so the choice isn't discarded as low-confidence"&lt;/em&gt;. I'd been writing instructions to get answers past my own filter. 😅&lt;/p&gt;

&lt;h2&gt;
  
  
  Most of the win is refusals it doesn't make
&lt;/h2&gt;

&lt;p&gt;Go back to those 102 disagreements, because one number in them explains the whole result. Of the listings where the two differed, the old path had published &lt;code&gt;event&lt;/code&gt; on &lt;strong&gt;51&lt;/strong&gt; of them, and Jev never once moved a listing into &lt;code&gt;event&lt;/code&gt; that the LLM path had managed to categorise.&lt;/p&gt;

&lt;p&gt;So the improvement isn't mostly better judgement about hard listings, it's that fewer listings get abandoned. Between the threshold discarding answers and the model itself reaching for &lt;code&gt;event&lt;/code&gt; when a listing looked awkward, roughly half the disagreements were cases where the old path had declined to commit and Jev simply answered.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened in production
&lt;/h2&gt;

&lt;p&gt;It went live yesterday. Comparing the first Jev release against the previous one, joined on showing ID across the 6,472 listings that appear in both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;event&lt;/code&gt; fell from &lt;strong&gt;128 to 29&lt;/strong&gt;, and the 29 left are things like Games Night, a cheese and port afternoon, and an awards ceremony, which are genuinely uncategorisable&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;multiple-movies&lt;/code&gt; rose from 59 to 90, mostly double bills the old path had given up on&lt;/li&gt;
&lt;li&gt;only &lt;strong&gt;2.41%&lt;/strong&gt; of listings changed category at all, so nothing lurched&lt;/li&gt;
&lt;li&gt;categorisation cost dropped &lt;strong&gt;55%&lt;/strong&gt;, from $0.0767 to $0.0342 per run&lt;/li&gt;
&lt;li&gt;535 calls went out and not one fell back to the LLM&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyq4jhtvmqskcxzethbvd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyq4jhtvmqskcxzethbvd.png" alt="Usage screenshot of using Jev" width="660" height="536"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The whole thing sits behind a &lt;code&gt;CATEGORISER&lt;/code&gt; environment variable so I can put it back in seconds, and the language model is still wired up underneath as a fallback for timeouts and rate limits. That fallback logs loudly when it fires, because a silent one would look exactly like a working Jev run.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it still gets wrong
&lt;/h2&gt;

&lt;p&gt;The misses I find interesting aren't really about model quality.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Nickel runs mystery screenings&lt;/strong&gt;, where the film isn't named until you're in the room. The listing for one of them opens "Mystery Erotic Thriller presents undisclosed deep cuts from the genre's overheated history", and the description goes on in the plural about neo-noir, studio sleaze and direct-to-video delirium. That's a description of the strand, not of the single film that's on tonight. Jev reads the plural and says &lt;code&gt;multiple-movies&lt;/code&gt;. I already have a guidance line warning about exactly this trap, telling it to count only the films a listing actually names, and it still loses to a description written entirely in the plural.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"450 XL: A silent movie"&lt;/strong&gt; has the words "a silent movie" in the title and is not one: it's a live performance. Jev said &lt;code&gt;movie&lt;/code&gt; at 0.24 confidence, which is the system working exactly as intended and still being wrong. It knew it didn't know.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Supernatural Mini Marathon"&lt;/strong&gt; came back as &lt;code&gt;multiple-movies&lt;/code&gt; when the right answer is &lt;code&gt;tv&lt;/code&gt;. The description the venue supplies reads exactly like a film plot and contains none of the signals I listed under &lt;code&gt;tv&lt;/code&gt;: no broadcaster, no "episode", no "series". What the listing does have is "Marathon" in the title, and "Marathons" is one of the examples I wrote under &lt;code&gt;multiple-movies&lt;/code&gt;. Answering correctly means already knowing that Supernatural is a television programme, which is knowledge about the world rather than anything the venue wrote down.&lt;/p&gt;

&lt;p&gt;The one genuine gap is mine: three single films of 60 to 73 minutes landed in &lt;code&gt;shorts&lt;/code&gt;. My criteria cover "a single film under 40 minutes" and "several films, none reaching 80", so a lone 65-minute documentary fits neither while I'm actively telling Jev it runs "shorter than the 80 minutes a single feature usually runs". I never defined that case, so it picked the nearest thing that fit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this kind of thing fits
&lt;/h2&gt;

&lt;p&gt;Jev is new enough that the useful question isn't whether it beat my prompt, it's what shape of problem suits it. After one evening with a real one, my rough test is three things: the output is one of a fixed set of options rather than prose, the decision is small but happens constantly, and you could sit down and hand-label a few hundred real examples without losing the will to live. Categorising cinema listings hits all three. Summarising them wouldn't, and neither would anything where I actually wanted the sentence it wrote.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>javascript</category>
      <category>showdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>No Results Found (Until You Look)</title>
      <dc:creator>Alistair</dc:creator>
      <pubDate>Sun, 20 Sep 2026 08:39:00 +0000</pubDate>
      <link>https://dev.to/alistairjcbrown/no-results-found-until-you-look-11ke</link>
      <guid>https://dev.to/alistairjcbrown/no-results-found-until-you-look-11ke</guid>
      <description>&lt;p&gt;I set my dad a challenge: find me a documentary that's on next weekend. I wanted to see how discoverable &lt;a href="https://clusterflick.com" rel="noopener noreferrer"&gt;Clusterflick&lt;/a&gt;'s filters were, and I'll admit it was a bit contrived. He ignored the filters entirely, typed "&lt;a href="https://clusterflick.com/films/?search=documentary" rel="noopener noreferrer"&gt;documentary&lt;/a&gt;" into the search box, and got nothing back.&lt;/p&gt;

&lt;p&gt;He wasn't doing anything unusual. I do much the same thing myself. When I'm checking whether we're picking up everything from a venue, I'll spot an event on Eventbrite, copy the title and search for it, and it comes up empty because an Eventbrite title rarely matches the film's title. When I'm just using the site to see a film a friend has mentioned, I search for it and get nothing because it isn't on in the next seven days, even though it's on the week after.&lt;/p&gt;

&lt;p&gt;In all three cases the answer was in the data, but the site just wasn't looking in the right place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fewer Films by Default
&lt;/h2&gt;

&lt;p&gt;Clusterflick pulls listings from 400+ venues across London, and if you show all of it at once you get a wall: today that's 1,820 movies (out of 2,044 events in total). So the default view is narrower: just movies (including shorts, double bills and marathons), at every venue, over the next seven days. Today that brings it down to 477.&lt;/p&gt;

&lt;p&gt;A smaller grid is much easier to browse, but it comes with a cost when you go looking for something specific. The seven-day window becomes an invisible reason a search comes up empty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every Option, at a Price
&lt;/h2&gt;

&lt;p&gt;The current filters are summarised at the top of the page, and clicking the summary opens an overlay where you can change them.&lt;/p&gt;

&lt;p&gt;The overlay starts with quick selects like "near me today" and "this week", followed by the common filters (title, event type, venue, date and time), each with a few presets such as "near me" or "this weekend". Everything else is there too, one click further down.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7uy48nc4cdk4wyh0ywyh.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7uy48nc4cdk4wyh0ywyh.jpg" alt="The filter overlay" width="799" height="468"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;👆 &lt;em&gt;The filter overlay, with the less-used options expanded&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The idea is that you can get any level of control you want, but the less-used filters take a little more effort to reach. It works reasonably well for people who open it, but my dad never did.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Might Someone Type?
&lt;/h2&gt;

&lt;p&gt;Those three searches, "documentary", an Eventbrite event title and a film showing in a fortnight, are three different things typed into the same box. That made me come at the problem from the other end: rather than steering people towards the overlay, what might someone put into the search box, and could the site meet each of those with a suggestion?&lt;/p&gt;

&lt;p&gt;The search box itself stays simple and still filters on film title, and the work happens when that query comes up short. When there are no results, Clusterflick tries other readings of what you typed, and if any of them would return something, it offers them as buttons with a count of what you'd get.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four Ways to Read an Empty Result
&lt;/h2&gt;

&lt;p&gt;Not every suggestion asks the same thing of you. Some take your query exactly as typed and just look for it somewhere else, while others give up something you asked for. The suggestions fall into four kinds, ordered by how much they cost you.&lt;/p&gt;

&lt;h3&gt;
  
  
  It's a filter, not a title
&lt;/h3&gt;

&lt;p&gt;"Documentary" is a genre, "IMAX" is a presentation format and "Mark Hamill" is an actor. None of them are film titles, but each matches a filter value, so the suggestion reads your query as that filter instead: &lt;em&gt;Show Documentary films&lt;/em&gt;, or &lt;em&gt;Show films starring Mark Hamill&lt;/em&gt;. This kind concedes nothing, because it's your query exactly as you typed it, so it goes first.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frq8szz52cinnc4xw9nvk.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frq8szz52cinnc4xw9nvk.jpg" alt="Empty result for " width="800" height="601"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;👆 &lt;em&gt;Searching "documentary" now offers the Documentary genre instead of showing an empty page&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It's also forgiving about how you type it; names are matched fuzzily, so "Mark Hammell" still finds him, and a query can match part of a longer value, so "IMAX" offers both "&lt;a href="https://clusterflick.com/films/?base=all&amp;amp;presentation=imax" rel="noopener noreferrer"&gt;IMAX&lt;/a&gt;" and "&lt;a href="https://clusterflick.com/films/?base=all&amp;amp;source=imax-70mm" rel="noopener noreferrer"&gt;IMAX 70mm&lt;/a&gt;". Those are two separate offers with different counts, and you pick the one you meant.&lt;/p&gt;

&lt;p&gt;Venues are deliberately left out. Their names are full of ordinary words (&lt;a href="https://clusterflick.com/venues/rio-cinema/" rel="noopener noreferrer"&gt;Rio&lt;/a&gt;, &lt;a href="https://clusterflick.com/venues/the-castle-cinema/" rel="noopener noreferrer"&gt;Castle&lt;/a&gt;, &lt;a href="https://clusterflick.com/cinema-groups/everyman/" rel="noopener noreferrer"&gt;Everyman&lt;/a&gt;, &lt;a href="https://clusterflick.com/venues/the-garden-cinema/" rel="noopener noreferrer"&gt;Garden&lt;/a&gt;) that collide with film titles too often to be useful.&lt;/p&gt;

&lt;h3&gt;
  
  
  Right query, wrong field
&lt;/h3&gt;

&lt;p&gt;This is the Eventbrite case. Venues often title a screening differently from the film, for example "&lt;a href="https://clusterflick.com/films/?search=Bar+Trash+presents" rel="noopener noreferrer"&gt;Bar Trash presents&lt;/a&gt;" or a festival strand name (I wrote about &lt;a href="https://dev.to/alistairjcbrown/cleaning-cinema-titles-before-you-can-even-search-1463"&gt;cleaning those titles up&lt;/a&gt; in an earlier post). If your query finds nothing in film titles but does match the original venue title, the suggestion offers to &lt;em&gt;Search original venue titles instead&lt;/em&gt;. The same applies to performance notes, so searching "&lt;a href="https://clusterflick.com/films/?search=Strobe" rel="noopener noreferrer"&gt;Strobe&lt;/a&gt;" finds screenings where a venue has flagged strobe lighting.&lt;/p&gt;

&lt;p&gt;The offer quotes back the text that actually matched, which shows your query was right and was just pointed at the wrong field. The query is only ever moved into an empty field. If you'd already typed something into the performance notes search yourself, overwriting it would lose your input without you noticing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgga5m8180yx39esa5av9.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgga5m8180yx39esa5av9.jpg" alt="Empty result for film club search with venue title suggestion" width="800" height="525"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;👆 &lt;em&gt;Searching "Bar Trash presents" now offers to search original venue titles instead of showing an empty page&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Did you mean…
&lt;/h3&gt;

&lt;p&gt;"Inglorious Bastards" gets you &lt;em&gt;Did you mean "Inglourious Basterds"?&lt;/em&gt;. This kind ranks below the others because it changes your words, so it only appears when the query as typed matches nothing, anywhere. If you've spelled a title correctly, you'll never be offered a correction.&lt;/p&gt;

&lt;p&gt;Ties turned out to be the normal case rather than the exception. "dummer" is one edit away from a whole run of titles through the word "summer" ("A Summer's Tale", "I Know What You Did Last Summer" and so on), and only two get offered. Alphabetical order was the obvious tie-break and the worst one, because it favoured whatever happened to start with a digit or an A. Instead, the film with the most screenings wins a tie, since a film showing across London all week is a better guess than one with a single late-night slot. If that's level too, the soonest showing decides.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftad3o09tr5efh0huxuw2.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftad3o09tr5efh0huxuw2.jpg" alt="Empty result for typo title search with title correction suggestion" width="800" height="515"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;👆 &lt;em&gt;Searching for a film title with a typo now offers to search with a corrected title instead of showing an empty page&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Giving something up
&lt;/h3&gt;

&lt;p&gt;This is the friend's-recommendation case, and it's the one I use most. You search for a film, it's not on in the next seven days, and the suggestion offers &lt;em&gt;Any date&lt;/em&gt;, along with when the next showing is.&lt;/p&gt;

&lt;p&gt;Deciding which widening to offer first took the most thought. The simple approach would be to offer whichever widening returns the most results, but that goes wrong quickly. If you've got the subtitles filter on, dropping it will often free up the most screenings, so it would sit at the top, and it's the one suggestion a subtitles user can't act on. If you've narrowed to venues near you, dropping that might return more than dropping the date, but I'd guess most people would rather wait a week than cross London.&lt;/p&gt;

&lt;p&gt;So the order is an editorial judgement. Date comes first, because "I want to see this film" rarely means "tonight or never". Venues sit lower, because travelling further is a real cost. Accessibility comes last, is only ever offered on its own, and never gets any detail, because any detail would read as an argument for giving up a requirement. It's listed at all only so that someone who &lt;em&gt;can&lt;/em&gt; be flexible isn't left guessing.&lt;/p&gt;

&lt;p&gt;The typed query itself is never widened away. It's the clearest statement of what you want on the whole page, so it gets moved to another field, never dropped.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbigr8n1hg47b339zpa3y.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbigr8n1hg47b339zpa3y.jpg" alt="Searching " width="800" height="553"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;👆 &lt;em&gt;Searching for a movie not showing in the next 7 days now offers to widen the date window instead of showing an empty page&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Counting Against the Real Data
&lt;/h2&gt;

&lt;p&gt;Every count on every suggestion comes from probing. The code builds a candidate set of filters, runs it through the same filtering code the page uses, and counts what survives. That means the number on the button is the number you'll get, and there's no second copy of the filter logic to drift out of sync.&lt;/p&gt;

&lt;p&gt;Candidates are tried in rounds, cheapest first: redirects and filter readings, then single widenings, then pairs. The search keeps going until it has enough offers, rather than stopping at the first round that finds something, because a cheap redirect and an expensive widening often point at entirely different films. There's no third round. Relaxing three filters at once is a reset with extra steps, and there's already a reset button.&lt;/p&gt;

&lt;p&gt;The full logic is in &lt;a href="https://github.com/clusterflick/clusterflick.com/blob/2e7747b62dd451ff19c0d7934f6da3b8d064f90e/src/lib/filters/suggest.ts" rel="noopener noreferrer"&gt;&lt;code&gt;suggest.ts&lt;/code&gt;&lt;/a&gt; if you want to see the details.&lt;/p&gt;

&lt;h2&gt;
  
  
  When There Are Results, Just Not Many
&lt;/h2&gt;

&lt;p&gt;There's a quieter version of the same problem. Sometimes a search does work, but the answer is narrower than it looks. You filter by a director, see one film, and assume that's all there is, when three more are on next month.&lt;/p&gt;

&lt;p&gt;For that there's a separate notice, which states that the date window is hiding more films rather than offering a rescue. It only appears when the grid holds three films or fewer. That's the point where the grid looks like the complete answer but isn't. I checked this limit against every cast and crew filter in a live release: 71% show nothing at all (that's the suggestions' job), 27% show exactly one film, and under 1% show more than three. The limit keeps the notice off a grid that's genuinely full, where it would just be noise.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F697cs0wsrsyff5nsgq3b.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F697cs0wsrsyff5nsgq3b.jpg" alt="Searching for Ridley Scott shows a notice that the date window is hiding more films" width="800" height="576"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;👆 &lt;em&gt;Searching for a director only shows a few movies in the next 7 days, but more over the next fortnight&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Rough Edges
&lt;/h2&gt;

&lt;p&gt;There's still plenty I haven't worked out, mostly in the overlay.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Venues&lt;/strong&gt; are the hardest. There's "near me" and a quick toggle for building a custom list, but I don't yet know what people actually want here: set groupings, their own curated list, a map to search, etc.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Event types&lt;/strong&gt; (TV, quizzes, comedy and so on) sit prominently in the overlay because of a time when I wasn't confident in how events were classified. I am now, so that filter could probably move down and something more useful take its place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Genre&lt;/strong&gt; is the filter I'm most likely to keep buried, because I don't think anyone opens the overlay to use it. The one unprompted search I watched was a genre, though. I think both are true: nobody goes looking for the genre filter, but people do type genres, and because the search box now catches them, keeping the filter out of the way is much safer than it would have been before.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source format&lt;/strong&gt; splits people. For some it's the most important filter on the page (35mm, 70mm), and for others it's noise. I'm not sure which group is bigger.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exclusion&lt;/strong&gt; isn't possible. Filters only add, so you can't say "no horror", and I don't know whether that's worth supporting.&lt;/p&gt;

&lt;p&gt;If you use Clusterflick, or you've wrestled with the same problems in your own filters, &lt;a href="https://clusterflick.com/about/" rel="noopener noreferrer"&gt;I'd like to hear how you'd answer any of these&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Desire Lines
&lt;/h2&gt;

&lt;p&gt;Planners have a name for the dirt track worn across a lawn where people keep cutting the corner: a &lt;a href="https://en.wikipedia.org/wiki/Desire_path" rel="noopener noreferrer"&gt;desire line&lt;/a&gt;. The paved route works fine, but nobody takes it, and the sensible response is to pave the track they've worn.&lt;/p&gt;

&lt;p&gt;The filter overlay is the paved route, and it's still there for anyone who wants it. The search box is where people actually walk. The filters haven't changed, but they now appear at the point someone needs them, with a count showing they'll get something back. My dad would have found his documentary.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ux</category>
      <category>opensource</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Making Film Format Searchable</title>
      <dc:creator>Alistair</dc:creator>
      <pubDate>Wed, 15 Jul 2026 07:47:00 +0000</pubDate>
      <link>https://dev.to/alistairjcbrown/making-film-format-searchable-cgi</link>
      <guid>https://dev.to/alistairjcbrown/making-film-format-searchable-cgi</guid>
      <description>&lt;p&gt;In &lt;a href="https://dev.to/alistairjcbrown/i-built-a-film-club-discovery-tool-for-londons-cinema-community-2md"&gt;Making London's hidden film clubs discoverable&lt;/a&gt;, I didn't need to scrape new data. The information needed to identify a film club was already sitting in the collected data; in the title, the URL, or a performance note. I just needed to ask a different question of that same data — and film format turned out to be the same story.&lt;/p&gt;

&lt;p&gt;For anyone who hasn't come across this: most screenings in cinemas are digital. But there are also many cinemas that run physical prints — &lt;a href="https://clusterflick.com/formats/35mm/" rel="noopener noreferrer"&gt;35mm&lt;/a&gt;, &lt;a href="https://clusterflick.com/formats/70mm/" rel="noopener noreferrer"&gt;70mm&lt;/a&gt;, occasionally &lt;a href="https://clusterflick.com/formats/16mm/" rel="noopener noreferrer"&gt;16mm&lt;/a&gt; — usually for classics or restorations (what I've called the format &lt;strong&gt;source&lt;/strong&gt;). There's also other presentational aspects, such as &lt;a href="https://clusterflick.com/formats/imax/" rel="noopener noreferrer"&gt;IMAX&lt;/a&gt;, &lt;a href="https://clusterflick.com/formats/screenx/" rel="noopener noreferrer"&gt;ScreenX&lt;/a&gt;, &lt;a href="https://clusterflick.com/formats/dolby-cinema/" rel="noopener noreferrer"&gt;Dolby Cinema&lt;/a&gt;, etc. (what I've called the format &lt;strong&gt;presentation&lt;/strong&gt;). Each setup has its own look and its own following, and for 35mm print some people will specifically go looking for that over a digital screening of the same film.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: 35mm Is Playing Somewhere, Good Luck Finding It
&lt;/h2&gt;

&lt;p&gt;Say you want to know what's playing on 35mm this week. Maybe you're chasing a specific film, maybe you just prefer the way film grain looks over digital. Either way, you're stuck doing the same tour every cinema-format enthusiast in London already knows by heart: check the &lt;a href="https://princecharlescinema.com/seasons-events/3570mm/" rel="noopener noreferrer"&gt;Prince Charles Cinema&lt;/a&gt;, check &lt;a href="https://www.picturehouses.com/event-details/0000000041/35mm-70mm/34" rel="noopener noreferrer"&gt;Picturehouse Central&lt;/a&gt;, check &lt;a href="https://whatson.bfi.org.uk/Online/default.asp?BOparam::WScontent::loadArticle::permalink=35mm70mmscreenings" rel="noopener noreferrer"&gt;the BFI Southbank&lt;/a&gt;, check whichever smaller venues you think of (like &lt;a href="https://cinemamuseum.org.uk/schedule/category/events/list/?tribe-bar-search=35mm" rel="noopener noreferrer"&gt;The Cinema Museum&lt;/a&gt;). Some venues put the format in the listing title. Some bury it in a paragraph of copy under the showtime. Some add it to the mountain of tags sitting under the listing, indistinguishable from every other tag on the page.&lt;/p&gt;

&lt;p&gt;Sometimes it's searchable, but often not. Sometimes you can filter for it, if you know the incantation to use. But no single site treats format as a consistent piece of structured data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Leveraging the Data I Already Have
&lt;/h2&gt;

&lt;p&gt;Cinemas already want you to know the format; a 70mm presentation of a classic is exactly the kind of thing a venue is proud to advertise. The information isn't hidden by intent, it's just unstructured and sometimes deprioritised. Clusterflick's pipeline already retrieves every venue's listing page to build the standard set of fields: title, date, time, director, year, etc. Format is one more piece of data worth pulling out of the same source.&lt;/p&gt;

&lt;p&gt;The challenge is standardisation, not just discovery. One venue writes "35mm presentation," another writes "on 35mm film," a third just puts "[35MM]" in the title. The extraction has to normalise all of that down to a single consistent value — and avoid false positives — before it's worth anything as a filter. Once it is, it behaves exactly like date or genre; pick it from the filter bar and the film list narrows to match.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dogfooding the Output
&lt;/h2&gt;

&lt;p&gt;Owning the whole flow, from what's retrieved off a venue's page to what shows up on the &lt;a href="https://clusterflick.com/films" rel="noopener noreferrer"&gt;Clusterflick films page&lt;/a&gt;, means I get to use the thing I built (and that's been the motivation all along!). &lt;a href="https://clusterflick.com/formats/" rel="noopener noreferrer"&gt;Clusterflick's formats page&lt;/a&gt; now exists because format is structured data not just prose in performance notes. Generating a dedicated page for each format is nearly free, just the same underlying field presented as its own browsable view.&lt;/p&gt;

&lt;p&gt;That's the value of not just collecting data but consuming it. If the format page felt awkward to use, or the filter missed obvious cases, I'd know immediately, because I'm the first user of my own output. &lt;br&gt;
(And I did spot issues; the first pull missed a bunch of obvious IMAX performances at the &lt;a href="https://clusterflick.com/venues/bfi-imax/" rel="noopener noreferrer"&gt;BFI IMAX&lt;/a&gt; and &lt;a href="https://clusterflick.com/venues/science-museum/" rel="noopener noreferrer"&gt;Science Museum&lt;/a&gt;. Whoops!)&lt;/p&gt;

&lt;h2&gt;
  
  
  Other People Are Building on This Too
&lt;/h2&gt;

&lt;p&gt;The data pipeline's output isn't just for the Clusterflick site. It's published openly (I've an upcoming post on why Clusterflick is open source), and a small but growing set of independent projects already build on it (see &lt;a href="https://clusterflick.com/about/" rel="noopener noreferrer"&gt;Built with Clusterflick&lt;/a&gt; for who's currently using the feed).&lt;/p&gt;

&lt;p&gt;Structured format data isn't just useful to me; it's useful to anyone building something on top of the same open London cinema data. If you're looking for what's being shown in London, take a look at the showing data that's freely available (and updated daily) on &lt;a href="https://clusterflick.com/about/" rel="noopener noreferrer"&gt;https://clusterflick.com/about/&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  No Safety Net This Time
&lt;/h2&gt;

&lt;p&gt;Adding format data wasn't the first time I applied this pattern of extracting more value from the same data. I first did it with accessibility data — but that's for the next post, where I can dedicate a proper dive into cinema accessibility. For accessibility data, I was lucky there's a second dataset out there, the &lt;a href="https://accessiblescreeningsuk.co.uk/about-us/" rel="noopener noreferrer"&gt;UKCA&lt;/a&gt;'s &lt;em&gt;"comprehensive and searchable listings database for accessible screenings in UK cinemas"&lt;/em&gt;. Using it as a comparison has caught real bugs (in both directions) and also increased confidence in how accessibility information is detected and tagged.&lt;/p&gt;

&lt;p&gt;Format has nothing equivalent. No dedicated site tracks 35mm and 70mm screenings across London cinemas the way Clusterflick now does, which means there's no external source to validate against. That means not knowing what I'm missing or mistagging, beyond spot-checking venues by hand (please let me know if there's a data set out there I can use!). That's a bit disappointing, but I think it could be a good sign. If nobody else has built this, it's either because nobody wanted it, or because pulling clean structured format data out of scattered cinema venue listings is a genuinely hard problem.&lt;/p&gt;

&lt;p&gt;I'm hoping it's not the former. Right now the format source pages track 142 films across 12 venues (135 on 35mm, 5 on 70mm, 2 on 16mm) spread across cinemas that range from the Prince Charles and the BFI to the &lt;a href="https://clusterflick.com/venues/the-castle-cinema/" rel="noopener noreferrer"&gt;Castle Cinema&lt;/a&gt;. I don't think that's a niche nobody cares about, maybe it's just a niche nobody had bothered to structure yet.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Github as Infrastructure</title>
      <dc:creator>Alistair</dc:creator>
      <pubDate>Wed, 15 Apr 2026 07:47:00 +0000</pubDate>
      <link>https://dev.to/alistairjcbrown/github-as-infrastructure-4a4i</link>
      <guid>https://dev.to/alistairjcbrown/github-as-infrastructure-4a4i</guid>
      <description>&lt;p&gt;Clusterflick has always been a personal project, which means keeping costs down has always been a goal. I already had a GitHub account for the code, so the question was how far I could push that. The answer turned out to be: further than I expected.&lt;/p&gt;

&lt;p&gt;This post is about using GitHub not just as a place to store code, but as the actual infrastructure the project runs on. Some of it is straightforward. Some of it is a bit unconventional. All of it comes back to the same goal: keep it cheap, keep it open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Actions as the Pipeline Engine
&lt;/h2&gt;

&lt;p&gt;The core of Clusterflick is a data pipeline: retrieve cinema listings, transform them, enrich them, combine them, generate outputs. That pipeline runs on &lt;a href="https://docs.github.com/en/actions" rel="noopener noreferrer"&gt;GitHub Actions&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Every morning, a workflow kicks off spinning up dozens of jobs to retrieve raw cinema data. As those finish, downstream workflows start spinning up more jobs — transforming, enriching, combining, and finally generating the website. Each step is a separate workflow in a separate repo, with outputs feeding into the next. The &lt;a href="https://github.com/clusterflick/.github/blob/main/profile/README.md" rel="noopener noreferrer"&gt;project readme&lt;/a&gt; has more details of the full flow if you want the detail.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpztf7z1cdgmp5nivovyk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpztf7z1cdgmp5nivovyk.png" alt="Flow diagram of the Clusterflick data pipeline" width="800" height="281"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One thing that makes this work in practice is GitHub's &lt;a href="https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions" rel="noopener noreferrer"&gt;secrets management&lt;/a&gt;. API keys for TMDB, the LLM provider, and the tokens needed for cross-repo dispatch events are all stored as secrets and injected into workflows at runtime — none of it sitting in the codebase.&lt;/p&gt;

&lt;p&gt;The free tier for public repos covers all of this comfortably. The open source decision and the infrastructure decision are linked — without public repos, the free Actions minutes disappear.&lt;/p&gt;

&lt;h2&gt;
  
  
  Releases as a Database
&lt;/h2&gt;

&lt;p&gt;This is the part people tend to raise an eyebrow at 😁&lt;/p&gt;

&lt;p&gt;Instead of a database, or an S3 bucket, or any paid storage layer, the output of all the scrapes is a bunch of JSON files uploaded as assets of a &lt;a href="https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository" rel="noopener noreferrer"&gt;GitHub Release&lt;/a&gt; on a public repo. A release is just a named snapshot and you can attach arbitrary files to it. The latest run uses the "latest" tag.&lt;/p&gt;

&lt;p&gt;Downstream workflows then download the latest release, and the pipeline continues. Each stage produces its own release in its own repo, which makes getting "the latest data" trivial — there's no pattern matching on release names, no querying a database. Each repo has one job: produce a release. Grab the latest release from that repo to get the data.&lt;/p&gt;

&lt;p&gt;It's not a pattern you'd reach for if you were building something traditional, but for a project where the data is public anyway it works well — the data is versioned, publicly accessible, queryable via the GitHub API, and costs nothing. There's also a nice side effect: every daily run produces an immutable, timestamped snapshot of cinema data. If you ever wanted to analyse how London's cinema landscape changes over time, that archive is just sitting there.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4lnguq5vqel0beanye3h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4lnguq5vqel0beanye3h.png" alt="Github releases page showing a recently data retrieval" width="800" height="590"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A Multi-Repo Architecture With Cross-Repo Triggers
&lt;/h2&gt;

&lt;p&gt;The pipeline spans multiple repos — &lt;code&gt;data-retrieved&lt;/code&gt;, &lt;code&gt;data-transformed&lt;/code&gt;, &lt;code&gt;data-cached&lt;/code&gt;, &lt;code&gt;data-combined&lt;/code&gt;, and others — with workflows in one repo triggering workflows in the next via &lt;a href="https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#repository_dispatch" rel="noopener noreferrer"&gt;&lt;code&gt;repository_dispatch&lt;/code&gt;&lt;/a&gt; events.&lt;/p&gt;

&lt;p&gt;The reason for the split is the Releases-as-storage pattern above. Each repo holds one stage's data as its latest release, and downstream repos pull from it. That boundary — one repo, one release, one stage — is what makes the "grab the latest" approach work cleanly. Some of these repos contain little more than a workflow file, a &lt;code&gt;package.json&lt;/code&gt;, and a readme.&lt;/p&gt;

&lt;p&gt;The trade-off is that tracing a failure across the chain means navigating between repos. The practical fix is a readme with status badges for each workflow — a glance tells you which stage broke, rather than having to click through each repo's Actions tab to find out.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F46j9mbs9bnorsj6rsqdk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F46j9mbs9bnorsj6rsqdk.png" alt="Github status badges showing the status of each job" width="800" height="289"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-Hosted Runners: When Cloud Runners Get Blocked
&lt;/h2&gt;

&lt;p&gt;Some cinema venues block requests from GitHub's cloud runner IP ranges — they're well-known and easy to identify as automated traffic. To handle those, I run a cluster of Raspberry Pi 4s at home as self-hosted runners. They use a residential IP address, so requests look like regular browser traffic.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/alistairjcbrown/the-raspberry-pi-cluster-in-my-living-room-6ik"&gt;The previous post in this series&lt;/a&gt; covers the hardware side of that setup. From GitHub's perspective: register each Pi as a runner in the org, add it to a runner group, target jobs at that group with &lt;code&gt;runs-on: self-hosted&lt;/code&gt;. The combination of cloud runners for most venues and self-hosted for the tricky ones means the pipeline rarely hits a wall.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pages for the Site
&lt;/h2&gt;

&lt;p&gt;The site itself is a statically generated &lt;a href="https://nextjs.org/" rel="noopener noreferrer"&gt;Next.js&lt;/a&gt; app, built in CI and deployed to &lt;a href="https://pages.github.com/" rel="noopener noreferrer"&gt;GitHub Pages&lt;/a&gt; via &lt;a href="https://github.com/actions/deploy-pages" rel="noopener noreferrer"&gt;&lt;code&gt;actions/deploy-pages&lt;/code&gt;&lt;/a&gt;. No server to manage, no hosting bill.&lt;/p&gt;

&lt;p&gt;Pages works well, but the default caching headers are conservative — not ideal when you're serving a lot of static assets. I've got &lt;a href="https://www.cloudflare.com/" rel="noopener noreferrer"&gt;Cloudflare&lt;/a&gt; sitting in front to handle that properly. The &lt;a href="https://dev.to/alistairjcbrown/site-performance-loading-30000-showings-in-a-browser-30go"&gt;site performance post&lt;/a&gt; goes into that in more detail.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn9221khelam8q4n14onc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn9221khelam8q4n14onc.png" alt="Screenshot of the Clusterflick website homepage" width="800" height="461"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Actions Beyond the Pipeline
&lt;/h2&gt;

&lt;p&gt;The pipeline is the obvious use of Actions, but it's not the only one. A few workflows that exist purely for maintenance or operational visibility:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Weekly dependency cache cleanup.&lt;/strong&gt; GitHub-hosted runners cache &lt;code&gt;node_modules&lt;/code&gt; and Playwright browser installs between runs, but those caches can get stale or bloated. A scheduled weekly workflow clears and rebuilds them, which keeps job times consistent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automated PRs for title normalisation.&lt;/strong&gt; When new cinema titles come in, a workflow records each title alongside its normalised output and opens a PR for manual review. This serves two purposes: it builds up a set of real titles to confirm that changes to the normaliser work as expected, and the PR diff makes it easy to spot cases where the normaliser isn't behaving correctly. I tried automating the review step itself with an LLM — &lt;a href="https://dev.to/alistairjcbrown/i-tried-to-automate-a-manual-review-task-with-claude-it-wasnt-worth-it-13m9"&gt;that post&lt;/a&gt; covers how that went.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Comparison against Accessible Screenings UK.&lt;/strong&gt; &lt;a href="https://www.accessiblescreeningsuk.co.uk/" rel="noopener noreferrer"&gt;Accessible Screenings UK&lt;/a&gt; maintains their own dataset of accessible cinema showings. A workflow runs a comparison between their data and Clusterflick's and surfaces anything that's in their data but missing from ours — a useful cross-check for coverage gaps that would otherwise be invisible.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl7earhthnvpj72ozh373.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl7earhthnvpj72ozh373.png" alt="Summary of a previous accessible screenings comparison" width="800" height="290"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Having these as scheduled or triggered workflows means they happen consistently rather than being something that gets done when someone remembers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Scripts Repo as an npm Package
&lt;/h2&gt;

&lt;p&gt;One pattern that's worked well is treating the shared scripts repo as an npm package installed directly from GitHub, rather than publishing it to the npm registry.&lt;/p&gt;

&lt;p&gt;In each repo's &lt;code&gt;package.json&lt;/code&gt;, the dependency looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"github:clusterflick/scripts"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running &lt;code&gt;npm install&lt;/code&gt; pulls the latest from the default branch. No versioning ceremony, no publishing step — every repo that depends on the scripts always gets the current version.&lt;/p&gt;

&lt;p&gt;There's no pinning by default, so a breaking change in the scripts repo will affect anything that reinstalls. The flip side is that a fix in the scripts repo propagates automatically the next time a job runs &lt;code&gt;npm install&lt;/code&gt; — so you can fix a broken job without re-running the entire workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  GitHub Projects for Task Management
&lt;/h2&gt;

&lt;p&gt;The last piece is &lt;a href="https://docs.github.com/en/issues/planning-and-tracking-with-projects/learning-about-projects/about-projects" rel="noopener noreferrer"&gt;GitHub Projects&lt;/a&gt; for tracking work. Issues live on the repos they relate to, and the project board pulls them all together.&lt;/p&gt;

&lt;p&gt;Keeping task management in GitHub means everything — code, data, CI, tasks — lives in one place. It's great for a project worked on in spare moments rather than full days.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7ufdk5z4vj4uxh5z7riw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7ufdk5z4vj4uxh5z7riw.png" alt="New cinemas Github project" width="800" height="408"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bigger Picture
&lt;/h2&gt;

&lt;p&gt;What I've ended up with is a project where GitHub does a lot more than host the code. It runs the pipeline, stores the data, hosts the site, handles the automation, and tracks the work. That wasn't the plan from the start — it accumulated decision by decision, each one driven by the same question: what's the cheapest way to do this that doesn't create more problems than it solves?&lt;/p&gt;

&lt;p&gt;The Releases-as-storage pattern still feels a bit odd to explain, but it works. The multi-repo cross-trigger setup adds some operational complexity, but it keeps each stage understandable on its own. None of it is architecture for architecture's sake — it's just what emerged from trying to keep the whole thing running for as little as possible.&lt;/p&gt;

</description>
      <category>github</category>
      <category>javascript</category>
      <category>opensource</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Tried to Automate a Manual Review Task with Claude. It Wasn't Worth It.</title>
      <dc:creator>Alistair</dc:creator>
      <pubDate>Sat, 04 Apr 2026 16:12:07 +0000</pubDate>
      <link>https://dev.to/alistairjcbrown/i-tried-to-automate-a-manual-review-task-with-claude-it-wasnt-worth-it-13m9</link>
      <guid>https://dev.to/alistairjcbrown/i-tried-to-automate-a-manual-review-task-with-claude-it-wasnt-worth-it-13m9</guid>
      <description>&lt;p&gt;Every day, a CI job adds new entries to &lt;a href="https://github.com/clusterflick/scripts/blob/main/common/tests/test-titles.json" rel="noopener noreferrer"&gt;&lt;code&gt;test-titles.json&lt;/code&gt;&lt;/a&gt; in my Clusterflick repo. When it finds a cinema listing title the normaliser hasn't seen before, it records the input and the current output, then opens a pull request. Someone — usually me — then has to review whether those outputs are actually correct, fix anything that isn't, and merge.&lt;/p&gt;

&lt;p&gt;It's not complicated work. Review the output and confirm the normalizer has done the correct job. If it hasn't, fix the output (test now fails ❌) and then fix the normalizer (until the test now passes ✅). But it happens twice day, and "not complicated" doesn't mean "not context switching".&lt;/p&gt;

&lt;p&gt;So I decided to try automating it with Claude. Several hours and $5 later, I don't think it was worth it — and I think the reasons why are worth writing up 💸&lt;/p&gt;

&lt;h2&gt;
  
  
  The Task
&lt;/h2&gt;

&lt;p&gt;The normaliser — &lt;a href="https://github.com/clusterflick/scripts/blob/ed3f84d25486b84703b3fd6e2d89fbbdae3a1bf3/common/normalize-title.js" rel="noopener noreferrer"&gt;&lt;code&gt;normalize-title.js&lt;/code&gt;&lt;/a&gt; — converts raw cinema listing titles into a consistent string. I've written about it more in depth in my previous post, &lt;a href="https://dev.to/alistairjcbrown/cleaning-cinema-titles-before-you-can-even-search-1463"&gt;Cleaning Cinema Titles Before You Can Even Search&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;When the CI job adds new test entries, it records whatever the normaliser currently produces. The reviewer's job is to decide whether that output is &lt;em&gt;correct&lt;/em&gt;. There's a &lt;a href="https://github.com/clusterflick/scripts/blob/ed3f84d25486b84703b3fd6e2d89fbbdae3a1bf3/docs/reviewing-title-normalisation-test-cases.md" rel="noopener noreferrer"&gt;&lt;code&gt;docs/reviewing-title-normalisation-test-cases.md&lt;/code&gt;&lt;/a&gt; file with detailed guidance on how to classify and fix different types of issues.&lt;/p&gt;

&lt;p&gt;The automation task: look at the new entries, use the guide to decide if they look correct, fix anything that's wrong, commit. Automating it with Claude seemed like a reasonable fit, especially as I'd been doing this semi automated locallly using a very basic prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;In this branch we've had some automated updates to `common/tests/test-titles.json`.
Confirm these changes are correct, or if they're not correct then fix them.
There's details on how this setup works in `docs/reviewing-title-normalisation-test-cases.md`
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Approach
&lt;/h2&gt;

&lt;p&gt;I set up Claude platform and added $5 of credit. Then set up a GitHub Actions workflow triggered by a &lt;code&gt;@claude review titles&lt;/code&gt; comment on any PR. The &lt;a href="https://github.com/anthropics/claude-code-action" rel="noopener noreferrer"&gt;Claude Code GitHub Action&lt;/a&gt; handles the Claude integration — it checks out the PR branch, runs Claude Code against it, and can commit fixes back to the branch.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3cof1mkaprdk57pr8ri3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3cof1mkaprdk57pr8ri3.png" alt="Screenshot of Claude platform" width="800" height="133"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The workflow was straightforward in principle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;issue_comment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;created&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;claude-review&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;-&lt;/span&gt;
      &lt;span class="s"&gt;contains(github.event.comment.body, '@claude review titles') &amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class="s"&gt;github.event.issue.pull_request != null&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6z9kyaz1vy4bo0rfn9qt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6z9kyaz1vy4bo0rfn9qt.png" alt="Screenshot of Claude in actions output" width="800" height="109"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Claude gets the diff, reads the documentation, checks each new entry, and either accepts it as correct or fixes it. Should be straightforward, and a manual trigger to kick it off so no surprises.&lt;/p&gt;

&lt;p&gt;For this, I was also going to double down with Claude; Claude.ai to guide me through the setup, and using Claude API (via the Github action) to do the action review. But getting there took a few attempts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problems
&lt;/h2&gt;

&lt;p&gt;Something worth noting upfront: every failed run here cost money, especially if Claude spirals and chews through tokens. There's not a lot of feedback (or too much once we figured out streaming that back) so it's much harder than it is locally to see what Claude's thinking and there's no reprompt to bring it back on path. On top of that, each run takes several minutes before you find out what went wrong, the feedback loop is slow and expensive. Debugging a GitHub Actions workflow normally costs you time. Debugging this one cost time &lt;em&gt;and&lt;/em&gt; cash.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Permissions.&lt;/strong&gt; The first run failed with OIDC token errors. The Claude Code Action uses OIDC to generate a GitHub App token, which requires &lt;code&gt;id-token: write&lt;/code&gt; in the workflow permissions. I'm not sure why Claude.ai didn't include that in the initial workflow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Branch checkout.&lt;/strong&gt; The PR branch wasn't checked out by default — the runner was on &lt;code&gt;main&lt;/code&gt;, so Claude found no diff (and chewed through tokens). I added an explicit checkout step with &lt;code&gt;ref: refs/pull/${{ github.event.issue.number }}/head&lt;/code&gt; and &lt;code&gt;fetch-depth: 0&lt;/code&gt; so &lt;code&gt;git diff&lt;/code&gt; had something to work with. Again, I'm not sure why Claude.ai didn't include that in the initial workflow.&lt;/p&gt;

&lt;p&gt;I probably should have caught this one myself. Checking out the PR branch is a well-known requirement when working with pull requests in Actions. I assumed a language model with broad knowledge of GitHub Actions would have it covered. The lesson there is the same as always with LLM output: trust but verify.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Missing &lt;code&gt;--dangerously-skip-permissions&lt;/code&gt;.&lt;/strong&gt; Without this flag, Claude keeps pausing to ask permission before running bash commands or editing files. In a non-interactive GitHub Actions environment that means it loops forever waiting for input it'll never get. Required flag for any autonomous use. Again, I'm not sure why Claude.ai didn't include that in the initial workflow.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fg8g22ttv10gnkknoj3h7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fg8g22ttv10gnkknoj3h7.png" alt="Screenshot of Claude.ai after being queried about dangerously-skip-permissions flag" width="800" height="242"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--allowedTools&lt;/code&gt; has a bug.&lt;/strong&gt; I initially used &lt;code&gt;--allowedTools Bash,Read,Edit,Write&lt;/code&gt; to restrict Claude to just the tools it needs. But there's a known issue where the init message still reports all available tools, which can confuse Claude into thinking it can use them. Swapped to &lt;code&gt;--disallowedTools&lt;/code&gt; instead, which works correctly.&lt;/p&gt;

&lt;p&gt;By this point I'd spent half my budget just getting the plumbing right, without the PR being updated at all. For context, this PR added 11 new titles, so it wasn't a huge amount of data to review.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 30-Turn Failure
&lt;/h2&gt;

&lt;p&gt;The first run that got past all the setup issues hit the 30-turn limit and stopped without committing anything. It cost $0.59 and took about five minutes.&lt;/p&gt;

&lt;p&gt;What happened was actually Claude doing the right thing. It ran all 11 inputs through the normaliser, saw that every output matched what was recorded, and then — correctly — kept going. Because matching the normaliser isn't the same as being correct. The documentation I'd pointed it at says it plainly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The &lt;code&gt;output&lt;/code&gt; field in &lt;code&gt;test-titles.json&lt;/code&gt; is what the test &lt;strong&gt;expects&lt;/strong&gt;, not necessarily what is correct.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So Claude spent the next 25+ turns reading through &lt;code&gt;normalize-title.js&lt;/code&gt;, &lt;code&gt;known-removable-phrases.js&lt;/code&gt;, and the existing test data, reasoning about whether each output was actually right. That's exactly the job. The problem was it cost $0.59 and ran out of turns before committing anything useful. &lt;/p&gt;

&lt;p&gt;I asked Claude.ai to help diagnose this, and it suggested adding an explicit stopping condition to the prompt — something like "if it matches, accept it, don't investigate further." I took that suggestion at face value without thinking through what it actually meant. It would stop the spiralling. It would also stop the reasoning. Those are the same thing 🤦&lt;/p&gt;

&lt;p&gt;I added the stopping condition, dropped &lt;code&gt;--max-turns&lt;/code&gt; to 15, and declared the cost problem fixed. It wasn't — I'd just hidden it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A "Successful" Run That Wasn't
&lt;/h2&gt;

&lt;p&gt;With the prompt fixed and tools switched to &lt;code&gt;--disallowedTools&lt;/code&gt;, the next run completed in 6 turns and 45 seconds. Cost: $0.19.&lt;/p&gt;

&lt;p&gt;The full sequence: check the git log, get the diff, read the docs, run all 11 inputs through the normaliser in a single batch, conclude &lt;em&gt;"All 11 new entries match the recorded output exactly. No fixes needed."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The problem is that conclusion is &lt;em&gt;always&lt;/em&gt; true, by construction. The CI job that creates these PRs records &lt;code&gt;normalizer(input)&lt;/code&gt; as the output — so of course it matches when you run the normaliser again. Confirming that match is confirming that the CI job that created the PR did the job correctly, nothing more.&lt;/p&gt;

&lt;p&gt;What I actually needed was the second step: reasoning about whether those outputs are &lt;em&gt;correct&lt;/em&gt;, spotting event prefixes that should be stripped, recognising real film titles that are getting mangled, and updating &lt;code&gt;known-removable-phrases.js&lt;/code&gt; accordingly. That's the work. In solving the cost problem by narrowing the prompt, I'd removed the work entirely.&lt;/p&gt;

&lt;p&gt;When I went back through the PR manually, I found several entries that still needed fixing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cost Problem Underneath
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What kept nagging at me:&lt;/strong&gt; the task is reviewing 11 strings. There's a large corpus of existing examples, a detailed instructions document, and an LLM with a vast amount of general knowledge. It shouldn't require 30 turns and $0.59 to do this — and the fact that it did suggests something isn't well-suited here, not just misconfigured.&lt;/p&gt;

&lt;p&gt;Part of it is a problem with visibility. With each run costing real money and taking several minutes, debugging is expensive. You can't easily see why Claude went down a particular path until you're staring at a full JSON trace of every tool call. Every misconfiguration costs you money and ten minutes before you understand what went wrong. Several of those cycles add up quickly — the $5 I spent getting here was just debugging, not doing useful work.&lt;/p&gt;

&lt;p&gt;And even when the infrastructure is right, the cost curve for this type of task is awkward. Simple cases (all outputs correct) should be cheap, but you can't know in advance whether the run will be simple. If Claude starts investigating an ambiguous case, you're back to 20+ turns and $0.50+. The unpredictability makes it hard to budget.&lt;/p&gt;

&lt;p&gt;For a task this focused — a small number of strings, a clear pattern to match against, a fixed corpus to consult — perhaps a deterministic script would be more reliable (and much cheaper). The Claude Code GitHub Action is well-suited to open-ended tasks where you're not sure what tools you'll need... and maybe if you've got a healthy budget to back that too. A free, open-source, personal project trying to automate reviewing normaliser outputs against a known pattern isn't really any of that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;I wouldn't abandon the idea entirely. The local Claude Code workflow — where I can watch it reason, reprompt when it went off track, and apply fixes interactively — has worked well and saved real time. The problem is trying to make that fully autonomous in a way that's cost-effective.&lt;/p&gt;

&lt;p&gt;If I came back to this, I'd probably try a direct API call with a tighter prompt and explicit output format rather than the full Claude Code agentic setup. Something that gets the diff, asks Claude to classify each entry as "looks correct" or "has issue: [reason]", and only triggers the expensive autonomous work when there's actually something to fix.&lt;/p&gt;

&lt;p&gt;But for now, some things are still faster and cheaper done by hand. 🍿&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>github</category>
      <category>showdev</category>
    </item>
    <item>
      <title>The Raspberry Pi Cluster in My Living Room</title>
      <dc:creator>Alistair</dc:creator>
      <pubDate>Wed, 25 Mar 2026 08:55:00 +0000</pubDate>
      <link>https://dev.to/alistairjcbrown/the-raspberry-pi-cluster-in-my-living-room-6ik</link>
      <guid>https://dev.to/alistairjcbrown/the-raspberry-pi-cluster-in-my-living-room-6ik</guid>
      <description>&lt;p&gt;There are six Raspberry Pi 4s on a shelf in my living room. They run 24/7, they're all wired directly into the router, and they exist for one fairly specific reason: some cinema websites block GitHub's IP ranges.&lt;/p&gt;

&lt;p&gt;GitHub Actions runners share IP space with a lot of automated traffic, and a handful of venues had decided they didn't want to serve requests from that space. The failures were inconsistent — empty responses, timeouts, bot-detection pages — which made them annoying to diagnose. Once I'd worked out what was actually happening, the fix was straightforward: residential IP addresses. Requests that look like they're coming from someone's home connection, because they are.&lt;/p&gt;

&lt;p&gt;Hence the Pis.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7e803a71a6usck861uyd.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7e803a71a6usck861uyd.jpg" alt="Raspberry Pis in mounts" width="800" height="583"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Pis, Not Just a Cheap PC?
&lt;/h2&gt;

&lt;p&gt;It's a fair question. I set myself a target: &lt;em&gt;£50 or less per Pi&lt;/em&gt;, all-in. That means the Pi 4 itself, an SD card, a power cable, and an ethernet cable. No wiggle room for a fancy case or anything optional. But six Pis at £50 each is £300 — you could buy a reasonable secondhand desktop for that and run six runners on it without breaking a sweat.&lt;/p&gt;

&lt;p&gt;The honest answer is that it didn't start as a deliberate architecture decision. I had one Pi spare, so I set it up as a runner. That was enough at first. As I added more venues and the pipeline got busier, I added another, then another. By the time I had three or four, I was actively buying more rather than reconsidering the approach — partly because they're cheap and low-power (running a desktop 24/7 would cost noticeably more on the electricity bill), but also because I'd started to like the fault tolerance story.&lt;/p&gt;

&lt;p&gt;Each Pi is independent. If one plays up, it takes one runner offline, not all of them. Better yet, there's nothing precious about any individual machine — the setup steps are &lt;a href="https://github.com/clusterflick/self-hosted-workflows?tab=readme-ov-file#setting-up-a-new-runner" rel="noopener noreferrer"&gt;fully documented&lt;/a&gt;, so if a Pi goes wrong I can wipe the SD card and have it back as a runner in under an hour. Cattle, not pets. A single PC running six processes doesn't give you that.&lt;/p&gt;

&lt;p&gt;Pi 4s aren't particularly cheap if you buy them new and in a hurry, but there's a reasonable secondhand market if you're patient. I watched eBay listings and Facebook Marketplace, picked them up when they matched the budget, and that's how I ended up with six of them. A few came without accessories, which meant sourcing cables separately — but even then, it worked out.&lt;/p&gt;

&lt;p&gt;One thing I learned the hard way: &lt;em&gt;the power supply matters more than you'd think&lt;/em&gt;. The Pi 4 is particular about voltage, and one of mine was on an underpowered cable. All headless, so there's no screen to hint at what's wrong — it just showed up as one runner that was less reliable than the others, dropping jobs intermittently. It took longer than I'd like to admit to narrow it down to the power supply. Swapping the power supply fixed it immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  SD Cards: The Unexpected Bottleneck
&lt;/h2&gt;

&lt;p&gt;The other thing that surprised me was how much the SD cards matter for this use case.&lt;/p&gt;

&lt;p&gt;Most Raspberry Pi guides will tell you any Class 10 card is fine, and for general use that's probably true. But GitHub Actions runners do a lot of I/O — constant git checkouts, caches being read and written, files being created and deleted across every job. Slow cards can appear fine at first, but you'll notice them becoming a bottleneck once they get a job, especially one with a lot of smaller steps. Jobs that should take 10 seconds start taking ten times as long, and you can't figure out why until you look at where the time is actually going.&lt;/p&gt;

&lt;p&gt;Swapping to &lt;em&gt;SanDisk Extreme Pro cards&lt;/em&gt; made a noticeable difference — runners were now consistently faster on anything I/O-heavy, which in practice is most jobs. I ended up writing &lt;a href="https://github.com/clusterflick/self-hosted-workflows/blob/f8109243ca07a0b5c5c39cd0b874e81fbf25eb5c/.github/workflows/check-sd-card.yml" rel="noopener noreferrer"&gt;a workflow to test SD card speed&lt;/a&gt; which uses &lt;a href="https://github.com/raspberrypi-ui/agnostics/blob/d77d0e053c884048f6656ee079bc5f3ed834c3e2/data/sdtest.sh" rel="noopener noreferrer"&gt;Raspberry Pi's own speed test script&lt;/a&gt;. It checks whether read and write speeds are fast enough to provide adequate performance, which saves finding out the hard way mid-pipeline (and I'm hoping will let me quickly diagnose if an SD card is degrading).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fndc4iziru8wx1h78kxih.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fndc4iziru8wx1h78kxih.png" alt="Screenshot of self hosted runner status workflow showing SD card speed test results for a specific runner" width="800" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The other SD card lesson: &lt;em&gt;16GB is too small&lt;/em&gt;. The GitHub Actions runner cache fills up in less than a week of regular use. I have a &lt;a href="https://github.com/clusterflick/self-hosted-workflows/blob/f8109243ca07a0b5c5c39cd0b874e81fbf25eb5c/.github/workflows/free-space.yml" rel="noopener noreferrer"&gt;scheduled workflow to free up space&lt;/a&gt; — it clears the npm cache, removes all Playwright browsers, then reinstalls the latest dependencies and pre-warms everything. It works, but it's a bit of a workaround for a storage problem. I've since bumped everything to 64GB cards, I still run the workflow weekly, and so far everything's running smoothly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Physical Setup
&lt;/h2&gt;

&lt;p&gt;Six Pis sitting loose on a shelf with cables going everywhere is exactly as annoying as it sounds, so I designed a mount to keep things tidy. It's a &lt;em&gt;3D-printed mount&lt;/em&gt; that holds each Pi in place, with enough spacing for airflow and clean cable routing (power cable is supported, SD card is accessible from the top, ethernet cable is hidden underneath).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm6u8zinljuhava7uqq5e.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm6u8zinljuhava7uqq5e.jpg" alt="The cluster — six Pi 4s, all wired, all tidy" width="800" height="744"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you want to print one yourself, I've uploaded the STL files to &lt;a href="https://www.printables.com/model/1571451-raspberry-pi-4-frame-base-stand" rel="noopener noreferrer"&gt;Printables&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fujtrc2joz6m2bhrt283b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fujtrc2joz6m2bhrt283b.png" alt="Rendering of the 3D model for mounting the Raspberry Pis" width="800" height="463"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Everything is connected &lt;em&gt;directly to the router via ethernet&lt;/em&gt;. No Wi-Fi. I briefly considered Wi-Fi for the tidiness of it, but I've had too many experiences with Wi-Fi dropouts causing mysterious CI failures, and the whole point of this thing is reliability. Ethernet cables aren't pretty, but they don't drop connections.&lt;/p&gt;

&lt;p&gt;The full cluster sits inside an &lt;a href="https://www.ikea.com/gb/en/p/smarra-box-with-lid-natural-90348063/" rel="noopener noreferrer"&gt;IKEA SMARRA box&lt;/a&gt;. It runs quietly, doesn't generate much heat, and sits in a corner where it's easy to ignore — which is exactly what you want from infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Haven't Covered
&lt;/h2&gt;

&lt;p&gt;Getting the Pis onto the network is the easy bit. Actually registering them as self-hosted GitHub Actions runners, keeping those runners healthy, and managing the runner environment across six machines is its own topic — one for another day.&lt;/p&gt;

&lt;p&gt;The short version for the curious: GitHub provides a script you run on each machine, it registers itself in your repo's settings, and from that point on it just sits there waiting to pick up jobs. The initial setup is straightforward enough. It's everything that comes after — keeping them healthy, diagnosing npm cache issues, hunting down slow runners — where things get more interesting. I do have &lt;a href="https://github.com/clusterflick/self-hosted-workflows/blob/f8109243ca07a0b5c5c39cd0b874e81fbf25eb5c/.github/workflows/runner-stats.yml" rel="noopener noreferrer"&gt;a workflow that reports stats across all runners&lt;/a&gt; — uptime, temperature, disk space remaining — which at least makes it easy to spot a machine that's quietly having a bad time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx8rlpal551mthj6s036b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx8rlpal551mthj6s036b.png" alt="Screenshot of self hosted runner status workflow showing stats for a specific runner" width="800" height="431"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Next post:&lt;/strong&gt; GitHub as Infrastructure — self-hosted runners, secrets management, and using GitHub Actions as the backbone of a daily data pipeline.&lt;/p&gt;

</description>
      <category>raspberrypi</category>
      <category>cicd</category>
      <category>githubactions</category>
      <category>homelab</category>
    </item>
    <item>
      <title>Cleaning Cinema Titles Before You Can Even Search</title>
      <dc:creator>Alistair</dc:creator>
      <pubDate>Wed, 18 Mar 2026 08:55:00 +0000</pubDate>
      <link>https://dev.to/alistairjcbrown/cleaning-cinema-titles-before-you-can-even-search-1463</link>
      <guid>https://dev.to/alistairjcbrown/cleaning-cinema-titles-before-you-can-even-search-1463</guid>
      <description>&lt;p&gt;When &lt;a href="https://clusterflick.com" rel="noopener noreferrer"&gt;Clusterflick&lt;/a&gt; first started pulling listings, I assumed the hard part would be the scraping. Getting the data off 250+ different cinema websites, each with their own structure and quirks — that's where the complexity lives, right?&lt;/p&gt;

&lt;p&gt;But before any of that work pays off, before a single TMDB search can happen, there's a problem sitting right at the start of the pipeline: cinema listings don't always give you a clean film title. They give you something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BAR TRASH – THE ZODIAC KILLER (1971) at Beer Merchants Tap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(IMAX) Princess Mononoke: 2025 Re-Release Subtited
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or my personal favourite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MUPPET PUPPETS CHRISTMAS CAROL WORKSHOP &amp;amp; SING-ALONG
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of those are going to find anything useful in a TMDB search. So before matching can happen, there's a normalisation step — and it's grown into something with its own test suite of nearly 15,000 cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Obvious Stuff
&lt;/h2&gt;

&lt;p&gt;The easy wins are the patterns you see immediately once you start looking at real listings. Film Clubs will attach their branding, and  cinemas love adding their series names and event types to the front of a title:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Bar Trash:
DocHouse:
CLASSIC MATINEE:
Animation at War:
Family Film Club:
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the end of titles is just as cluttered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;… + Q&amp;amp;A with Director
… on 35mm film
… (4K Remaster)
… Special Screening
… with Introduction
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For all of these, there's a &lt;a href="https://github.com/clusterflick/scripts/blob/cc77f913c7c2db110362b4f532d076b794e09b03/common/known-removable-phrases.js" rel="noopener noreferrer"&gt;&lt;code&gt;known-removable-phrases.js&lt;/code&gt;&lt;/a&gt; file — a flat list of exact strings and patterns to strip. It currently has around 1,000 entries. The rule for adding to it is simple: if a phrase is a superfluous label added by a venue, that isn't part of identifying the film, it goes here. Spelling corrections and encoding fixes are handled separately.&lt;/p&gt;

&lt;p&gt;The list isn't pretty, but it works. After stripping known phrases, &lt;code&gt;BAR TRASH – THE ZODIAC KILLER (1971) at Beer Merchants Tap&lt;/code&gt; becomes &lt;code&gt;THE ZODIAC KILLER (1971)&lt;/code&gt;. Progress.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Plus Problem
&lt;/h2&gt;

&lt;p&gt;A lot of venues append extra information to titles using a &lt;code&gt;+&lt;/code&gt; separator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Slade in Flame + Q&amp;amp;A with Noddy Holder
TO A LAND UNKNOWN + PRE-RECORDED Q&amp;amp;A
Goodbye to the Past + pre-recorded intro by Annette Insdorf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The solution is obvious: split on &lt;code&gt;+&lt;/code&gt; and take whatever's before it. Except — and this is where it gets awkward — some legitimate film titles contain a &lt;code&gt;+&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Romeo + Juliet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the actual title of the Baz Luhrmann film. Split naively and you'd search for "Romeo" and find nothing useful. So there's a corrections list that pre-empts the split:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Romeo + Juliet&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Romeo+Juliet&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Removing the spaces makes it invisible to the splitter, then it gets normalised back correctly downstream. It's a bit of a hack, but it does the job.&lt;/p&gt;

&lt;p&gt;The same logic applies to the &lt;code&gt;–&lt;/code&gt; and &lt;code&gt;/&lt;/code&gt; separators, which venues also use to attach event context. The pipeline strips what comes after the last separator — unless the result looks wrong, in which case there's probably a correction for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Presents" and Other Sneaky Prefixes
&lt;/h2&gt;

&lt;p&gt;Some patterns can't be handled with a fixed string list — there are too many variations. So instead we look for signal words to decide what information we can discard. If a title contains &lt;code&gt;presents:&lt;/code&gt;, for example, everything before &lt;code&gt;presents:&lt;/code&gt; is almost certainly not the film title:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ghibliotheque presents... Spirited Away
VHS Late Tapes Takeover: LCVA presents POUT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These get handled with a regex match: if &lt;code&gt;presents?:?&lt;/code&gt; appears mid-title, take whatever follows it.&lt;/p&gt;

&lt;p&gt;The same approach works for &lt;code&gt;premiere of:&lt;/code&gt;, &lt;code&gt;screening of:&lt;/code&gt;, &lt;code&gt;retrospective screening of:&lt;/code&gt;, and a handful of others. Each one is a named match rather than a blindly applied strip, so the code can be explicit about what it's doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Corrections List
&lt;/h2&gt;

&lt;p&gt;Even after removing known phrases and applying structural patterns, there are titles that are just wrong — or at least not in the form TMDB expects. That's where &lt;a href="https://github.com/clusterflick/scripts/blob/cc77f913c7c2db110362b4f532d076b794e09b03/common/normalize-title.js" rel="noopener noreferrer"&gt;&lt;code&gt;normalize-title.js&lt;/code&gt;&lt;/a&gt; comes in. It has a &lt;code&gt;corrections&lt;/code&gt; array with around 500 entries, covering everything from typos to venue-specific quirks to completely misnamed films.&lt;/p&gt;

&lt;p&gt;Some are straightforward spelling fixes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Carvaggio&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Caravaggio&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Seigfried&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Siegfried&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Labryinth&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Labyrinth&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Some are encoding artefacts or odd formatting choices:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;½&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; 1/2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Some are venues getting the actual film title wrong. The BFI listed a film as "Battleground" as a translation from the original Italian — the film is called "Battlefield":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Battleground + intro &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Battlefield + intro &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And then there are the genuinely weird ones. &lt;code&gt;MUPPET PUPPETS CHRISTMAS CAROL WORKSHOP &amp;amp; SING-ALONG&lt;/code&gt; — that's not a film, it's an event which includes a film.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;MUPPET PUPPETS CHRISTMAS CAROL WORKSHOP &amp;amp; SING-ALONG&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Muppet Christmas Carol&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With hindsight, this is the kind of thing I try to avoid - a one-off correction for a singluar event. This probably should have not had a correction applied and instead rely on failing over to the LLM for identification using matching hints.&lt;/p&gt;

&lt;p&gt;One entry I'm particularly fond of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;/^Dr&lt;/span&gt;&lt;span class="se"&gt;\.?&lt;/span&gt;&lt;span class="sr"&gt; Strangelove$/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Dr. Strangelove or: How I Learned to Stop Worrying and Love the Bomb&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because cinemas almost never write the full title, but having the full title makes it much more likely to match on a TMDB search.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Gets Stripped Last
&lt;/h2&gt;

&lt;p&gt;After the corrections and phrase removal, there's a final cleanup pass: diacritics get normalised, smart quotes become straight quotes, soft hyphens get removed, trailing punctuation goes, articles at the start (&lt;code&gt;the&lt;/code&gt;, &lt;code&gt;a&lt;/code&gt;) get stripped (in most cases, not all) so that &lt;code&gt;The Big Lebowski&lt;/code&gt; and &lt;code&gt;Big Lebowski&lt;/code&gt; match the same thing.&lt;/p&gt;

&lt;p&gt;Year suffixes in brackets like &lt;code&gt;(1971)&lt;/code&gt; are kept, because they're genuinely useful disambiguation — &lt;code&gt;Psycho (1960)&lt;/code&gt; is a different film from &lt;code&gt;Psycho (1998)&lt;/code&gt; (and you'll probably want to know which version you're about to watch 😉).&lt;/p&gt;

&lt;p&gt;There's also the theatre performance problem. Some venues list National Theatre Live and Royal Ballet screenings using the same listing format as regular films. &lt;code&gt;NT Live: Dr Strangelove&lt;/code&gt; isn't looking for a film called "Dr Strangelove" — it's looking for the NT Live broadcast of it. There's a whole separate setup for that which gets detected and normalised before this pipeline runs. But that's probably worth its own post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Perfect Is the Enemy of Good
&lt;/h2&gt;

&lt;p&gt;The list of corrections is never going to be finished. New venues bring new branding. Films get re-released with different title formats. Cinemas just spell things wrong.&lt;/p&gt;

&lt;p&gt;What the normalisation step needs to do is get &lt;em&gt;most&lt;/em&gt; titles into a clean enough state that the TMDB search returns the right film. The cases it misses — titles that are too ambiguous or too corrupted — fall through to the LLM matching stage, which can handle a messier input. That's the right place for those anyway: the normalisation step is supposed to be fast and cheap, not exhaustive.&lt;/p&gt;

&lt;p&gt;The test suite in &lt;a href="https://github.com/clusterflick/scripts/blob/cc77f913c7c2db110362b4f532d076b794e09b03/common/tests/normalize-title.test.js" rel="noopener noreferrer"&gt;&lt;code&gt;normalize-title.test.js&lt;/code&gt;&lt;/a&gt; keeps the list honest. Every correction and removable phrase is supposed to have a corresponding test case in &lt;a href="https://github.com/clusterflick/scripts/blob/cc77f913c7c2db110362b4f532d076b794e09b03/common/tests/test-titles.json" rel="noopener noreferrer"&gt;&lt;code&gt;test-titles.json&lt;/code&gt;&lt;/a&gt;, so there's a record of what each entry is for and a way to verify it doesn't break anything when the list changes. And it gets updated every day as new data comes in.&lt;/p&gt;

&lt;p&gt;It's not elegant. But the alternative — sending &lt;code&gt;BAR TRASH – THE ZODIAC KILLER (1971) at Beer Merchants Tap&lt;/code&gt; to TMDB and hoping for the best — doesn't work. And now you know why 🍿&lt;/p&gt;

&lt;p&gt;P.S. Shout out to &lt;a href="https://clusterflick.com/film-clubs/bar-trash/" rel="noopener noreferrer"&gt;Bar Trash&lt;/a&gt; for having some of the most consistent and standardised titles ❤️&lt;br&gt;
Those titles make for a great example in this blog post, but they're far from being the most complex ones I need to deal with!&lt;/p&gt;

&lt;p&gt;🎬 A list of the movies mentioned:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.themoviedb.org/movie/63959-the-zodiac-killer" rel="noopener noreferrer"&gt;The Zodiac Killer (1971)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.themoviedb.org/movie/128" rel="noopener noreferrer"&gt;Princess Mononoke (1997)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.themoviedb.org/movie/10437-the-muppet-christmas-carol" rel="noopener noreferrer"&gt;The Muppet Christmas Carol&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.themoviedb.org/movie/60808-flame" rel="noopener noreferrer"&gt;Flame (1975)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.themoviedb.org/movie/1214052" rel="noopener noreferrer"&gt;To a Land Unknown (2025)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.themoviedb.org/movie/242542-rozstanie" rel="noopener noreferrer"&gt;Goodbye to the Past (1961)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.themoviedb.org/movie/454-romeo-juliet" rel="noopener noreferrer"&gt;Romeo + Juliet (1996)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.themoviedb.org/movie/129" rel="noopener noreferrer"&gt;Spirited Away (2001)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.themoviedb.org/movie/1174246-campo-di-battaglia" rel="noopener noreferrer"&gt;Battlefield (2024)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.themoviedb.org/movie/935-dr-strangelove-or-how-i-learned-to-stop-worrying-and-love-the-bomb" rel="noopener noreferrer"&gt;Dr. Strangelove or: How I Learned to Stop Worrying and Love the Bomb (1964)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.themoviedb.org/movie/1401957-national-theatre-live-dr-strangelove" rel="noopener noreferrer"&gt;National Theatre Live: Dr. Strangelove (2025)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.themoviedb.org/movie/539-psycho" rel="noopener noreferrer"&gt;Psycho (1960)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.themoviedb.org/movie/11252-psycho" rel="noopener noreferrer"&gt;Psycho (1998)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Next post:&lt;/strong&gt; &lt;del&gt;Testing Your Prompts Like You Test Your Code&lt;/del&gt;&lt;br&gt;
Unfortunately I've not gotten this work completed. So until then, the next post will be &lt;em&gt;&lt;a href="https://dev.to/alistairjcbrown/the-raspberry-pi-cluster-in-my-living-room-6ik"&gt;The Raspberry Pi Cluster in My Living Room&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>datascience</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Site Performance: Loading 30,000 Showings in a Browser</title>
      <dc:creator>Alistair</dc:creator>
      <pubDate>Wed, 11 Mar 2026 08:47:00 +0000</pubDate>
      <link>https://dev.to/alistairjcbrown/site-performance-loading-30000-showings-in-a-browser-30go</link>
      <guid>https://dev.to/alistairjcbrown/site-performance-loading-30000-showings-in-a-browser-30go</guid>
      <description>&lt;p&gt;At least twice a day, the pipeline scrapes 250+ London cinemas and produces a dataset of 1,500+ films with 30,000+ showings. Then I need to get all of that into a browser.&lt;/p&gt;

&lt;p&gt;Getting the raw data from venues is its own challenge (&lt;a href="https://dev.to/alistairjcbrown/scaling-from-3-cinemas-to-240-venues-what-broke-and-what-evolved-2jkk"&gt;covered in an earlier post&lt;/a&gt;) but even once you've got it, making it available to users fast and in a useful way has its own set of problems to solve.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://clusterflick.com/" rel="noopener noreferrer"&gt;Clusterflick&lt;/a&gt; runs entirely as a static site, served from GitHub Pages with no live server. That's a deliberate constraint — the whole project runs on GitHub's free tier, and I'd like to keep it that way (more on that in a future post). But it means the browser has to do more of the work, and that puts performance decisions front and centre.&lt;/p&gt;

&lt;p&gt;By the time data reaches the frontend, it's already been through several pipeline stages — each one producing a GitHub release that the next stage picks up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/clusterflick/data-retrieved/" rel="noopener noreferrer"&gt;&lt;strong&gt;Retrieve:&lt;/strong&gt;&lt;/a&gt; raw HTML, JSON APIs, and scraped pages from all 252 venues

&lt;ul&gt;
&lt;li&gt;~800 MB total&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;

&lt;a href="//github.com/clusterflick/data-transformed/"&gt;&lt;strong&gt;Transform:&lt;/strong&gt;&lt;/a&gt; extracts structured showings from the raw data, matches films against &lt;a href="https://www.themoviedb.org/" rel="noopener noreferrer"&gt;TMDB&lt;/a&gt; and saves the ID of matches

&lt;ul&gt;
&lt;li&gt; down to ~15 MB total&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;

&lt;a href="https://github.com/clusterflick/data-combined/" rel="noopener noreferrer"&gt;&lt;strong&gt;Combine:&lt;/strong&gt;&lt;/a&gt; merges the films from all venues together and hydrates films that have a TMDB ID with rich metadata (cast, genres, poster images, ratings)

&lt;ul&gt;
&lt;li&gt;~18 MB total&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;

&lt;a href="https://github.com/clusterflick/clusterflick.com/blob/28ada56182d96a253e218133bcc5edcdd304cc64/scripts/process-combined-data.js" rel="noopener noreferrer"&gt;&lt;strong&gt;Process:&lt;/strong&gt;&lt;/a&gt; strips redundant data, extracts URL prefixes, splits into chunks

&lt;ul&gt;
&lt;li&gt;~5 MB raw, ~1.5 MB gzipped over the wire&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;This post is about the decisions in that last step (and one I unmade) getting from the combined JSON to something a browser can load and render quickly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1sl0v05k3t3nwispga3g.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1sl0v05k3t3nwispga3g.jpg" alt="Clusterflick main page" width="800" height="688"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Compression Detour
&lt;/h2&gt;

&lt;p&gt;Before building anything clever on the frontend, I wanted to be sure the raw data was as small as possible. I'd been running the JSON through &lt;a href="https://www.npmjs.com/package/compress-json" rel="noopener noreferrer"&gt;&lt;code&gt;compress-json&lt;/code&gt;&lt;/a&gt;, a library that structurally transforms JSON — deduplicating repeated values into lookup tables, encoding types differently. It made the raw file dramatically smaller. As an example, for one of the runs the full dataset without it is 10.97 MB; with it, 4.85 MB. That's a real reduction.&lt;/p&gt;

&lt;p&gt;So I ran a benchmark across every optimisation in the pipeline to see which ones were actually earning their place.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Optimisation&lt;/th&gt;
&lt;th&gt;Gzipped impact&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Removing showing overviews&lt;/td&gt;
&lt;td&gt;💪 -6.1% (saves 109 KB)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;URL prefix extraction&lt;/td&gt;
&lt;td&gt;💪 -5.0% (saves 90 KB)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Removing IDs&lt;/td&gt;
&lt;td&gt;💪 -2.4% (saves 43 KB)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Removing false a11y flags&lt;/td&gt;
&lt;td&gt;🤷 ~0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trimming RT data&lt;/td&gt;
&lt;td&gt;🤷 ~0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;compress-json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;😱 &lt;strong&gt;+18.5% (hurts!)&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The headline finding: &lt;code&gt;compress-json&lt;/code&gt; makes the gzipped output &lt;em&gt;larger&lt;/em&gt;. Without it, the gzipped total is 1.43 MB. With it, 1.76 MB. That's 333 KB I was paying to make things worse.&lt;/p&gt;

&lt;p&gt;The reason makes sense once you think about it. Gzip excels at finding repeated byte sequences — exactly what &lt;code&gt;compress-json&lt;/code&gt; was doing first. The two approaches fight each other: &lt;code&gt;compress-json&lt;/code&gt;'s transformed structure is actually &lt;em&gt;harder&lt;/em&gt; for gzip to compress than plain repetitive JSON. Gzip decompression is built into every browser's network stack — native C++ code that runs before JavaScript even sees the response. &lt;code&gt;compress-json&lt;/code&gt; decompression, by contrast, runs on the main thread in JavaScript. So the current pipeline was paying three times: larger transfer size, extra JS bundle weight for the decompress library, and CPU time running &lt;code&gt;decompress()&lt;/code&gt; on every chunk.&lt;/p&gt;

&lt;p&gt;So I deleted it. The "no compress-json" variant still has all the other optimisations applied and lands at 1.43 MB — 19% smaller than before. 🎉&lt;/p&gt;

&lt;p&gt;The two optimisations that turned out to have zero impact — removing false accessibility flags and trimming Rotten Tomatoes fields — were easy to rationalise after the fact. Accessibility data is sparse; very few performances have those flags set at all, so deleting &lt;code&gt;false&lt;/code&gt; values removes almost nothing. The RT fields are a handful of small values per movie. Neither gives gzip much to work with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Splitting the Data into Chunks
&lt;/h2&gt;

&lt;p&gt;Even at 1.43 MB gzipped, serving the full dataset as a single file would mean users wait for everything before seeing anything. Instead, as part of the data processing it's splits into chunks and a metadata file written alongside them.&lt;/p&gt;

&lt;p&gt;The chunking isn't by movie count — it's by &lt;strong&gt;serialised byte size&lt;/strong&gt;, with a target of ~400 KB per chunk. Chunking by movie count would produce wildly uneven file sizes; a blockbuster showing at 50+ venues generates far more data than a one-week indie run. Performance count was an earlier approach, but it still produced too much variance — chunk files ranged 65 KB - 1.2 MB. Switching to byte size brought that down to 16 KB - 727 KB, with the bulk of chunks clustering tightly between 324 KB and 436 KB.&lt;/p&gt;

&lt;p&gt;The remaining outliers are expected. The small tail chunks at the end of the alphabet simply don't have enough movies left to fill a full bucket. The large ones contain individual films whose serialised data alone exceeds the target — a blockbuster with 50+ venues and thousands of performances will do that — so they necessarily get a bucket to themselves.&lt;/p&gt;

&lt;p&gt;Movies are sorted alphabetically by &lt;em&gt;normalised&lt;/em&gt; title before being bucketed — mirroring the default sort order on the site. The idea is that we'll start downloading chunk 0 first, and it'll contain the movies a the top of the list which are visible on screen when the page first loads. So the data the user actually sees is most likely to arrive first and there's less change of visible updates as subsequent chunks load in below the fold.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;data.meta.a1b2c3d4e5.json
data.0.f6g7h8i9j0.json
data.1.k1l3m5n7o9.json
...
data.&amp;lt;index&amp;gt;.&amp;lt;fingerprint&amp;gt;.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F28x0o2opfub7tg5b9frp.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F28x0o2opfub7tg5b9frp.jpg" alt="Screenshot of the network web development tools showing data chunks loading in" width="800" height="297"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The metadata file carries the full lookup tables for genres, people, and venues (shared across all movies), the URL prefix table used to reconstruct booking links, and the &lt;code&gt;mapping&lt;/code&gt; that tells the client which chunk contains which movie ID. It's the one file the browser always fetches first — and it's hashed like the chunks, so its filename is baked into &lt;code&gt;NEXT_PUBLIC_DATA_FILENAME&lt;/code&gt; at build time.&lt;/p&gt;

&lt;p&gt;There's one catch with GitHub Pages: it sets a 10-minute cache TTL on everything at the browser level, which means even a fingerprinted file that hasn't changed for weeks gets revalidated every 10 minutes. Cloudflare sits in front of the site and fixes this in two ways: it caches the files at the edge, and it overrides GitHub's cache-control headers so browsers are told to store all JSON files for a year. Since every file — chunks and metadata alike — is fingerprinted, a changed file always means a new URL and a cache miss by design. A first-time visitor fetches from Cloudflare's edge and caches locally for a year. A repeat visitor gets it straight from their browser cache. Either way, they're only ever making a network request for files that have actually changed.&lt;/p&gt;




&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fc56350nj7nyjmzbrluvp.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fc56350nj7nyjmzbrluvp.jpg" alt="Cloudflare cache control rule for JSON files, storing them at an edge cache and setting the browser cache header to 1 year" width="800" height="780"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;Once the client has the metadata, &lt;a href="https://github.com/clusterflick/clusterflick.com/blob/b90ac8737b4aa032e8be35bf0bf572d44b03e30a/src/state/cinema-data-context.tsx#L204" rel="noopener noreferrer"&gt;&lt;code&gt;CinemaDataProvider&lt;/code&gt; handles the rest&lt;/a&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Priority chunk&lt;/strong&gt; — on a movie detail page, the client looks up the movie's chunk in the mapping and &lt;a href="https://github.com/clusterflick/clusterflick.com/blob/b90ac8737b4aa032e8be35bf0bf572d44b03e30a/src/state/cinema-data-context.tsx#L272-L275" rel="noopener noreferrer"&gt;fetches it immediately&lt;/a&gt;. Showings appear before the rest of the dataset has loaded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;All other chunks in parallel&lt;/strong&gt; — &lt;a href="https://github.com/clusterflick/clusterflick.com/blob/b90ac8737b4aa032e8be35bf0bf572d44b03e30a/src/state/cinema-data-context.tsx#L279-L285" rel="noopener noreferrer"&gt;via &lt;code&gt;Promise.allSettled()&lt;/code&gt;&lt;/a&gt;, so a single failed chunk doesn't block everything else from loading.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expand and prune&lt;/strong&gt; — IDs stripped before serialisation are re-added via &lt;code&gt;expandData()&lt;/code&gt; (restoring the keys that were removed to save bytes), and past performances are stripped before chunks enter React state.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Static Export Changes Everything
&lt;/h2&gt;

&lt;p&gt;Clusterflick uses &lt;a href="https://nextjs.org/" rel="noopener noreferrer"&gt;Next.js&lt;/a&gt; with &lt;code&gt;output: "export"&lt;/code&gt;. There's no live server. Every page is pre-rendered to static HTML during &lt;code&gt;npm run build&lt;/code&gt;, then served from GitHub Pages.&lt;/p&gt;

&lt;p&gt;This shapes every rendering decision. When Next.js docs talk about &lt;a href="https://nextjs.org/docs/app/getting-started/server-and-client-components" rel="noopener noreferrer"&gt;Server Components&lt;/a&gt;, in this context that means "code that runs at build time on a Node process" — not a server handling live requests. Whatever I pre-render is fixed until the next build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Grids on the Home Page
&lt;/h2&gt;

&lt;p&gt;The home page has a slightly odd architecture, and it's worth explaining why.&lt;/p&gt;

&lt;p&gt;At build time, &lt;a href="https://github.com/clusterflick/clusterflick.com/blob/b90ac8737b4aa032e8be35bf0bf572d44b03e30a/src/app/page.tsx" rel="noopener noreferrer"&gt;&lt;code&gt;app/page.tsx&lt;/code&gt;&lt;/a&gt; (a Server Component) reads the chunk files from disk, merges them, applies the default filters — films and shorts, 7-day window — and takes the first 72 results sorted by normalized title. These 72 movies are rendered as a static HTML grid of poster images and links. No JavaScript required. This grid is wrapped in an &lt;a href="https://github.com/clusterflick/clusterflick.com/blob/b90ac8737b4aa032e8be35bf0bf572d44b03e30a/src/app/ssr-only.tsx" rel="noopener noreferrer"&gt;&lt;code&gt;SSROnly&lt;/code&gt; component&lt;/a&gt; that removes itself after hydration.&lt;/p&gt;

&lt;p&gt;So during the initial paint, and for any crawler, there's a real grid of films with real titles and links in the HTML. Once JavaScript loads and mounts, &lt;code&gt;SSROnly&lt;/code&gt; cleans up that static content and hands off to the interactive grid.&lt;/p&gt;

&lt;p&gt;The 72 limit is deliberate. It's enough for a meaningful SEO payload — film titles, poster images, links — without bloating the HTML with hundreds of entries. The real, interactive grid that users actually browse is built entirely client-side with the full dataset, applying any filters which may be in effect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Virtualising 1,500+ Posters
&lt;/h2&gt;

&lt;p&gt;The filter UI is designed to give immediate visual feedback as you change options — in the current design the filter overlay is semi-transparent, so you can see the poster grid updating behind it as you adjust. That only works if rendering is fast. On an earlier design, where the filter controls sat directly above a flat list of results, the lag was obvious and painful: every filter change triggered a re-render of the entire list.&lt;/p&gt;

&lt;p&gt;The solution is &lt;a href="https://github.com/bvaughn/react-virtualized" rel="noopener noreferrer"&gt;&lt;code&gt;react-virtualized&lt;/code&gt;&lt;/a&gt; — specifically its &lt;code&gt;Grid&lt;/code&gt; component combined with &lt;code&gt;WindowScroller&lt;/code&gt;. Rather than rendering the full list, it calculates which cells are currently visible in the viewport and only renders those, plus a small buffer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;WindowScroller&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isScrolling&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;registerChild&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onChildScroll&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;scrollTop&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;registerChild&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Grid&lt;/span&gt;
        &lt;span class="na"&gt;autoHeight&lt;/span&gt;
        &lt;span class="na"&gt;cellRenderer&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;cellRenderer&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;columnCount&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;columnCount&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;columnWidth&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;POSTER_WIDTH&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;GAP&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// 208px per column&lt;/span&gt;
        &lt;span class="na"&gt;rowHeight&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;POSTER_HEIGHT&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;GAP&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;    &lt;span class="c1"&gt;// 308px per row&lt;/span&gt;
        &lt;span class="na"&gt;rowCount&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;rowCount&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;overscanRowCount&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;               &lt;span class="c1"&gt;// pre-render 3 rows above/below viewport&lt;/span&gt;
        &lt;span class="na"&gt;scrollTop&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;isScrolling&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isScrolling&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;onScroll&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;onChildScroll&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="err"&gt;...&lt;/span&gt;
      &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;WindowScroller&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;~ &lt;a href="https://github.com/clusterflick/clusterflick.com/blob/b90ac8737b4aa032e8be35bf0bf572d44b03e30a/src/app/page-content.tsx#L186-L213" rel="noopener noreferrer"&gt;&lt;code&gt;src/app/page-content.tsx&lt;/code&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;WindowScroller&lt;/code&gt; ties the grid's scroll position to the page's native scroll rather than creating a separate scrollable container. That keeps the browser scrollbar, avoids scroll-jank on mobile, and means the address bar hides naturally on iOS.&lt;/p&gt;

&lt;p&gt;Fixed cell dimensions (always 200×300px with an 8px gap) let react-virtualized calculate row and column positions with simple arithmetic, avoiding expensive DOM measurement. Window width isn't available at build time, so the component initialises with a single-column placeholder and sets real dimensions in a &lt;code&gt;useEffect&lt;/code&gt; after mount.&lt;/p&gt;

&lt;p&gt;The first two rows are above the fold on most screens, so &lt;code&gt;next/image&lt;/code&gt; is told to load those eagerly with &lt;code&gt;fetchpriority="high"&lt;/code&gt;. Everything below row 2 is lazy-loaded as the user scrolls.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwdwxo08asovdqvcx5317.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwdwxo08asovdqvcx5317.jpg" alt="Poster grid showing that only the visible posters are in the DOM" width="800" height="517"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One wrinkle: the intro section above the grid can be collapsed or expanded, which shifts the grid's offset on the page. &lt;code&gt;WindowScroller&lt;/code&gt; needs to know about this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispatchEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;resize&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A synthetic &lt;code&gt;resize&lt;/code&gt; event prompts &lt;code&gt;WindowScroller&lt;/code&gt; to recalculate its position. Not elegant, but it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Movie Detail Pages: Stripping Performances Before They Cross the Wire
&lt;/h2&gt;

&lt;p&gt;Each film has its own pre-rendered page. &lt;a href="https://github.com/clusterflick/clusterflick.com/blob/b90ac8737b4aa032e8be35bf0bf572d44b03e30a/src/app/movies/%5Bid%5D/%5Bslug%5D/page.tsx#L15-L22" rel="noopener noreferrer"&gt;&lt;code&gt;generateStaticParams()&lt;/code&gt;&lt;/a&gt; iterates every movie at build time and Next.js generates a static HTML file for each — typically 1,500+ pages per build.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/clusterflick/clusterflick.com/blob/b90ac8737b4aa032e8be35bf0bf572d44b03e30a/src/app/movies/%5Bid%5D/%5Bslug%5D/page.tsx#L223-L244" rel="noopener noreferrer"&gt;&lt;code&gt;app/movies/[id]/[slug]/page.tsx&lt;/code&gt; Server Component&lt;/a&gt; does the structurally stable work: resolves genres, people, and venues for the film; generates JSON-LD structured data (&lt;code&gt;Movie&lt;/code&gt;, &lt;code&gt;BreadcrumbList&lt;/code&gt;, &lt;code&gt;ScreeningEvent&lt;/code&gt;) for search engine rich results. Then — critically — it strips &lt;code&gt;performances&lt;/code&gt; from the movie prop before passing it to the client component:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;performances&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;_performances&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;movieWithoutPerformances&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;movie&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That means the pre-rendered HTML — and the inline JSON Next.js serialises into it for hydration — only contains movie metadata (title, poster, ratings, cast). The actual showtimes are fetched at runtime by the data context.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/clusterflick/clusterflick.com/blob/b90ac8737b4aa032e8be35bf0bf572d44b03e30a/src/app/movies/%5Bid%5D/%5Bslug%5D/page-content.tsx#L47-L54" rel="noopener noreferrer"&gt;&lt;code&gt;app/movies/[id]/[slug]/page-content.tsx&lt;/code&gt; Client Component&lt;/a&gt; calls &lt;a href="https://github.com/clusterflick/clusterflick.com/blob/b90ac8737b4aa032e8be35bf0bf572d44b03e30a/src/app/movies/%5Bid%5D/%5Bslug%5D/page-content.tsx#L81" rel="noopener noreferrer"&gt;&lt;code&gt;getDataWithPriority(movie.id)&lt;/code&gt;&lt;/a&gt; on mount, which fetches the chunk containing &lt;em&gt;this&lt;/em&gt; film first before loading everything else in parallel. A &lt;code&gt;startTransition&lt;/code&gt; defers the showings computation until after the hero section has rendered — so the poster, title, and ratings appear immediately, with showtimes filling in shortly after.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzsr87rxvaeiwtbqbl5i0.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzsr87rxvaeiwtbqbl5i0.gif" alt="Animation showing performancing loading in after the main page content on the Project Hail Mary movie page" width="1240" height="1071"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where It Stands
&lt;/h2&gt;

&lt;p&gt;With all of this in place, I ran Lighthouse against the site across cold and warm cache — averaged over three runs on desktop.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Cold cache&lt;/th&gt;
&lt;th&gt;Warm cache&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lighthouse score&lt;/td&gt;
&lt;td&gt;74/100&lt;/td&gt;
&lt;td&gt;92/100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First Contentful Paint&lt;/td&gt;
&lt;td&gt;459ms&lt;/td&gt;
&lt;td&gt;23ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Largest Contentful Paint&lt;/td&gt;
&lt;td&gt;2.5s&lt;/td&gt;
&lt;td&gt;281ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speed Index&lt;/td&gt;
&lt;td&gt;2.5s&lt;/td&gt;
&lt;td&gt;42ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cumulative Layout Shift&lt;/td&gt;
&lt;td&gt;0.197&lt;/td&gt;
&lt;td&gt;0.18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transfer size&lt;/td&gt;
&lt;td&gt;5.5 MB&lt;/td&gt;
&lt;td&gt;20 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feb17g0ij4td50ozmwq6g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feb17g0ij4td50ozmwq6g.png" alt="Screenshot of the CLI output which has the same information as the above table" width="800" height="443"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The warm cache numbers are the point of everything in this post — 308 of 336 network requests served from cache, 5.5 MB down to 20 KB (less than 1% of the data going across the wire), LCP dropping from 2.5s to 281ms (about 10% of the original time). That's what content-hashed files plus a year-long browser TTL actually buys you.&lt;/p&gt;

&lt;p&gt;Cold cache is where there's still work to do. A 74/100 and a 2.5s LCP on first visit isn't bad, but it's not where I'd like it to be. The LCP is the main thing to improve — 2.5s is right at the edge of Google's "needs improvement" threshold, and it's what's dragging the cold cache score down. The CLS (0.197) is a known trade-off from the SSR grid handing off to the virtualised interactive grid, but given warm cache sits at 0.18 and still scores 92/100, it's clearly not the bottleneck.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Next post:&lt;/strong&gt; &lt;a href="https://dev.to/alistairjcbrown/cleaning-cinema-titles-before-you-can-even-search-1463"&gt;Cleaning Cinema Titles Before You Can Even Search&lt;/a&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>frontend</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A Brief Detour: Two Writing Challenges and What Came Out of Them</title>
      <dc:creator>Alistair</dc:creator>
      <pubDate>Wed, 04 Mar 2026 08:30:00 +0000</pubDate>
      <link>https://dev.to/alistairjcbrown/a-brief-detour-two-writing-challenges-and-what-came-out-of-them-4h8h</link>
      <guid>https://dev.to/alistairjcbrown/a-brief-detour-two-writing-challenges-and-what-came-out-of-them-4h8h</guid>
      <description>&lt;p&gt;Regular Clusterflick series readers: I got distracted. Twice 😅&lt;/p&gt;

&lt;p&gt;In the last week I entered a couple of dev.to writing challenges, and both turned out to be good excuses to write about things that were already on the series roadmap — just earlier and in a slightly different shape than I'd originally planned.&lt;/p&gt;

&lt;p&gt;The first was the 1️⃣ &lt;a href="https://dev.to/challenges/weekend-2026-02-28"&gt;DEV Weekend Challenge: Community&lt;/a&gt;, which I used to write about the &lt;a href="https://clusterflick.com/film-clubs/" rel="noopener noreferrer"&gt;film club discovery&lt;/a&gt; and &lt;a href="https://clusterflick.com/near-me/" rel="noopener noreferrer"&gt;"near me"&lt;/a&gt; features, which I finally took the time to build. The second was the 2️⃣ &lt;a href="https://dev.to/challenges/mlh/built-with-google-gemini-02-25-26"&gt;Built with Google Gemini: Writing Challenge&lt;/a&gt;, which pulled forward what was going to be a later post about using LLMs in the data pipeline.&lt;/p&gt;

&lt;p&gt;Both are standalone submissions, but they're very much part of this project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/alistairjcbrown/i-built-a-film-club-discovery-tool-for-londons-cinema-community-2md"&gt;Making London's hidden film clubs discoverable&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/alistairjcbrown/three-things-i-learned-using-llms-in-a-data-pipeline-51c3"&gt;Three Things I Learned Using LLMs in a Data Pipeline&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The LLM post in particular covers things I'd have gotten to eventually in this series — the matching pipeline, the &lt;code&gt;reason&lt;/code&gt; key trick, defensive JSON parsing. Worth a read if you've been following along!&lt;/p&gt;

&lt;p&gt;Back to the regular schedule next week 🫡&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Next post:&lt;/strong&gt; &lt;a href="https://dev.to/alistairjcbrown/site-performance-loading-30000-showings-in-a-browser-30go"&gt;Site Performance: Loading 30,000+ Showings in a Browser&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>ai</category>
      <category>opensource</category>
      <category>clusterflick</category>
    </item>
    <item>
      <title>Three Things I Learned Using LLMs in a Data Pipeline</title>
      <dc:creator>Alistair</dc:creator>
      <pubDate>Mon, 02 Mar 2026 19:44:27 +0000</pubDate>
      <link>https://dev.to/alistairjcbrown/three-things-i-learned-using-llms-in-a-data-pipeline-51c3</link>
      <guid>https://dev.to/alistairjcbrown/three-things-i-learned-using-llms-in-a-data-pipeline-51c3</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/mlh-built-with-google-gemini-02-25-26"&gt;Built with Google Gemini: Writing Challenge&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built with Google Gemini
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;"Ghibliotheque Presents: My Neighbor Totoro + Intro"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That's a real cinema listing title, but it's not a title you can just search for. And as titles go, it's one of the more straightforward ones. Things get even messier when we get into cinema listing pages. I've seen venues that don't include a year, don't include the director, or give you little more than a title and a one-line description. If you're building an aggregator that needs to identify what's actually showing, you spend a lot of time staring at strings like this.&lt;/p&gt;

&lt;p&gt;I've been building &lt;a href="https://clusterflick.com" rel="noopener noreferrer"&gt;Clusterflick&lt;/a&gt;, a cinema aggregator for London that pulls listings from 250+ venues daily. I thought scraping would be the hard part. But figuring out what a listing actually &lt;em&gt;is&lt;/em&gt; — which film, matched to which entry in &lt;a href="https://themoviedb.org" rel="noopener noreferrer"&gt;The Movie DB&lt;/a&gt; — is where a lot of complexity lies. And it's where I've been using Gemini.&lt;/p&gt;

&lt;p&gt;There's a whole layer of work involved in cleaning raw listing strings down to something searchable — that's worth a post of its own — but even with a clean title, the matching problem doesn't go away. Many venues don't include the necessary information to programatically search using The Movie DB API — just a title and maybe a vague description. Even when they do have more data, e.g. title plus year or even title plus director, it doesn't necessarily uniquely identify a film. And legitimate films with short or common names can be difficult to surface in TMDB search results at all.&lt;/p&gt;

&lt;p&gt;I use Gemini to help at four stages in the identification pipeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Match against TMDB&lt;/strong&gt; — given the cinema listing and a list of search results from TMDB, Gemini picks the best match. This handles the majority of cases.

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/clusterflick/scripts/blob/b0d0954749836c5ab4ad3c685811fbdf28410340/common/ask-llm-to-review-results.js" rel="noopener noreferrer"&gt;&lt;code&gt;common/ask-llm-to-review-results.js&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Direct identification&lt;/strong&gt; — if TMDB search returns nothing useful, I ask Gemini if it recognises the film from the listing alone. Its training data often knows about films that don't surface well through search.

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/clusterflick/scripts/blob/b0d0954749836c5ab4ad3c685811fbdf28410340/common/ask-llm.js" rel="noopener noreferrer"&gt;&lt;code&gt;common/ask-llm.js&lt;/code&gt;&lt;/a&gt; (The original use of Gemini in the project — everything else has grown from this first step)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Classify the listing&lt;/strong&gt; — if we still can't identify a film, I ask Gemini what the listing actually &lt;em&gt;is&lt;/em&gt;: a film, a short, a double bill, a quiz night, a live event, a comedy show. That classification feeds into filters on the website, and it determines what happens next in the pipeline — a listing classified as multiple films or shorts triggers its own follow-up steps.

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/clusterflick/scripts/blob/b0d0954749836c5ab4ad3c685811fbdf28410340/common/ask-llm-to-categorise.js" rel="noopener noreferrer"&gt;&lt;code&gt;common/ask-llm-to-categorise.js&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extract multiple films or shorts&lt;/strong&gt; — if a listing is identified as containing multiple films or shorts (a double bill, a shorts programme, a marathon), I ask Gemini to pull out the individual titles so each can be matched separately.

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/clusterflick/scripts/blob/b0d0954749836c5ab4ad3c685811fbdf28410340/scripts/transform/identify-multiple-movies.js" rel="noopener noreferrer"&gt;&lt;code&gt;scripts/transform/identify-multiple-movies.js&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/clusterflick/scripts/blob/b0d0954749836c5ab4ad3c685811fbdf28410340/scripts/transform/identify-shorts.js" rel="noopener noreferrer"&gt;&lt;code&gt;scripts/transform/identify-shorts.js&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each stage only fires if the previous one didn't produce a result. That keeps costs down and means Gemini is only doing the hard work when simpler approaches have already failed.&lt;/p&gt;

&lt;p&gt;The model I'm using is &lt;code&gt;gemini-2.5-flash-lite&lt;/code&gt;. I'd been running on &lt;code&gt;gemini-2.0-flash&lt;/code&gt; for a while and recently upgraded — one line change in the code, and I saw no noticeable difference in the identification and categorisation output from the previous run. Free performance improvement!&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;Clusterflick is live at &lt;a href="https://clusterflick.com" rel="noopener noreferrer"&gt;clusterflick.com&lt;/a&gt; — 250+ venues and thousands of films across London, updated daily.&lt;/p&gt;

&lt;p&gt;The pipeline code is open source (&lt;a href="https://github.com/clusterflick/scripts" rel="noopener noreferrer"&gt;github.com/clusterflick/scripts&lt;/a&gt;), and runs across GitHub's cloud runners and &lt;a href="https://dev.to/alistairjcbrown/scaling-from-3-cinemas-to-240-venues-what-broke-and-what-evolved-2jkk"&gt;a cluster of 6 Raspberry Pis in my living room&lt;/a&gt; — so if the judges are looking for a good home for that prize, I have a shelf ready! 🍿&lt;/p&gt;

&lt;p&gt;The parsing layer discussed below is in &lt;a href="https://github.com/clusterflick/scripts/blob/b0d0954749836c5ab4ad3c685811fbdf28410340/common/llm-client.js" rel="noopener noreferrer"&gt;llm-client.js&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Asking for a reason made the model more honest
&lt;/h3&gt;

&lt;p&gt;When I first started asking Gemini to match listings to TMDB results, I was asking it to return a match and a confidence score (I use 0–9). It worked, but I was getting too many confident wrong answers — the model would pick something and report high confidence even when it was clearly a stretch.&lt;/p&gt;

&lt;p&gt;The fix was adding a &lt;code&gt;reason&lt;/code&gt; key to the expected JSON response. Forcing the model to articulate &lt;em&gt;why&lt;/em&gt; it had chosen a match made it noticeably more cautious. It's like the difference between someone blurting out an answer and someone having to show their working. The false positives dropped.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Listing matched description of magical forest spirits and animation style"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"match"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8392&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I now apply the same pattern wherever I need the model to make a judgement call. Structured output with a reason field is the single most effective prompt change I've made.&lt;/p&gt;

&lt;h3&gt;
  
  
  Using Gemini to improve my prompts
&lt;/h3&gt;

&lt;p&gt;At some point I realised I was spending more time tweaking prompts than writing actual pipeline code. So I started asking Gemini to critique and rewrite them for me.&lt;/p&gt;

&lt;p&gt;It sounds circular, but it works. The model is better than I am at structuring instructions for itself — clearer constraints, better edge case handling, more consistent output. Now when a prompt isn't giving me the results I want, my first step is to paste it into a fresh conversation and ask the model what's wrong with it and how it would rewrite it.&lt;/p&gt;

&lt;p&gt;The results are often prompts I wouldn't have written myself. More explicit about edge cases. Better at specifying output format. And because the model wrote them, they tend to produce more predictable responses.&lt;/p&gt;

&lt;h3&gt;
  
  
  Defensive parsing is non-negotiable
&lt;/h3&gt;

&lt;p&gt;Even with well-crafted prompts, LLM output in production will occasionally be malformed. I found this out when the model truncated a film overview mid-sentence and left a trailing backslash — one bad character broke &lt;code&gt;JSON.parse&lt;/code&gt; and failed the entire job.&lt;/p&gt;

&lt;p&gt;The longer the pipeline ran, the more edge cases surfaced. The model occasionally hallucinates fields that aren't in the schema (&lt;code&gt;backdrop_path&lt;/code&gt; appearing uninvited was a fun one). It sometimes leaves unescaped quotes inside string values. Markdown code fences show up often enough that stripping them became standard. Each of these is now a line in the sanitisation layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chatSession&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Unwrap the string if it's been wrapped in a markdown block&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;jsonString&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;`json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;correctedJsonString&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonString&lt;/span&gt;
  &lt;span class="c1"&gt;// Apply corrections for malformed escape characters (perhaps due to truncation)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\\(?![&lt;/span&gt;&lt;span class="sr"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\\/&lt;/span&gt;&lt;span class="sr"&gt;bfnrtu&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;|u&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-fA-F&lt;/span&gt;&lt;span class="se"&gt;]{4})&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// Apply corrections for hallucinated invalid additions&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/"backdrop_path": "&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;,&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+,&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// Fix unescaped quotes within the "reason" field value&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sr"&gt;/"reason"&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*:&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*"&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;.*&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;([&lt;/span&gt;&lt;span class="sr"&gt;,}&lt;/span&gt;&lt;span class="se"&gt;])&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_match&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reasonContent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;terminator&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fixed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;reasonContent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(?&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;!&lt;/span&gt;&lt;span class="se"&gt;\\)&lt;/span&gt;&lt;span class="sr"&gt;"/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`"reason":"&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;terminator&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;correctedJsonString&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Error parsing LLM answer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;--- Original response: -----------------------&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;--- Corrected response: ----------------------&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;correctedJsonString&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every line in there is a real production issue. Treat LLM responses as untrusted input, sanitise before you parse, and log both the original and corrected response when things go wrong — you'll want that context when debugging.&lt;/p&gt;

&lt;h2&gt;
  
  
  Google Gemini Feedback
&lt;/h2&gt;

&lt;p&gt;Flash-lite has been reliable and cheap, which matters when you're running a pipeline daily across hundreds of venues and thousands of films. Cost has stayed predictable as the number of venues has grown, which is exactly what I needed.&lt;/p&gt;

&lt;p&gt;One deliberate choice worth mentioning: I run with &lt;code&gt;temperature: 0&lt;/code&gt;. This is a data pipeline, not a creative writing tool — I want output as close to deterministic and consistent as possible.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;generationConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;topP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;topK&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;maxOutputTokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8192&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The upgrade from 2.0 to 2.5 was painless — one line change, no prompt tuning needed. To confirm nothing had shifted, I ran the pipeline twice with each model version and compared the transformed output. No noticeable differences for any venues. That kind of stability is worth a lot in production.&lt;/p&gt;

&lt;p&gt;The main frustration I haven't fully solved is flip-flopping. The pipeline runs daily, and occasionally a listing that was confidently matched to film X on one run comes back as film Y the next. The confidence is right on the edge either way — only one can be right, or both can be wrong — and &lt;code&gt;temperature: 0&lt;/code&gt; helps but doesn't eliminate it. I'd love better signalling when the model is genuinely on the fence, rather than having to infer uncertainty from a confidence score that turns out not to be reliable enough to always act on.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>geminireflections</category>
      <category>gemini</category>
    </item>
    <item>
      <title>Making London's hidden film clubs discoverable</title>
      <dc:creator>Alistair</dc:creator>
      <pubDate>Sun, 01 Mar 2026 11:50:52 +0000</pubDate>
      <link>https://dev.to/alistairjcbrown/i-built-a-film-club-discovery-tool-for-londons-cinema-community-2md</link>
      <guid>https://dev.to/alistairjcbrown/i-built-a-film-club-discovery-tool-for-londons-cinema-community-2md</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/weekend-2026-02-28"&gt;DEV Weekend Challenge: Community&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Community
&lt;/h2&gt;

&lt;p&gt;I've spent the last year building &lt;a href="https://clusterflick.com" rel="noopener noreferrer"&gt;Clusterflick&lt;/a&gt; — a site that pulls together cinema listings from across London so you can see everything showing, everywhere, without jumping between a dozen different websites. It started as a personal itch: I just wanted to know what was on (for the backstory, &lt;a href="https://dev.to/alistairjcbrown/building-clusterflick-a-london-cinema-aggregator-kk3"&gt;see my intro post&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;But the more I used it, the more I realised I was only solving half the problem. I could tell you &lt;em&gt;what&lt;/em&gt; was showing at &lt;em&gt;which venue&lt;/em&gt; — but I couldn't tell you if the screening was part of a &lt;strong&gt;film club&lt;/strong&gt;, whether the club screenings were accessible, or even that the club existed at all. London has a genuinely brilliant film club scene: community cinemas, genre nights, archive screenings, disability-led clubs. Most of them are invisible unless you already know to look for them.&lt;/p&gt;

&lt;p&gt;That felt wrong. These communities deserve better than a buried events page most people never find.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Two new features, both aimed at making London's film club community more discoverable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Film Club Pages
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://clusterflick.com/film-clubs" rel="noopener noreferrer"&gt;clusterflick.com/film-clubs&lt;/a&gt; gives each film club its own dedicated page. Each page shows their logo, a short description of who they are and what they programme, links back to their own site, and — crucially — pulls together their full upcoming lineup across &lt;em&gt;all&lt;/em&gt; the venues they screen at. A lot of clubs move around; they're not tied to a single cinema. Clusterflick now reflects that.&lt;/p&gt;

&lt;p&gt;To give a sense of the range:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://clusterflick.com/film-clubs/bar-trash/" rel="noopener noreferrer"&gt;Bar Trash&lt;/a&gt; programmes cult and curiosity films for people who've exhausted the mainstream;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://clusterflick.com/film-clubs/pitchblack-playback/" rel="noopener noreferrer"&gt;Pitchblack Playback&lt;/a&gt; runs immersive listening sessions in the dark, using cinema sound systems the way most people never get to hear them;&lt;/li&gt;
&lt;li&gt;and &lt;a href="https://clusterflick.com/film-clubs/lost-reels/" rel="noopener noreferrer"&gt;Lost Reels&lt;/a&gt; specialises in bringing forgotten, lost, or otherwise unavailable films back to UK screens.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three very different clubs, all doing something you won't find on a standard listings site, and all working across multiple venues.&lt;/p&gt;

&lt;p&gt;I also included accessibility information on each club page, surfaced directly from the screening data. If a club regularly programmes relaxed screenings or subtitled showings, that's highlighted. It shouldn't take three clicks to find out whether a club is somewhere you can actually go.&lt;/p&gt;

&lt;h3&gt;
  
  
  Near Me
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://clusterflick.com/near-me" rel="noopener noreferrer"&gt;clusterflick.com/near-me&lt;/a&gt; uses the browser's location API to show you what's geographically closest to wherever you are right now — venues, films showing there, and the film clubs attached to those screenings. It's not trying to be Google Maps. The goal is simpler: give someone a starting point. "What's on near me tonight?" is one of the most natural questions in the world, and it's surprisingly hard to answer if you don't already know which cinemas are in your area. And alongside "what's on near me?", it now also answers "what film clubs are near me?" — surfacing the clubs connected to those local venues.&lt;/p&gt;

&lt;p&gt;Together, these two features turn Clusterflick from a listings aggregator into something closer to a community directory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;Both features are live now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🎬 Film clubs: &lt;a href="https://clusterflick.com/film-clubs" rel="noopener noreferrer"&gt;clusterflick.com/film-clubs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📍 Near me: &lt;a href="https://clusterflick.com/near-me" rel="noopener noreferrer"&gt;clusterflick.com/near-me&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;

  &lt;iframe src="https://www.youtube.com/embed/9Kc8_OBBwic"&gt;
  &lt;/iframe&gt;


&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqmsizp1bnltbiqvvx6o7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqmsizp1bnltbiqvvx6o7.png" alt="Bar Trash Film Club page on Clusterflick"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi3uxpswcihzuikeqsexz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi3uxpswcihzuikeqsexz.png" alt="Near You page on Clusterflick, showing Film Clubs in Hackney"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;

&lt;/p&gt;
&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/clusterflick" rel="noopener noreferrer"&gt;
        clusterflick
      &lt;/a&gt; / &lt;a href="https://github.com/clusterflick/clusterflick.com" rel="noopener noreferrer"&gt;
        clusterflick.com
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Code for the clusterflick website
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Clusterflick&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://clusterflick.com" rel="nofollow noopener noreferrer"&gt;clusterflick.com&lt;/a&gt;&lt;/strong&gt; · &lt;strong&gt;&lt;a href="https://main--6984c607d80835bfe88c8309.chromatic.com" rel="nofollow noopener noreferrer"&gt;Storybook (Chromatic)&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Every film, every cinema, one place.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Clusterflick is an open-source web app that aggregates film screenings from
across London cinemas into a single, searchable interface. Compare screenings
find showtimes, and discover what's on — whether you're chasing new releases or
cult classics.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Features&lt;/h2&gt;
&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unified Cinema Listings&lt;/strong&gt; — Browse film screenings from 250+ London cinemas
in one place&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rich Movie Data&lt;/strong&gt; — View ratings and reviews from IMDb, Letterboxd
Metacritic, and Rotten Tomatoes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiple Event Types&lt;/strong&gt; — Find movies, TV screenings, comedy, music events,
talks, workshops, and more&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Venues &amp;amp; Boroughs&lt;/strong&gt; — Browse all cinemas by venue or explore all 33 London
boroughs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Festival Pages&lt;/strong&gt; — Dedicated pages for London film festivals with full
programme listings&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accessibility Filters&lt;/strong&gt; — Filter by audio description, subtitles, hard of
hearing support, relaxed screenings, and baby-friendly showings&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Geolocation&lt;/strong&gt; — Sort venues by distance from your current location&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shareable Filters&lt;/strong&gt; —…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/clusterflick/clusterflick.com" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;




&lt;p&gt;And the data pipeline that feeds the cinema data the site relies on is here: &lt;a href="https://github.com/clusterflick/data-combined" rel="noopener noreferrer"&gt;github.com/clusterflick/data-combined&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;The site is built with &lt;a href="https://nextjs.org/" rel="noopener noreferrer"&gt;Next.js&lt;/a&gt; and TypeScript, hosted on GitHub Pages. The film club pages are server-side rendered — all the data is known ahead of time, so they can be fully built at deploy. Near Me is the opposite: since it depends on the user's location, there's nothing to pre-render. The venue and screening data loads client-side, and the results appear once both that data and the user's location are available.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Near Me&lt;/strong&gt; logic is straightforward in principle: grab the user's coordinates from the browser Location API, load the cinema location data from the data pipeline, calculate distances, sort, render. The trickier part was deciding what "near" means when you're in London. After some trial and error, 2 miles turned out to be the sweet spot — enough to surface a decent set of options without stretching the definition of "nearby" too far.&lt;/p&gt;

&lt;p&gt;For the &lt;strong&gt;film club pages&lt;/strong&gt;, the main work was research and curation. I used Claude to help with the initial research pass — pulling together descriptions, verifying club details, and drafting copy — then reviewed and edited everything manually. The club-to-screening relationships come from the data pipeline, which already tags screenings with their organiser where that data is available. In the end I've added 22 clubs to the system, and over time I'll continue to add more.&lt;/p&gt;

&lt;p&gt;CI/CD runs via GitHub Actions. The data pipeline runs twice a day, and the site rebuilds automatically each time it finishes — so listings stay fresh without any manual intervention. I can also kick off a deployment manually when there are site updates to ship.&lt;/p&gt;

&lt;p&gt;This has been &lt;a href="https://github.com/orgs/clusterflick/projects/3/views/1" rel="noopener noreferrer"&gt;sitting in my GitHub issues&lt;/a&gt; for the last few months — five separate issues, all variations on the same ask; "what's nearby?" and "how do I find film clubs?". I kept kicking them down the road. This weekend challenge was the forcing function I needed to actually ship them. 🎉&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>showdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Getting the Data Model Right: Movie -&gt; Showings -&gt; Performances</title>
      <dc:creator>Alistair</dc:creator>
      <pubDate>Wed, 25 Feb 2026 08:47:00 +0000</pubDate>
      <link>https://dev.to/alistairjcbrown/getting-the-data-model-right-movie-showings-performances-25pm</link>
      <guid>https://dev.to/alistairjcbrown/getting-the-data-model-right-movie-showings-performances-25pm</guid>
      <description>&lt;p&gt;When I started building cinema aggregation tooling — pulling listings from multiple independent cinemas — the first real decision was the data model. I've fought bad schemas before. So I sat with this one for a while before writing any code.&lt;/p&gt;

&lt;p&gt;The hierarchy I landed on is &lt;strong&gt;Movie → Showings → Performances&lt;/strong&gt;, and while it might sound over-engineered at first glance, every layer earns its place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just Movie → Performances?
&lt;/h2&gt;

&lt;p&gt;My first schema was essentially flat. A movie had a title, some overview metadata (directors, actors, duration), and an array of performances — times you could go and see it. Simple enough, and it worked fine when I was dealing with a single cinema's listings.&lt;/p&gt;

&lt;p&gt;But a cinema doesn't just &lt;em&gt;show a film&lt;/em&gt;. It shows &lt;strong&gt;variants&lt;/strong&gt; of a screening. Take &lt;a href="https://clusterflick.com/venues/hackney-picturehouse/" rel="noopener noreferrer"&gt;Hackney Picturehouse&lt;/a&gt;'s 40th anniversary run of &lt;em&gt;&lt;a href="https://letterboxd.com/film/labyrinth/" rel="noopener noreferrer"&gt;Labyrinth&lt;/a&gt;&lt;/em&gt;. They didn't just list it once with a bunch of times — they had regular showings, a "Kids' Club" baby-friendly screening, and a "Relaxed Screening" for folks needing additional support, including neurodivergent audiences and those living with dementia. These aren't just different times — they're fundamentally different experiences, each with their own listing page, their own description, and their own set of performance slots.&lt;/p&gt;

&lt;p&gt;That middle layer — the &lt;strong&gt;Showing&lt;/strong&gt; — captures this. A Showing represents one cinema's particular presentation of a movie. It carries the variant-specific context: the URL for that listing, any notes about what makes it different, and its own array of performances underneath. Hackney Picturehouse's &lt;em&gt;Labyrinth&lt;/em&gt; becomes three Showings, each with their own performances — rather than one flat list of times where you have to squint at freetext notes to figure out which screening is which.&lt;/p&gt;

&lt;h2&gt;
  
  
  The original schema
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/alistairjcbrown/hackney-cinema-calendar/blob/main/schema.json" rel="noopener noreferrer"&gt;The first version of my transform schema&lt;/a&gt; — the contract that each cinema's scraper had to produce — looked roughly like this: a flat array of objects, each with a &lt;code&gt;title&lt;/code&gt;, a &lt;code&gt;url&lt;/code&gt;, an &lt;code&gt;overview&lt;/code&gt; block of metadata, and an array of &lt;code&gt;performances&lt;/code&gt;. Each performance had a &lt;code&gt;time&lt;/code&gt;, optional &lt;code&gt;screen&lt;/code&gt;, freetext &lt;code&gt;notes&lt;/code&gt;, and a &lt;code&gt;bookingUrl&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It got the job done for a single venue. But it was doing too much in too few layers. The "notes" field on each performance was carrying all the variant information as unstructured text. Categories lived in the overview, but there was no way to distinguish between a film, a live comedy night, and a quiz. Duration was required, which made sense &lt;a href="https://dev.to/alistairjcbrown/calendar-feeds-where-it-all-started-27o2"&gt;when we were only generating calendar events&lt;/a&gt;, but caused problems when the data was missing. And there was no hook for enriching the data with external sources.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;p&gt;The evolved schema introduces several things the original couldn't support cleanly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A &lt;code&gt;showingId&lt;/code&gt;&lt;/strong&gt; gives each showing a stable identity. This matters when you're deduplicating across sources or tracking what's changed between scrapes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A &lt;code&gt;category&lt;/code&gt; enum&lt;/strong&gt; (&lt;code&gt;movie&lt;/code&gt;, &lt;code&gt;tv&lt;/code&gt;, &lt;code&gt;quiz&lt;/code&gt;, &lt;code&gt;comedy&lt;/code&gt;, &lt;code&gt;music&lt;/code&gt;, &lt;code&gt;talk&lt;/code&gt;, &lt;code&gt;workshop&lt;/code&gt;, &lt;code&gt;shorts&lt;/code&gt;, &lt;code&gt;event&lt;/code&gt;) acknowledges that modern independent cinemas are not just cinemas. They host all kinds of events, and your data model needs to represent that without shoehorning everything into a film-shaped hole. It also set the scene for going beyond cinemas to any venue that screens films and might have other interesting events.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structured accessibility data&lt;/strong&gt; at the performance level replaces freetext notes for things like audio description, baby-friendly screenings, hard-of-hearing support, relaxed sessions, and subtitles. This is crucial — accessibility isn't a property of the movie, or even the showing. It's a property of &lt;em&gt;that specific screening at that specific time&lt;/em&gt;. A Tuesday afternoon showing might be relaxed; the Saturday evening one isn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A &lt;code&gt;status&lt;/code&gt; object&lt;/strong&gt; on each performance captures things like whether it's sold out. Again, this is inherently performance-level data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;External enrichment fields&lt;/strong&gt; — &lt;code&gt;themoviedb&lt;/code&gt; and &lt;code&gt;themoviedbs&lt;/code&gt; (plural) — provide the hook for hydrating listings with data from TMDB. The singular version covers standard films; the plural handles double bills or curated screening programmes where a single showing maps to multiple movies.&lt;/p&gt;

&lt;p&gt;And several small refinements: &lt;code&gt;duration&lt;/code&gt; is no longer required (because a quiz night doesn't have a runtime), &lt;code&gt;year&lt;/code&gt; was added to the overview, &lt;code&gt;classification&lt;/code&gt; replaced the awkwardly-named &lt;code&gt;age-restriction&lt;/code&gt;, and &lt;code&gt;additionalProperties: false&lt;/code&gt; was added throughout the schema to keep the data tight when validating.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr52o45anpk4k4u60qtv4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr52o45anpk4k4u60qtv4.png" alt="Entity relationship style diagram of the final transform schema" width="800" height="964"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it gets interesting: combining venues
&lt;/h2&gt;

&lt;p&gt;The transform schema represents what comes out of a single venue's scrape. Each cinema produces its own array of showings. But the aggregation site needs to combine these into a unified view: one movie, with showings from multiple cinemas, each with their own performances.&lt;/p&gt;

&lt;p&gt;This is where the hierarchy really pays off. The Movie → Showings → Performances structure scales naturally from single-venue to multi-venue. You don't need to restructure anything — you just group showings under a shared movie identity.&lt;/p&gt;

&lt;p&gt;But combining also means deduplicating, and that's where things get nuanced. When the same movie appears at three different cinemas, you'll have overlapping metadata at different levels:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Director and cast info&lt;/strong&gt; might exist in the showing-level overview (scraped from the cinema's own listing) &lt;em&gt;and&lt;/em&gt; at the movie level (from TMDB). Which do you trust? Usually the external source is more reliable and complete, but not always — a cinema might list a special guest or a different cut.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accessibility information&lt;/strong&gt; is firmly performance-level. No deduplication needed — it's inherently specific to that time slot at that venue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Categories and genres&lt;/strong&gt; can drift between sources. One cinema might tag something as "Drama", another as "Drama / Thriller", and TMDB might call it "Drama, Crime". You need a strategy for reconciling these.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Deduplication isn't a single operation — it's a per-field decision about which source of truth wins at which level of the hierarchy. Having clean separation between movies, showings, and performances makes those decisions much more tractable than they'd be in a flat structure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The payoff
&lt;/h2&gt;

&lt;p&gt;Spending time upfront on the data model meant that when complexity arrived — new venue types, accessibility requirements, external data enrichment, multi-venue aggregation — the schema absorbed it instead of fighting it. The hierarchy isn't clever for its own sake; it maps onto how cinemas actually programme their events, and that's what makes it hold up.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Next post:&lt;/strong&gt; &lt;del&gt;Site Performance: Loading 30,000+ Showings in a Browser&lt;/del&gt;&lt;br&gt;
Change in the schedule: &lt;a href="https://dev.to/alistairjcbrown/a-brief-detour-two-writing-challenges-and-what-came-out-of-them-4h8h"&gt;A Brief Detour: Two Writing Challenges and What Came Out of Them&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>json</category>
      <category>javascript</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
