Evento Admin

Engineering record

Evento

An iOS app for what is on in Barcelona, an editorial back office, and a Postgres database that holds the rules. Written between July and September 2026. This document is the development record: what each build changed, and the reasoning behind it.

3,081Events
571Venues
29Programme sources
75Migrations
522Backend tests
174Commits
The Events tab: a hero carousel over category pills.
Lido Pimienta's page at Poble Espanyol, pale sky blue.
The map over Barcelona, venue marks among category pins.
La Paloma's venue page, red velvet auditorium.
TINI at Palau Sant Jordi, standard ticket beside the VIP package.
Editorial shelves stacked down the Events tab.
Festival Empremtes 2026 and its programme.
Search, venues above events.
L'Auditori's card opened from its map pin.
A venue page scrolled to its halls.
An exhibition page.

Screens from the build running on a phone. Every page above is rendered from one public_events or public_venues row.

The problem

Four sources, one concert

Listings arrive from the city's Open Data agenda, from Ticketmaster, from thirteen venues that publish their own programmes, and from events typed in by hand. They agree about almost nothing.

One concert reaches the database as Empremtes 2026 | Manu Guix at 18:30 and as Manu Guix at 19:30, at two spellings of Sala Apolo, one of them nested inside a festival's programme and one standing on its own. A civic centre appears four times because each of its halls was filed as a venue. A season of nine shows arrives as a single event with the line-up written into its description.

Nearly every design decision in this system follows from that. The strategy has been to push every judgement of that kind into Postgres, where it is a function with a name, a comment explaining why it exists, and a query that can be run against the whole catalogue before the rule is changed.

Why the database and not the client

Three consequences decided it, and all three had already cost something:

  • A rule in the client ships on Apple's schedule. A venue publishing its schedule in a new shape needed an App Store release to be read.
  • A rule in the client cannot be inspected. The programme parser had a real bug — Placebo's presale line, "a partir del 25 de març a les 10h", read as a three-row timetable — and nobody could see it without installing the app. On the server it was a query, and a deploy.
  • A rule in the client is one client's rule. Anything written on the phone would have to be written again for Android and again for the web.

So the client is a renderer. It has no write path at all, and it makes exactly one kind of request.

Build history

Seven builds

Each of these is a day or two of concentrated work ending with a build installed on a phone. Migration numbers are stable identifiers — 073 is always core_title knows a play from its billing — and are the fastest route to why a rule exists.

Where the rules were written

2520 100 0725 91117 6 Build 1Build 2 Build 3Build 4 Build 5Build 6 Build 7 Build 3 moved venue identity out of Swift and into rows; build 6 moved the programme parser.
Build 12 July 2026no migrations

Everything on the phone

One SwiftUI target with the city compiled into it. VenueNormalizer resolved venue names through about forty contains branches; categories were collapsed by hand, mapping "Concerts" onto "Music" and "Art" onto "Exhibition"; a 287-line parser decided on the device whether a description was a timetable and where its columns were.

Why it did not last. Not because it was slow — it was fine. Because every rule was invisible and every fix needed a release. The next build was mostly about undoing this.

Build 215 – 19 August 2026migrations 001 – 007

A backend, and the identity question

Postgres becomes the source of truth. The design question that shaped everything after it: what makes two rows the same event?

The answer taken here is that identity is asserted, not inferred. Every source must present a stable external id, and ingest_event() — the single write path — matches on it. A re-scrape updates a row; it never adds one. Where two different sources describe the same night, that is a separate problem, solved later and deliberately conservatively (see Identity Numbers).

event_sources arrives in the same build, so an event can be credited to more than one provider without any of them owning it. And the review gate: a source the system discovers imports as drafts until a person enables it.

The bug that set the tone. Migration 001 is four lines. Ticketmaster sends '' rather than null for a description it does not have, and coalesce() only guards against null — so every re-import was overwriting descriptions somebody had written with nothing.

migrations/001_blank_text_guard.sql
-- coalesce() guards against NULL. It does not guard against ''.
description = coalesce(nullif(btrim(p_description), ''), description)
Build 34 – 5 September 2026migrations 008 – 032

Identity as data, and an editable home screen

Twenty-five migrations in two days, and the largest single idea in the project: the matching rules stop being code and become rows.

venue_aliases holds every spelling that resolves to a venue. rooms.match_keywords holds every phrase that means a particular hall. Both were seeded directly from the Swift tables being deleted in the same build, so nothing was lost in the move. Thirty-five venue fragments — one building under several names — were consolidated, each merge snapshotted so it can be read back.

