London Dance Timetable
Build diary Chapter one 29 September 2026

What happens when a product marketer gets tired of a bad customer journey

I spend my working life asking where the friction in a customer journey is. I also dance — a lot — so when I realised I had been putting up with a terrible one in my own life for four years, it was hard to unsee.

← All posts
Part oneThe problem

London has an extraordinary dance scene and an extraordinary community with it: every level, every background, every style. There are the legendary schools — Pineapple, which you have seen in music videos whether you realised it or not. There are the established ones: Danceworks, Base, The Manor. And there are the smaller projects, many of which started with K-pop and have since grown into several styles. They rent rooms inside the bigger studios and run largely through Instagram.

London does not have a shortage of dance classes. Quite the opposite. On any evening there are dozens of great hip-hop, commercial, K-pop and other drop-in classes happening across the city.

The problem is discovery.

When I decide after work that I want to dance, I usually have a few minutes to choose. But the options are spread across different schools, booking platforms, apps and Instagram accounts. One class starts at 6:30, another at 7:00, another at 8:00 — and there is no single view where I can compare the evening. Pineapple has a timetable. Danceworks has a different one. The Manor, Base, XY Studio, Studio 808, K-pop in London, DGC and LUM3X are all different again, and none of them talk to each other.

By the time I have checked the schools I remember, looked up the teachers and worked out what I can still physically get to, the decision window is already closing.

So most of the time I did what customers do when discovery requires too much effort: I defaulted to what I already knew. I checked the same two or three schools, chose the easiest option to find, or did not go at all. Then later I would discover the class I would actually have chosen sitting on timetable number seven.

That happened over and over again for four years.

London did not need more dance classes. It needed a better discovery layer for the ones that already existed.

I wanted one page that answered one question: what can I dance tonight?

This was not a startup idea. No launch plan, no users to please. It started as a product for exactly one customer: me.

But being the first customer also gave me a very clear view of the job to be done, the product boundary and what I did not want to build. Not another marketplace. Not another booking platform. Not reviews, profiles or social features. Just the shortest possible path from I feel like dancing to I found the right class.

Jumping ahead: I now open it every day, and I have already been to classes I would normally have missed.

Building for one customer is wonderfully clarifying. You cut every feature you would not personally use. You also, it turns out, make a few choices you later have to undo, and I will get to those.

Who did what

I should be straight about this, because it is the most interesting part and it would be silly to pretend otherwise. I did not write the code. Claude Code did. I have never written a scraper in my life.

What I did was everything else, and it turned out to be most of the work:

So the code in part three is something I specified, reviewed and argued with, rather than something I typed.

Part twoWhat I built

dancetimetable.comLive now. Free, no account, no app.

One page. Pick a day, and every class runs down it in time order, whichever school it belongs to. Filter by style, level, start time, teacher, nearest station, or how long it takes you to get there.

It opened with the nine schools I dance at. It is on fourteen now, and all five extra ones went in because the dance community asked for them.

~700classes a week
14London schools
420teachers named
2hrefresh cycle

Nothing is sold on it. Book goes to the school, at the school’s own price, no commission, no reviews.

The product’s job is discovery, not intermediation: help someone find the right class, then hand the relationship back to the school.

Some problems you cannot engineer away

There was one product limitation I could not fix. I wanted Book to open the exact class you picked, and it does for ten of the fourteen schools, because Mindbody, Bookwhen and Omnify mint a URL per class.

The other four sit on booking platforms that do not expose a public URL for an individual class. There is no clever link to reconstruct and nothing hidden in the page: the URL simply does not exist. So for those schools, Book opens the school’s own timetable and the dancer finds the class from there.

633classes open the exact class
363land on the school’s timetable
10 of 14schools deep-link

I had two choices: hide the inconsistency or explain it. The about page says which schools support direct links and which do not.

That decision sounds small, but I think it matters. A good customer experience is not always one in which every constraint disappears. Sometimes it is one in which the limitation is clear enough that the customer never has to wonder whether the product is broken.

The design I liked, and the one I shipped

I tried a few. The first was white, pink and black, very clean, and I liked it a lot. Then I kept looking at it and seeing Pineapple.

That was the problem. The site gathers fourteen schools and belongs to none of them. A palette borrowed from one of them makes a claim I did not want to make, however much I liked the colour — and the schools whose classes I list deserve a page that is not quietly flying one of their flags.

So I generated a set of alternatives with AI and picked the one that felt right: cream, near-black, and a lime accent that is not anybody’s.

The two layouts side by side on a phone, both opened on the same Thursday: version one on a white ground with a pink accent, version two on cream with a near-black bar and a lime accent.
The same Thursday, opened from the top, two looks

