Skip to content
Brian Whitaker

Work

Show Scout

Regional theatre programming is announced in a hundred places and indexed in none. Show Scout watches for the shows you want to see and tells you when one lands in a city you care about.

The problem I actually had

I like theatre. I kept missing shows I would have travelled for, because regional programming is announced across a few hundred venue sites, a handful of producer newsletters, and nowhere else. There is no index. If you want to know that a touring production is playing three hours from you in October, the only reliable method is to go and look, repeatedly, forever.

So the product question was not "how do I build a theatre database". It was: what is the smallest system that can tell someone something they would have missed, without either of us doing any work?

What it does

You add shows to a watchlist and cities you would travel to. After that you do nothing. When a performance turns up that matches both, you get a message.

SS-01 — Screenshot to capture

The watchlist page with four or five shows added and a city list beside it

framing Desktop, full width, light theme. Populate with real, recognisable titles — not Lorem. Crop to the content column, not the whole browser chrome.

why First proof the thing is real. This is the shot a reader looks at before deciding whether to keep reading.

How a match becomes a message

How a match becomes a message

Nothing in this path is triggered by a person. Select any stage to see what it does and what made it awkward.

  1. Input

    ScraperFastAPI on Railway

    A Python service that walks venue and producer sites and normalises what it finds into performance records. It runs separately from the web app because scraping is bursty, occasionally slow, and I did not want it sharing a runtime with request handling.

  2. Interface

    Admin reviewSoft-reject

    Scraped records land in a review queue rather than straight into the catalogue. Rejects are soft — the row stays with a rejected flag, so the scraper does not re-propose the same bad record next run. Role-based access is enforced through a profiles table and a UserContext provider.

  3. Persistence

    performancesPostgres

    Approved performances, with a timezone-aware show_datetime. A show at 7:30pm is 7:30pm where the theatre is, not where the server is — getting this wrong is the kind of bug users notice immediately and never forgive.

  4. Interface

    Watchlistpg_trgm search

    Users add shows they want to see. Titles are messy in the wild, so search is fuzzy — a pg_trgm trigram index means 'Sweeny Todd' still finds the right row.

  5. Interface

    City preferencesSoft deletes

    Where a user is willing to travel. Removing a city is a soft delete so that historical matches stay explainable, and the editor warns on unsaved changes.

  6. Runs on a schedule

    Matching jobpg_cron

    A scheduled Postgres function, match_watchlist_to_performances(), joins watchlist, user_cities and performances and writes rows into matches. Putting the join in the database instead of application code means it is one plan, one transaction, and no N+1 across users.

  7. Persistence

    matchesPostgres

    The join table is also the notification ledger. A match row records what was matched and whether the user has been told, which is what makes the send step idempotent.

  8. Runs on a schedule

    Nudge jobVercel cron

    A scheduled route reads unsent matches and hands them to SendGrid, then marks them sent. Split from the matching job on purpose: a mail provider outage should not stop matching, and a matching bug should not send a hundred wrong emails.

  9. Reaches the user

    Email and SMSSendGrid, Twilio

    The actual point of the product: you find out a show you wanted is playing near you, without having checked anything.

The hard part was time

A performance has a date and a time, and both are meaningless without a place. A 7:30pm curtain in Ashland is not the same instant as a 7:30pm curtain in Chicago, and a user in a third timezone needs to be told about both correctly.

I settled this at the schema layer rather than in application code, so that every consumer of a performance row — the matcher, the email template, the mobile app — inherits the same answer instead of each re-deriving it.

supabase/migrations/…_performances.sqlsqlStoring the instant and the venue's zone separately. The instant is for comparison; the zone is for display.
create table performances (
id            uuid primary key default gen_random_uuid(),
production_id uuid not null references productions(id),
venue_id      uuid not null references venues(id),
-- the moment in time, always UTC
show_datetime timestamptz not null,
-- what the audience sees on the ticket
local_date    date generated always as (
                (show_datetime at time zone venue_timezone())::date
              ) stored,
status        text not null default 'pending'
              check (status in ('pending','approved','rejected'))
);
REVISE:

Replace the snippet above with the real migration once you have settled the generated-column approach — and if you went a different way, say so here. A case study that admits a rewrite reads better than one that pretends the first design held.

The matching job

The obvious implementation is a loop: for each user, fetch their watchlist, fetch their cities, query performances. That is an N+1 with a network hop per user, and it gets slower exactly as the product succeeds.

Instead the join lives in Postgres as a function on a pg_cron schedule. One query plan, one transaction, and the write into matches is the same statement that found them.

supabase/functions/match_watchlist_to_performances.sqlsqlThe whole matching product in one statement. The ON CONFLICT clause is what makes the job safe to run twice.
insert into matches (user_id, performance_id, matched_at)
select w.user_id, p.id, now()
from watchlist w
join user_cities uc on uc.user_id = w.user_id
join performances p on p.production_id = w.production_id
                 and p.venue_city_id  = uc.city_id
where p.status = 'approved'
and p.show_datetime > now()
on conflict (user_id, performance_id) do nothing;

SS-02 — Screen recording to capture

Adding a show to the watchlist, typed with a deliberate misspelling, and the fuzzy search still finding it

framing 10–15 seconds, screen only, no audio, no cursor trails. Type slowly enough to read. Loop cleanly — start and end on the same view.

why Fuzzy search via pg_trgm is a sentence in the prose and a delight in motion. This is the single highest-value recording on the site.

Where Claude sits in the product

REVISE:

This section is the one AI-product-engineer hiring managers will read closest. Be concrete: what exactly does the Claude API call do, what does the prompt receive, what happens when it returns something wrong, and how do you know? Write it as an engineering decision, not a feature announcement.

Scraped listings arrive as free text. Titles vary, venues abbreviate, and the same production appears under three names in a week. Claude normalises those into a candidate record, which then goes to a human — me — for approval rather than straight into the catalogue.

SS-03 — Screenshot to capture

The admin review queue with a mix of pending, approved and soft-rejected rows

framing Show at least one rejected row so the soft-delete story is visible. Blur or replace any real venue contact details.

why Evidence of the human-in-the-loop decision, which is the substance of the AI section.

Decisions

ChoseOverBecause
Matching as a Postgres function on pg_cronA Node worker looping over usersOne query plan instead of N+1 over the network, and the write happens in the same transaction that found the match.
A separate Python scraper on RailwayScraping inside the Next.js appScraping is bursty and slow. Keeping it off the request-handling runtime means a hung fetch cannot degrade the site.
Soft rejects in the admin queueDeleting bad scraped rowsA deleted row gets re-proposed on the next scrape. A rejected row is a memory.
Incremental TypeScript migrationA big-bang rewrite or staying on JSSupabase generates types from the schema, so the payoff concentrates in the data layer — that is where I converted first.
Coming-soon page with waitlist captureWaiting for feature completeness to launchDemand evidence before the notification volume matters. REVISE with what the waitlist actually told you.

What is not done

  • REVISE: Be honest and specific here. Unfinished work described precisely reads as judgement; described vaguely it reads as an excuse.
  • The React Native / Expo companion app is scaffolded and lives in its own repo.
  • Monetization is designed but not switched on — lifetime-subscription caps against Stripe, chosen over Patreon for entitlement control.

SS-04 — Screenshot to capture

The Expo app running in a simulator, showing the watchlist

framing 9:16 device frame, one screen only. Skip this slot entirely if the mobile app is not presentable — a missing shot is better than a weak one.

why Shows the system extends past the web app.

Where it landed

  • REVISE: waitlist signups since the coming-soon page went up
  • REVISE: performances indexed / venues scraped
  • Matching runs unattended on a schedule — no manual curation step