Matching a room is a good example of why data beats code here. The rule is "the longest matching keyword wins", because sala 4 alicia de larrocha is a more specific claim than a bare sala 4. Expressed as a query that is one order by; expressed as branches it is a maintenance problem in three languages.

migrations/008_venue_identity.sql — room_for_name()
select r.id from public.rooms r, haystack h
where r.venue_id = p_venue_id
  and exists (
    select 1 from unnest(r.match_keywords) as kw
    where h.key like '%' || public.venue_alias_key(kw) || '%'
  )
-- The longest matching keyword is the most specific claim.
order by (select max(length(public.venue_alias_key(kw)))
          from unnest(r.match_keywords) kw) desc
limit 1;

The second idea in this build is the same one applied to presentation: the home screen becomes sections and section_events rows. A shelf is created in the back office, not in a Swift file, and its layout — carousel or list — is a column.

Also here: one canonical category per bucket, batch editing as a transactional audited function, and the app's first test target.

Build 49 September 2026migrations 033 – 041

The whole catalogue, in one request

Until now the client paged. This build changed the strategy to fetch everything once — about seven megabytes — and answer every subsequent question locally.

The reasoning: search, the map, the category pills and the date filters are all predicates over the same set. Serving them from the network means a request per keystroke and per pan, a loading state in six places, and an offline app that shows nothing. Holding the set means one request, one loading state, and a catalogue that survives a tunnel.

It also forced a correctness fix. Paging needs a total order, and is_featured desc, starts_at asc is not one when 1,829 events share a start instant — two requests disagreed about which row came first, so offset paging skipped and repeated. Adding id as a final tie-break made the order total.

On the client side this is where the event page took its present shape: the page colour is sampled from the bottom edge of the hero image, so the photograph continues into the page rather than sitting on a panel. A hero can be a video with the image as its still. Highlights became a shelf of cards rather than one full-width banner, because a banner can only show one thing and took 190 points to do it.

Build 512 – 13 September 2026migrations 042 – 052

Merging, and the locking model

The busiest two days — 36 commits on the 12th. The theme is what happens when a person and an importer disagree.

The model: a manual edit sets is_locked, and a lock stops an automatic import overwriting that row. It is not meant to stop the person who made the correction, so merge_reviewed_pair() lets an editor lift a lock and merge inside one transaction, writing the lift to audit_log first. The record survives and the merge stays reversible.

Refinements to that rule followed as the edges appeared: a lock should protect what a person wrote, not a blank (064); and a lock should mean somebody actually changed something, not that a form was opened and saved (065).

merge_venues was consolidated into one function in this build. The previous two-argument overload was still live and silently destroyed three things — no venue_merges snapshot, no aliases carried across, no street aliases — and PostgREST could resolve either overload depending on the arguments sent.

Four venues started publishing their own programmes here, each needing its own adapter: the Palau de la Música draws its listing in the browser from a JSON feed, Apolo's agenda is an infinite scroll whose rows carry no year, and both Teatre Lliure houses list runs whose individual performances live only on the box office.

Pins cluster by venue. A featured venue draws its own mark; everything else draws its category, or a count where several events share a point. A pin is labelled for its place, never for whichever event survived the current date filter.
Build 614 September 2026migrations 053 – 069

The last work comes off the phone

Seventeen migrations, almost all of them rules about what a listing means.

The programme parser moves to lib/programme.ts with the tests written for it, and events.programme stores what it found as JSON. The client decodes a structure instead of parsing prose. An editor can also override the source text in programme_text and watch it re-parse in a live preview before saving — the correction is read by exactly the same rules as the original, rather than becoming a second format nobody can check.

The other rules in this build are all of the same kind — a statement about the domain, written once, applied to the whole catalogue:

  • 053 — a VIP package is not an event; it is a ticket for the performance it is sold for.
  • 062 — a night already stored as a session of a run is not a second event.
  • 066 — a badge comes from what a listing states, not from a phrase in a biography.
  • 068 — a show with no picture of its own borrows its cycle's.
  • 069 — a free cycle makes its nights free unless one of them says otherwise.
A season published as one event, shown twice on purpose: as the programme the venue printed, and as the individual nights parsed out of it, each of which can be opened, saved and navigated to.
Build 715 September 2026migrations 070 – 075

Every venue a place, and a duplicate finder that answers