I am not precious about it. It is a launch look rather than a final one — good enough to put the thing out, and with room to grow into a proper rebrand once I know who this is actually for.

The redesign did buy something practical as well. The row lost everything that was not helping you choose, so more of the evening fits on one screen. I had designed the first one for the desk I built it at; the version of me who needed this was on a platform at Vauxhall with one hand free.

Part threeHow it works — the technical half
Step 1

Four booking systems wearing nine costumes

What the fourteen schools actually run on
FOURTEEN SCHOOLS, SIX FEEDSMINDBODY3BOOKWHEN6LEMONBAR2BSPORT1OMNIFY1OWN CALENDAR1ONE TABLE · ~700 CLASSES A WEEK

The first surprise was good news. Almost nobody builds their own booking system. Behind nine very different websites sat four platforms, each with a data feed if you looked at what the page itself was fetching.

Pineapple and Danceworks are Mindbody. The Manor runs on Lemonbar, Base on bsport. The five smaller schools all use Bookwhen. Four scrapers, not nine, in plain Python.

scrapers/ — the whole import list
import csv
import json
import re
from dataclasses import dataclass
# that is it. no requests, no beautifulsoup, no selenium.

No headless browser anywhere, which is why the whole thing runs on a free scheduled job in under a minute. Adding the five newer schools later meant two more platforms — Omnify, and in one case a studio’s own hand-built calendar — but the shape of the job never changed.

Step 2

361 ways to say what kind of dance this is

Every style label the schools use, collected: 361 distinct spellings. Heels, HEELS, Heel’ography, HeelsUp. Levels were worse, 75 spellings for eight rungs on a ladder.

I asked Claude to do the first pass at merging them, and it got a lot wrong. Not because it is bad at this, but because the answers are not in the data. So I went through the mapping row by row myself: this is where being wrong is embarrassing in public, and a teacher seeing their advanced class filed under beginner is a bad first impression. Rules cover anything new, and anything the rules cannot place is flagged for me rather than guessed.

What looked like a data-cleaning problem was really a customer-language problem.

The taxonomy had to reflect how dancers actually think and search, not how neatly a database would prefer to organise them.

How 361 class labels became 14 style groups and 75 level labels became 9 levels, with real examples of each raw spelling.
Every raw spelling, and the group it ended up in
scrapers/grouping.py — when the class name tells you nothing
def style_by_rules(category, name):
    name_hit, cat_hit = _first_hit(name), _first_hit(category)
    # a name like "POP-UP" or "Workshop" carries no style,
    # so the school's own category wins
    if name_hit in GENERIC and cat_hit and cat_hit != name_hit \
       and not re.search(r"commercial|jazz ?funk|choreo", name, re.I):
        return cat_hit
    return name_hit or cat_hit or "Other"

My favourite rule came from dancing, not from engineering. K-pop schools mostly do not grade their classes, and a blank level is not the same as an unknown one. Those classes are All Level, which in a K-pop room means something specific: the choreography usually runs harder than a beginner class, anyone may walk in, and nobody is going to slow down for you.

Filing the blank as unknown would have hidden those classes from everyone using the filters. Filing it as beginner would have been a lie. Nobody reading the data could have worked that out.

scrapers/grouping.py — a blank K-pop level means All Level
if r.extra["style_group"] == "K-Pop" and not re.search(
        r"beg|rookie|absolute|foundation|intro", r.level_raw or "", re.I):
    r.extra["level_group"] = "All Level"

Note the order of that condition, because I got it wrong first. The original rule filed every K-pop class as open to everyone, and quietly overwrote 45 classes whose school had stated a level. The school’s own word comes first; the rule only fills a silence.

Step 3

The teachers who exist only as pixels

This is the one that genuinely needed AI, and not in the way I expected.

K-pop in London does not put teacher names on their booking page. Not in a field, not in the description, nowhere a scraper can reach. They post them weekly on Instagram, as a picture of a timetable.

For a dancer that is the most important thing on the page. You pick a K-pop class by who is teaching it. So “we cannot get that field” was not an acceptable answer.

What worked: Claude read the picture. Not a clever OCR pipeline, just vision on a weekly graphic, producing rows committed to the repository with the source written beside each one so anyone can check where a name came from.

data/teacher_overrides.csv — names read off an Instagram picture
school,date,class_name_contains,teacher,source
K-pop in London,2026-09-20,BAD,Maria,Instagram @kpopinlondonmin weekly schedule post 14 Sep 2026

Deliberately not automatic yet. The schedule changes monthly, so running vision every two hours is waste, and a misread name entering the timetable silently is worse than a name arriving late.

Step 4

