events.lat is a calendar of concerts, festivals and cultural events in Latin America: more than 3,000 upcoming events in over 100 cities, in English, Spanish and Portuguese. Almost none of it is typed in by hand. It comes from partner feeds: Ticketmaster, Eventbrite, Sympla, Bandsintown, Songkick and Luma.
Each feed is fine for what it was built for: selling tickets. Put them all on one browse page, though, and you see things that make no sense to someone looking for something to do on Saturday. Here are the three problems we hit, and the small, boring functions that fixed them.
1. Add-ons that look like events
Partners publish ticket upsells as their own events. A "Hi-Touch" photo session sold with the note boleto de evento no incluido (event ticket not included). A pre-signed poster. A parking pass for a festival. In the feed they have a title, a date and a venue, just like the concert.
The fix is a pattern on the title:
const ADDON_PATTERN = /(boleto de evento no in?cluido|no incluye boleto|entrada no incluida|ticket not included|hi[\s-]?(touch|bye)|pre[\s-]?signed|meet ?& ?greet|soundcheck|^\s*estacionamiento\b|\b(estacionamiento|parking)\s*$|\bupgrade\b)/i; const isTicketAddon = (e: { title?: string | null }) => ADDON_PATTERN.test(String(e.title ?? "")); The hard part is what to leave out. An Abono … is a season or multi-day pass, and a Grada 15, Formula 1 … is a grandstand tier. Both look like add-ons, but they are real tickets to a real event, so filtering them would hide things a visitor can actually buy.
Parking had a bug of its own. The first version matched the word at the start of a title, and that hid an event called "Parking Lot Party". Now English "parking" only counts as the last word ("Festival Vaivén 2026 Estacionamiento" is a car park; "Parking Lot Party" is a party). Spanish estacionamiento is safe at either end. Each of those cases is now a test.
2. One show, ten listings
A theatre run comes in once per performance. El Fantasma de la Ópera playing ten nights at one theatre filled ten slots on the page, and pushed everything else off it.
So we group performances into one production with a key built from the title and the venue:
const fold = (v: unknown) => String(v ?? "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, ""); function productionKey(e: { title?: string | null; venue?: string | null; city?: string | null }) { const title = fold(e.title) .replace(/\b(19|20)\d{2}\b/g, "") // "… 2026" on some nights, not others .replace(/[^a-z0-9]+/g, " ") .trim(); return `${title}::${fold(e.venue ?? e.city).trim()}`; } Accents, case, punctuation and the year all go, because partner titles for the same run vary from night to night, for example "Ópera" on one and "Opera" on another, with or without "2026". The venue stays in the key, so the same touring show in two cities is still two listings.
Then we keep only the soonest performance of each production, in the order the rows came in:
type Listing = { title?: string | null; venue?: string | null; city?: string | null; event_date?: string | null }; function dedupeListing<T extends Listing>(events: T[] | null | undefined): T[] { const seen = new Set<string>(); const out: T[] = []; for (const e of events ?? []) { if (isTicketAddon(e) || isImplausibleEvent(e)) continue; const key = productionKey(e); if (seen.has(key)) continue; seen.add(key); out.push(e); } return out; } One rule we hold to: these helpers only remove noise. They never reorder by "quality", because someone looking for a specific date has to find that date where they expect it. The list stays in date order. Since the list can shrink, callers ask for a few extra rows.
3. Rows that can't be real
Some feeds leave out the location and the importer fills in a placeholder. We found an event in a city called "Nocity", dated 31 December 2039.
const PLACEHOLDER_CITIES = new Set(["nocity", "no city", "n/a", "na", "unknown", "tba", "tbd", "-"]); const MAX_YEARS_AHEAD = 3; function isImplausibleEvent(e: { city?: string | null; event_date?: string | null }) { const city = String(e.city ?? "").trim().toLowerCase(); if (city && PLACEHOLDER_CITIES.has(city)) return true; const year = Number(String(e.event_date ?? "").slice(0, 4)); return Number.isFinite(year) && year > new Date().getFullYear() + MAX_YEARS_AHEAD; } A missing city is not on the list. A listing can be real with the venue known and the city absent, and hiding those would quietly shrink the catalogue. Only a value that says "no location" counts. The same goes for a missing date.
Why this runs twice
The obvious fix was to set those rows to pending in the database. It didn't hold: the daily sync and enrich jobs approved the 2039 row again within days. So the check runs when the page renders, where nothing the importer does later can undo it.
But a check at render time alone was not enough either. The events page shows an exact total, and the hidden row still counted: "Showing 2,939 events" included one nobody could see. So the same two rules also go into the database query, in the form PostgREST needs, and the tests check that both copies use the same list of placeholder cities and the same three-year limit:
it("exports the same placeholder cities the predicate rejects", () => { for (const city of PLACEHOLDER_CITY_LIST) { expect(isImplausibleEvent({ city, event_date: "2026-12-01" })).toBe(true); } }); What I'd tell someone merging feeds
- Keep cleaning separate from ranking. A filter that only removes rows is easy to test and easy to trust. Once it starts reordering, every bug report turns into an argument about taste.
- Write the near-misses down as tests. The season pass, the grandstand tier and the "Parking Lot Party" are the cases that matter, because a looser pattern quietly hides real things you could buy.
- Assume the importer will undo your database fix. If a daily job can set a row back, guard it where the page renders as well.
The result is live at events.lat: the full calendar, a map and a page of free events. Ticket links go to the official seller, at the seller's own price.