has_profile had drifted. It started as "somebody curated this one" and became the gate on public_venues, which meant 656 of 688 venues had no page and could not be edited. Every venue has one now, and is_featured takes over the editorial job: it decides the Popular Venues shelf and which venues draw their own mark on the map.

That flip had a trap in it worth recording. is_placeholder_venue — which the event duplicate finder uses to skip street stand-ins — tested not has_profile. Setting the column true everywhere would have made that function return false for every row in the table, silently, and widened the duplicate finder to every geocoded street corner in Barcelona. The guard was restated as what it had always been reaching for: a venue with a picture or a description is a real place.

Halls stop being venues

extract_room_venues() finds venues named Building — Hall, creates the hall as a room with the keywords that make room_for_name recognise it, and lets merge_venues move the events into it. Fifteen moved. Six had no venue matching the first half of the name and were reported for a person rather than invented — the rule is deliberately narrow, because "Antic Teatre - Espai de Creació - Teatre" has two separators and no single reading.

The duplicate finder was timing out

The Duplicates page had been returning nothing at all, and the merge function that validated by joining duplicate_groups() to itself could not have completed a merge either. core_title() is fourteen regex passes, about 2.4 ms a title, and the finder called it twice per row and four more times inside the join condition — five of the eight seconds. Storing it as a generated column took the query from 8.6 s and timing out to 174 ms.

One field, two kinds of answer

Search looks for venues and events at the same time and shows them as separate sections: the places that match first, then the events that match, each counted in its own heading. Typing "Palau" finds the Palau de la Música and the Palau Sant Jordi, and under them the 125 concerts happening at either — so the same word answers "where is that place" and "what is on there" without the reader choosing a mode first.

An event matches on its title, its venue, its category, its room and the festival it belongs to, so "Apolo" returns everything at the venue rather than only the events with Apolo in the name, and "Empremtes" returns a festival's whole line-up. A venue matches on its name and its type. Accents and case are folded on both sides — "sonar" finds Sónar.

It runs against the catalogue already on the device, over an index whose fields are folded once when it is built rather than on every keystroke. There is no request while somebody is typing, and no spinner.

Left: a building with its halls. Right: one search field over two kinds of thing — venues first, then the events at them.

Ingestion

One write path

An adapter's only job is to turn a page into NormalisedEvent[]. Everything after that is a Postgres function, so a rule can be read, measured against the whole catalogue and changed in a deploy.

Fetch programme URL Adapter 13 hand-written resolve_venue place id → alias → name Identity match external id, asserted Write insert or update Review gate draft → published Then, in order VIP packages fold into their performance · title badges · programmes re-read · sold-out state Nothing here invents data; each step reads what a source published and files it.

Scroll the diagram sideways on a narrow screen.

Two guards, and why they are the ones that matter

Identity is asserted, never guessed. A match is made on an external id a source supplies, or on a proven occurrence. The alternative — inferring that two similar rows are the same — is the expensive kind of wrong: a false match silently deletes a performance, and nothing downstream can tell that it happened.

Nothing a scrape discovers publishes itself. A newly found source imports as drafts. Enabling it is a deliberate act with a name attached, which means an adapter can be written and run against production without any risk of a half-correct reading reaching a reader.

Adapters

A source gets hand-written code only when the generic reader cannot see its programme. The generic path tries JSON-LD, then microdata, then a listing reader that pairs headings with dates and, if the listing carries no dates at all, follows each link and reads the event's own page.

AdapterSourceWhy it needs one
palauPalau de la MúsicaListing drawn in the browser from a JSON feed
apoloSala ApoloInfinite scroll; rows carry no year
lliureTeatre Lliure ×2Season lists runs; performances on the box office
palomaLa PalomaMonth-at-a-time AJAX; delivered page is empty
parallel62Sala Paral·lel 62Ajax Load More; page is an empty container
santjordiPalau Sant JordiThree rooms on one page; cards named after their artwork
macba cccb mnacMuseumsDates live on each exhibition's own page

Reading a listing politely

Two details that turned out to matter. A status chip can sit on either side of a title — the Teatre Nacional prints Exhaurit in front of it — so a card's own window is read forwards and then thirty-two characters backwards, which is enough for the chip and far too little to reach the previous card's title.

And a failed request is not an answer. A Wikimedia lookup that returns 429 used to be indistinguishable from "no article exists", so a throttled batch marked 44 venues as checked without one of them having been asked, and the queue would not offer them again for a month. Refusals are now separated from absences, and the run backs off instead of continuing.