Finding 399 dancers on Instagram

I wanted every teacher’s Instagram next to their name, so you can see how someone moves before you book. Searching the web for “dancer London instagram” works about 60% of the time and burns an afternoon.

The trick was not technical. Dance schools follow their own teachers. Open the school’s Following list, search inside it, and the hit rate jumps to 80% with far less ambiguity. 265 of 399 now have a verified link, and teachers can ask to be removed.

Step 5

Jazz and jazz funk are not the same class

Look at 361 style labels with an engineer’s eye and they are all duplicates waiting to be collapsed. About half of them are. The other half are distinctions, and nothing in the data tells you which half a label is in.

Jazz and Jazz Funk share a word and almost nothing else: different music, different training, different room. Merge them and the data looks beautifully clean while the customer experience gets worse.

Frame Up creates the opposite problem. It is a distinct style, but it sits inside the Heels world, so anyone filtering for Heels should find it there. The first pass had filed it separately.

An ungraded K-pop class is not “unknown difficulty”, it is All Level — and All Level is not the same as easy. The choreography usually runs harder than a beginner class: anyone can walk in, but nobody slows down for you. Treat the missing level literally and the class disappears from the filters; call it beginner and you have misled someone.

I made every one of those calls by hand, because I dance at these places. Someone who does not would have merged them, and the page would still have looked perfect.

The names have the same shape of problem, one size smaller. A teacher whose name carries an accent gets written both ways across two schools: two filter entries, half the classes each, and the Instagram link on only one of them.

Folding accents merges them. The nice touch is choosing which spelling survives: the one that kept its accent, because that is usually how the person writes their own name.

build_table.py — merging one teacher’s two spellings
# prefer the spelling that keeps its accents, then the most common one
best = sorted(spellings.items(),
    key=lambda kv: (-sum(1 for c in kv[0] if ord(c) > 127), -kv[1], kv[0])
)[0][0]

The opposite problem bit immediately. A class taught by “Aisyah / Tia” should appear under both names. But “Heels & Feels (Faculty)” is one company, and splitting it produced a teacher called “Feels (Faculty)”.

The fix is a small piece of scepticism: only split when each half looks like a real person, meaning it has two words or already appears somewhere on its own.

build_table.py — the Heels & Feels guard
solo = {t for t in (r.teacher for r in all_rows)
        if t and not re.search(r"\s*(?:/|&| and | x )\s*", t)}

def splits(name):
    parts = [p.strip() for p in re.split(r"\s*(?:/|&| and | x )\s*", name) if p.strip()]
    if len(parts) < 2: return None
    ok = all(len(p.split()) >= 2 or p in solo for p in parts)
    return [canonical.get(p, p) for p in parts] if ok else None
Step 6

A label that reads the opposite of what it says

Later I added an Absolute Beginner group, because “beginner” in a London dance studio often means “you have done this before, just not much”, and a genuine first-timer needs to know which classes are for them. Schools rarely have a field for it. They write it in the description: absolute beginners welcome.

So the rule reads descriptions — and the first version of it swept up ten classes that say the exact opposite.

scrapers/grouping.py — the sentence that means no
# "not suitable for absolute beginners" contains the phrase we look for,
# so the denial has to be tested before the welcome.
_DENIED = re.compile(r"(?:not|isn't|is not|no)\s+(?:\w+\s+){0,3}"
                     r"(?:suitable|recommended|for)\s+(?:\w+\s+){0,2}absolute", re.I)

The page looked fine either way. Only somebody who had read those ten class descriptions would have known.


What the weekend actually cost

An evening of building, and then weeks of checking. Every problem in this post ran without an error, and each one was wrong anyway. That is the ratio I did not expect going in.

The hardest errors were the ones that did not break anything.

Then I shared it, and I did not spend long on the launch. That was deliberate. I know this audience: dance communities run on trust, personal recommendations and people sending each other things they genuinely find useful, and a polished campaign would have felt completely wrong as a starting point.

I did not need awareness for awareness’s sake. I needed people to use the tool, feel how much easier it made planning their week, and send it to somebody they danced with. So I started with the community I was already part of: one honest post, a few stories, and direct messages to people I actually knew.

They turned up. Dancers. Teachers. People who run schools. And a subscriber list where there had been nobody.

The site started changing almost immediately, because people were asking for things I had never needed when the customer was only me. That is where the project stopped being a build exercise and started becoming a real product-marketing problem.

That is the next piece.

Coming next: What came back from the community, how the audience started reshaping the product, and the marketing plan built on a budget of roughly nothing

Part of London Dance Timetable — every drop-in dance class in London on one page, free, no account. Written by Lucy Nikolskaya. Get in touch.