Firefun
Finds the events and the venues open near you in Harare, and gives organisers somewhere to publish them.
- Sole engineer
- 2026
- in development
- TypeScript, Next.js, React Native, Firebase, Cloud Run
- Private repository
- Live
- 1,600+
- 1–5 ms
- 22 s
The source is not public. The site is up and under test, so the product itself is the demo rather than a writeup standing in for one.
Firefun is an events and places app for Harare. It answers two questions: what is on, and what is open near me. Organisers publish their own events through a portal, places carry opening hours and reviews, and the platform sells tickets with payouts running on a schedule.
It is one product across three build systems. A Next.js 15 web app, a React Native and Expo mobile app, and Cloud Functions all read the same Firestore, so anything whose answer must be identical on every surface lives in a shared package rather than being written three times. Most of what follows comes from that constraint, or from what happened when real data arrived.
Bounded reads, and the bound that corrupts the result
Every browse surface originally read the whole approved-places collection and narrowed it in the client. That is invisible on twenty seeded documents. On real data it was not: at around 1,400 places the landing page pulled 1.52 MB of serialised documents in order to display twelve of them, and the places feed served 3 MB of HTML in 22 seconds.
The fix is a geographic window. Queries run against geohash ranges around a centre, with the window sizes for each surface defined once in the shared package so web server rendering, the web client and mobile cannot drift apart.
The interesting part is the bound that looks correct and is not. Putting
limit() on a geohash range is the obvious way to cap reads, and it quietly
destroys the result: limit() truncates in index order, geohash order is
spatial, so it removes a contiguous region rather than thinning the set
evenly. Measured against live data, a 20 km window came back with 311 of 799
places, left an entire quadrant of the city blank, and returned 10 of 27
outdoors places because the budget was eaten by the dense restaurant strip that
happened to sort first.
Capping after the distance filter is no better, since it trades a geographic
bias for a distance one. Ordering the range by prominence instead is not
available either, because Firestore requires the first orderBy to match the
range field. The only safe bound is a non-spatial discriminator, which means an
equality filter on a precomputed tier.
So that scheme is designed, index-verified, and deliberately not built. At around 800 approved places a whole-window read measures about a second in region and 110 KB gzipped, which is cheap enough that a tier field would be machinery nobody uses. It is written down with the triggers that should bring it back: a second city, a corpus past roughly 3,000 places, a server render past two seconds, or the mobile map feeling slow, since mobile reads the full window on device and will hit the wall first.
Writing down the conditions under which a decision expires felt more useful than either building it early or pretending the question was closed.
Four hundred pins and a quarter of a legend
The map had a density problem that only appears at scale. Measured on about 1,600 Harare places, zoom level 13 drew 400 pins, 73% of which overlapped another within 40 pixels, and only a quarter carried a readable label. The rest were anonymous coloured dots.
Culling labels was the existing answer and it was the weakest of the three that were needed. Density is now handled in layers, outermost first:
- A type-family filter, which is the map’s primary control, ahead of freeform tags. It is single-choice by design, because multi-select rebuilds the crowd the filter exists to prevent.
- A pin budget per zoom level. The viewport renders at most N pins, chosen by ranking on review volume and then rating, so the survivors are the recognisable ones. The selected pin is always exempt: having the thing you just tapped vanish would be worse than any amount of crowding.
- Label collision culling, unchanged, now operating on a set small enough for it to win.
No clustering plugin. Clusters answer “how many things are near here”, and the question on this map is “which of these do I recognise”, which is what the prominence ranking is for.
The zoom thresholds, the label geometry, the collision resolver and the pin
budget all live in the shared package, so the two maps cull identically. A place
that disappears at zoom 12 on the web disappears at zoom 12 on mobile. The icon
bindings stay per app, since one uses lucide-react and the other Ionicons, but
they are keyed as an exhaustive record over the type union, so adding a type
without giving it a glyph is a compile error rather than a blank pin.
A shared package that cannot have dependencies
@firefun/core holds the logic both apps must agree on. It is consumed three
different ways, and that is the whole design constraint.
Mobile compiles it from TypeScript source through Metro. Web resolves it through tsconfig paths and ships those sources into the standalone bundle. Cloud Functions inline it with esbuild. A dependency added to core therefore has to resolve correctly in three separate build systems, so core has none at all.
That is why the haversine distance calculation is written out by hand there,
about ten lines, rather than pulling in a geo library. Turning a centre and a
radius into geohash ranges genuinely needs geofire-common, so each surface
calls that itself and then passes the merged documents back through the shared
distance filter. The dependency-free half of the problem lives in core; the half
that needs a library stays outside it.
Functions is the awkward consumer. It does not list core as a dependency at all,
which looks like an oversight and is not: firebase deploy uploads the
functions directory alone, and the builder installs from that manifest with npm,
which rejects the workspace: protocol outright. Even in dev dependencies it
would fail. So core is mapped by tsconfig paths to its built type declarations
for typechecking, and inlined into the bundle by esbuild for runtime.
The sharpest edge is on mobile. The package’s "react-native" field points
Metro at the source entry point, and removing it does not break the build. Metro
just falls through to main and starts silently compiling the built output
instead, which means mobile can be running stale code while every check passes.
That one is documented in place, because nothing about the failure announces
itself.
Deploy decisions the platform made
The web tier runs as a hand-built container on Cloud Run rather than on Firebase
App Hosting, and not by preference. App Hosting cannot build this repository:
its monorepo support is Nx-only, and because the web app depends on core through
workspace:*, a backend rooted at the app directory fails on a missing lock
file. Building a pnpm workspace in that buildpack is a known issue Firebase
closed as not planned.
The workaround turned out better than the thing it replaced. A hand-built
container can sit in africa-south1 next to Firestore, which puts server-side
reads at 1 to 5 ms instead of the 150 to 180 ms a European region would have
cost on every render.
Three functions cannot live in that region and run in europe-west1 instead.
Two of them are scheduled, and Cloud Scheduler has no African region; the third
is a first-generation auth trigger, and africa-south1 only supports second
generation. This is the kind of inconsistency that reads like a mistake to
anyone who finds it later, so it is documented as deliberate with the reason
attached. For the same reason Node is pinned at 22 across local development and
the functions runtime: the first-generation trigger cannot run on 24, and
developing against a runtime you never deploy to is a slow way to find out.
Getting events in without anyone typing them
The import pipeline is around 2,500 lines and is the part I would show first.
A discovery product is worth nothing with an empty catalogue, and Harare’s event
listings are not a tidy API. Some venues publish structured metadata, some have
an RSS or calendar feed, and a great many announce a show as a poster image and
nothing else. The pipeline reads all of it: HTML with embedded structured data,
feeds, calendar files, and poster images that go through sharp and then
Tesseract OCR.
Everything it reads becomes an evidence block. Blocks are packed into groups bounded by both count and total characters, then handed to a local model through Ollama, which returns structured events: title, venue, start and end, type, price text, recurrence, and a confidence.
The design decision I care about is that the model also returns the indexes of
the evidence blocks each event came from. A candidate therefore carries the
source names and URLs it was derived from, a snippet of the text it was read
out of, its confidence, and a needsReview flag. Extraction produces a proposal
with a citation trail, not a row in the database. Applying candidates is a
separate step, after deduplication and recurrence expansion.
That matters because OCR on a nightclub poster is exactly as reliable as it sounds. When a date comes out wrong, the question is which poster it came from, and the answer is attached to the record rather than lost in a log.
Small rules that carry weight
A few things in here are one-liners that are load-bearing, and they are the ones most likely to be tidied away by someone who does not know why they exist.
Write the geohash wherever you write coordinates. Proximity queries order by geohash, and Firestore omits documents missing an ordered field entirely. No error, no warning, the row is simply absent. A place saved without a geohash is invisible on the landing page, the feed, and the map simultaneously, and looks like it saved fine. Every write path has to set it alongside coordinates, and delete it when coordinates are cleared, or the document keeps a stale position.
The approved filter belongs inside the query, not after it. For a list operation the security rules engine has to prove the condition from the query itself, because it never sees the documents. Filtering for approved status after the fact does not narrow the read, it gets the whole read denied.
Opening hours never merge a day range past Sunday. The formatter collapses
runs of adjacent days that share hours, turning seven near-identical clauses
into two lines. But a place open Sunday and Monday to Friday, shut on Saturday,
has no honest single range: Mon – Sun claims Saturday it is not open, and
Sun – Fri reads as though it does too. So it stays two truthful lines. Seven
lines becoming two is the win; a wrong range is not a smaller one. The formatter
also passes the string straight through untouched when it does not parse,
because seeded and hand-authored places carry prose like Daily 7am–6pm and
reformatting that into something that merely looks parsed would be worse than
leaving it alone.
There is a good invisible-character bug in the same file. Google’s Places API separates the time from AM/PM with a narrow no-break space and uses an ordinary no-break space elsewhere, so two days with identical hours would refuse to merge over a character you cannot see. Hours are normalised before they are compared.
Type is read through a resolver, never directly. Place and event types are
optional on the model because documents predate the field. Reading .type
directly works on everything created since and silently misclassifies everything
older, so both are read through resolvers that fall back to classifying the
freeform tags.
Where it is
Live and under test, not launched. The catalogue is real Harare venues and the full path from organiser signup through publishing to ticket purchase works.
Honest about what is unfinished: the prominence tier is deferred by design, the shared UI package is an empty placeholder because nothing has yet needed component sharing across web and native, mobile authoring is mid-build, and a couple of helpers under the Firebase directory still duplicate logic that should have moved into core. The URL is also still a raw Cloud Run hostname, which is the next thing to fix.