Deduplication

When two rows are one night

Two events are candidates when they share a venue, a room, a local day and a core_title. All of the difficulty is in the last one.

core_title() strips, in order: a series frame around an act, a sponsor's billing, a bracketed cycle, a tour suffix, a genre word, a leading article, a trailing year, and an author credit. Each of those is a separate migration, added when a real pair was found that the existing rules missed.

Published asReduces toRule
Empremtes 2026 | Manu GuixmanuguixSeries frame (059)
Banco Mediolanum Guitar BCN 2026: PastorapastoraSponsor billing (059)
Merino (Festival Empremtes)merinoBracketed cycle (059)
Macy Gray – The Trouble With The Truth TourmacygrayTour suffix (059)
Teatre El Cadell, de Josep Maria MirócadellGenre word + author credit (073)
La Casa de Bernarda AlbacasadebernardaalbaNone — the words are the name

The last row is the constraint every rule is written against. A title reduces only where the extra words are billing, and never where they are the name.

The rule that stops it collapsing real performances

Clock times disagree between sources by up to a few hours, so a wide window is needed to catch genuine duplicates — and a wide window is exactly what would merge a matinée into an evening. The discriminator is not the clock, it is the source.

Two rows that share no source are two accounts of one night, and may differ by up to three hours. Two rows from the same source are that source listing two sittings — El Petit Príncep runs nine times across four days, all from the venue's own site — and stay within thirty minutes.

migrations/073 — the window, in duplicate_groups()
and (
  abs(extract(epoch from a.starts_at - b.starts_at)) <= 1800
  or (not (a.src && b.src)     -- no source in common
      and abs(extract(epoch from a.starts_at - b.starts_at)) <= 10800)
)

Venues resolve transitively. Events do not.

A venue is a building: if three spellings of a street are each the same place as the next, they are one place, so venue merges treat the pairs as edges of a graph and give each connected component one survivor. Events never do this — two performances that each resemble a third are not thereby the same performance, and merging them loses a night.

Every merge is reversible

merge_events and merge_venues both snapshot the absorbed row before touching it, and record the surviving row too. A venue merge also carries aliases, street aliases and connected programme sources across — the alias matters most, because without it every name that resolved to the absorbed venue starts resolving to nothing on the next import.

By the numbers

What the catalogue is made of

4,688 source attributions across 3,071 events. The gap between those two figures is the whole job: 562 events are described by more than one provider and have been converged into a single row that credits all of them.

Where events come from

Open Data Barcelona 2,172 Ticketmaster 1,372 Venue programmes 899 Entered by hand 245 Attributions, not events — one event may carry several.

How often sources agree

2,509 one source 377 two — a venue and the city, usually 48 three 137 four or more Each of those 562 multi-source events was two or more rows before it was matched.

What is on

Culture 828 Music 815 Exhibition 297 Festival 107 Performing arts, cinema, markets, sport 125

Published events only. Archived rows — past events and absorbed duplicates — are excluded.

What it costs to answer

OperationBeforeAfterWhat changed
Whole-catalogue duplicate scan8.6 s, timing out174 mscore_title stored as a generated column; one grouped count instead of a correlated subquery
core_title() per title2.4 msonce, at writeFourteen regex passes, now paid on insert rather than on every read
Venue duplicate scan, 571 venues—2.4 sTrigram similarity, with distance only narrowing
Feed, whole catalogue—~7 MBFetched once per launch; search and filters are then local
Rows lost to a decode mismatch878 of 2,1950A nullable column needs an optional on the other side

The shape of the data

events 3,081 · 70 published fields venues 571 rooms 36 · match_keywords venue_aliases 199 spellings event_sources 4,688 attributions event_tickets tiers and upgrades event_badges 10 badges sections the home screen, as rows events (children) 160 festival nights

Scroll sideways on a narrow screen. Parent and child events are the same table — a festival night is a row with a parent_event_id, and appears both nested in its parent and as a top-level row of its own.

For data partners

What integrating a feed looks like

If you publish events in Barcelona and are deciding whether to give this project API access, this section is the honest description of what would happen to your listings.

Your feed keeps its identity

A source is a first-class row, not a merge target. event_sources records every provider that described an event, with its own external id and the URL it came from, and source_precedence decides which provider's version of a contested field wins. Nothing is anonymised into a pool: an event that came from you is still known to have come from you after it has been merged with anything else.

