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.











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
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.
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.
-- coalesce() guards against NULL. It does not guard against ''. description = coalesce(nullif(btrim(p_description), ''), description)
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.
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.
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.
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.
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.
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.
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.
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.
| Adapter | Source | Why it needs one |
|---|---|---|
| palau | Palau de la Música | Listing drawn in the browser from a JSON feed |
| apolo | Sala Apolo | Infinite scroll; rows carry no year |
| lliure | Teatre Lliure ×2 | Season lists runs; performances on the box office |
| paloma | La Paloma | Month-at-a-time AJAX; delivered page is empty |
| parallel62 | Sala Paral·lel 62 | Ajax Load More; page is an empty container |
| santjordi | Palau Sant Jordi | Three rooms on one page; cards named after their artwork |
| macba cccb mnac | Museums | Dates 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 as | Reduces to | Rule |
|---|---|---|
| Empremtes 2026 | Manu Guix | manuguix | Series frame (059) |
| Banco Mediolanum Guitar BCN 2026: Pastora | pastora | Sponsor billing (059) |
| Merino (Festival Empremtes) | merino | Bracketed cycle (059) |
| Macy Gray – The Trouble With The Truth Tour | macygray | Tour suffix (059) |
| Teatre El Cadell, de Josep Maria Miró | cadell | Genre word + author credit (073) |
| La Casa de Bernarda Alba | casadebernardaalba | None — 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.
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
How often sources agree
What is on
Published events only. Archived rows — past events and absorbed duplicates — are excluded.
What it costs to answer
| Operation | Before | After | What changed |
|---|---|---|---|
| Whole-catalogue duplicate scan | 8.6 s, timing out | 174 ms | core_title stored as a generated column; one grouped count instead of a correlated subquery |
core_title() per title | 2.4 ms | once, at write | Fourteen regex passes, now paid on insert rather than on every read |
| Venue duplicate scan, 571 venues | — | 2.4 s | Trigram similarity, with distance only narrowing |
| Feed, whole catalogue | — | ~7 MB | Fetched once per launch; search and filters are then local |
| Rows lost to a decode mismatch | 878 of 2,195 | 0 | A nullable column needs an optional on the other side |
The shape of the data
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.
How an integration actually runs
Scroll sideways on a narrow screen.
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.
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.
/// 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
ScrollViewaround aLazyHStackwith 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
LazyVStackestimates 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.