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 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.
One obvious bound does not work. Putting limit() on a geohash range looks like
the way to cap reads, and it corrupts 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 trades a geographic bias for a distance one.
Ordering the range by prominence 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.
That scheme is designed and index-verified but not built. At around 800 approved places a whole-window read measures about a second in region and 110 KB gzipped, so a tier field would be machinery nobody uses. The conditions that should bring it back are written down: 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.
Four hundred pins and a quarter of a legend
The map has 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 not enough on its own. 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, 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, since having the thing you just tapped vanish would be worse than the crowding.
- Label collision culling, unchanged, now operating on a small enough set to work.
There is 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 sets its main 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 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 does need geofire-common, so each surface calls
that itself and 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, 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
falls through to main and silently compiles the built output instead, so
mobile can be running stale code while every check passes. That one is
documented in place, because the failure does not announce 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. That inconsistency 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 wastes time.
Getting events in without anyone typing them
The import pipeline is around 2,500 lines.
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 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 model also returns the indexes of the evidence blocks each event came from.
So a candidate 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 unreliable. 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 one-liners in here are load-bearing and easy to tidy 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 at the same time, 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 correct single range: Mon – Sun claims Saturday it is not open, and
Sun – Fri reads the same way. So it stays two lines. 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 looks parsed would be worse than leaving it alone.
There is an 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 refused 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.
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.