Attribution is structural, not a footer

Every event page credits the providers behind it, driven by the event's own source_links rather than by a hard-coded flag — so a provider added later appears without an app update, and one that stops supplying an event stops being credited. The app links out to the source's own page for the event; the "More info" and ticket links point at the provider, not at a copy.

Your rate limits are respected

Scrapes are serial per source with a deliberate pause, back off when refused rather than retrying through a failure, and a refusal is recorded as a refusal so the same request is not made again in a loop. Requests carry an identifying User-Agent. A source that supplies a feed replaces a scrape entirely, which is strictly less traffic for you.

Duplicates work in your favour

The deduplication described above exists precisely so that a partner's listings do not appear twice beside the city's Open Data version of the same night. Where two sources describe one event, they converge into one row that credits both, and the stronger source wins each field.

Nothing publishes itself

A newly connected source imports as drafts behind a review gate. Its events reach readers only after somebody enables it, so a misconfigured integration cannot put wrong information in front of an audience under your name.

Scale, for context. 3,081 events and 571 venues in Barcelona, from four kinds of source; 29 connected programme sources; a published contract of 70 fields per event. The catalogue is refreshed nightly and the client holds the whole of it, so a correction you publish is visible to every reader on their next refresh.

How an integration actually runs

Your feed an endpoint, or a key for an existing API Registered a row in scrape_sources, disabled by default Adapter your fields mapped to NormalisedEvent, once Review gate first run lands as drafts; a person turns it on Already known? your external id first, then a proven occurrence Converges one row, both credited Created yours alone, credited Published links back to you What you keep throughout Your external id · your canonical URL · your ticket links · a row in event_sources naming you · precedence over lower-ranked sources for every field you supply · the right to be switched off in one click.

Scroll sideways on a narrow screen.

Left: ticket tiers as the seller published them, each linking out rather than to a copy. Right: every shelf is a row in sections, ordered in the back office — so a partner's collection can be given a shelf of its own without a client release.

The client

A renderer with no write path

Seven models, twenty-one views, eleven managers, twenty-two test files, 17,590 lines of Swift. It makes one kind of request, holds the answer, and draws it.

BackendAPI search_events() paged, total order Lossy decode a bad row is skipped, and reported EventMapping nonisolated, detached off the main actor EventFeed @Published events indexed by id Views Map · Events Search · Saved EventsCache disk, minus what ended

Decoding is deliberately lossy — and that has a cost

One malformed row must not empty the app, so a row that fails to decode is skipped and the rest of the feed is kept. The cost is that a contract mismatch looks like nothing at all.

It did exactly that once. public_events.is_free is nullable and the Swift model declared it a plain Bool, so every row with a null threw and was dropped — 878 of 2,195 rows, forty per cent of the catalogue. The feed still loaded, so the symptom presented as a festival vanishing while the thirteen shows on its own programme stayed, because a festival's children are also top-level rows and decoded fine. The app's own log had been reporting it on every launch.

The lesson taken was not "stop being lossy" — it is still the right behaviour — but that the count of dropped rows has to be loud, and that every nullable column in the contract needs an optional on the other side of it.

Events/Models/SupabaseModels.swift
/// Null where nobody has said. `public_events.is_free` is nullable and
/// a good fraction of Open Data's catalogue leaves it unset, so this
/// must be optional — as the sub-event row already had it.
///
/// It was `Bool` until the app was quietly losing 878 of 2195 feed rows
/// to `valueNotFound(Bool, is_free)`. Null is unknown, not free.
let isFree: Bool?

Layout bugs found by measurement, not by reading

Two SwiftUI problems in this codebase had the same shape: a section drifted on its own while nothing in it had changed size or position. Instrumenting the moving view found nothing, because the moving view was not the cause.

  • A horizontal ScrollView around a LazyHStack with no stated height reports a height that changes as cards materialise, and rubber-bands vertically. The card is a fixed 220 pt, so the height was always knowable — it just was not stated.
  • A LazyVStack estimates the rows it has not built, and every revision of that estimate moves everything after it while its own children stay still.

Both were found by recording the screen at 20 fps and comparing horizontal bands of each frame against the first: 84 px of travel on a 1.85 s cycle, with the shelf above and the tab bar at exactly zero. Sampled screenshots at 2.5 fps had reported the same screen as perfectly stable.

Impact-Site-Verification: 84615cce-44a5-41d3-b2f2-b694e465f801