Back to projects
Self-directed web appLivePrivate repo

First Crack

A specialty coffee shop and roaster discovery platform for the Southeast US. Map-driven exploration with community-driven data, built for quality over quantity.

Origin

I brew coffee at home, and I love supporting the specialty community. It's a massive industry, but the local shops and small roasters are what make it interesting, and they're also the hardest to find. There are plenty of apps that surface coffee, but I noticed that finding the right place where you actually are still came with friction. The Southeast US in particular felt under-served.

First Crack started as a side project to scratch that itch and to get deeper with Elixir/Phoenix. It turned into something I genuinely care about: a companion that gives everyone a high-quality baseline experience but tailors itself to the way each person explores. The backing system is effectively a knowledge graph. Google Places is one source, but the data is actively enriched, scored, and re-validated over time.

Stack

The submission moderator

The piece I'm proudest of is the moderation pipeline. User-submitted shops and crawler-discovered candidates flow into a shared submissions table, then an Oban worker hands each one to Claude Haiku with a detailed rubric: independent vs. chain, single origin vs. drip-only, micro-roaster signals, and a confidence calibration scale.

The interesting part isn't the call itself; it's the policy around it.

The model is mocked behind a behaviour in tests, so the full decision tree (auto-approve, auto-reject, advisory-only, low-confidence flagging, budget exhaustion, circuit-open, parse failures, fetch timeouts) is covered without spending tokens.

Geospatial

PostGIS does the heavy lifting. Places are stored as geometry with a GIST index, and radius search uses ST_DWithinon the geography type so distances are real meters, not Cartesian approximations. The query builder supports a few sort modes (distance-first when a radius is set, full-text rank plus quality score otherwise), so the same endpoint serves “coffee near me” and “best in Atlanta” without two code paths.

Quality score is denormalized as a column and refreshed weekly by a maintenance worker with a freshness decay, so sort order stays cheap at query time and naturally rewards listings with recent verification.

The async pipeline

Oban runs roughly fifteen workers across moderation, enrichment, crawler, photos, notifications, and maintenance queues. New submissions kick off a chain: moderator → place details prefetcher → enrichment (website, phone, Instagram, hours) → brew-method extractor → photo fetcher (resized, WebP'd, written to R2). On the maintenance side, a freshness worker re-checks verified listings, a closure detector flags places that look permanently shut, and a quality sweep recomputes scores weekly.

A guardrail I committed to early: workers flag for review, they never silently update verified data.Enrichment populates new fields and surfaces conflicts; only the moderator (and humans) are allowed to change a listing's status.

Map and mobile UX

The frontend is a Vite-built React 19 SPA. Mapbox is rendered as symbol layers only, no DOM markers, with canvas-drawn pins for status (open, closing soon, closed, unknown) and type (cafe, roaster, both). Selection happens via setFilter on the existing layers rather than re-issuing the source data, which keeps interaction snappy even with hundreds of pins on screen.

Clustering thresholds are split by viewport: 120 places on mobile, 250 on desktop. The pixel budget on a phone is so much tighter that density which reads sparse on a laptop becomes a wall of pins otherwise. Camera state (center, zoom, bearing, pitch) is cached per city so coming back to a page lands you where you left off.

Server state is owned by TanStack Query. There's no Redux or Zustand. Auth lives in a context, filters live where they're used, and everything else flows through query keys with sensible staleTimes. A small deploy watcher polls a prerendered /version.json against a build ID baked into the bundle and gracefully signs sessions out when the server has shipped a newer client.

Decisions

Why Elixir/Phoenix

I write Elixir/Phoenix professionally and wanted a real personal project to push my skills further. It turned out to also be a great fit on the merits: Elixir/OTP is genuinely fun to write, Phoenix is performant well past anything I need, and Oban gives me a robust job queue without a Redis or external service to operate. Pattern matching and immutability remove a class of state bugs in a system that's mostly background work and pipelines.

Why Fly.io

Postgres co-location, regional latency, and reliability for the backend. The app server and database live in the same region, the deploy story is just a Dockerfile and a release command, and I don't need multi-region until I do. The frontend is on Vercel, which is the right tool for that side.

Why Claude Haiku

For the moderation rubric, Haiku follows instructions accurately, returns structured JSON reliably, and is cheap enough that the daily budget is more about defense-in-depth than affordability. I've considered an open-source model as a fallback, and may add one if API pricing across Anthropic's tiers ever shifts meaningfully. Right now the value is clearly there.

Hardest problems

What's next

Continuing to fully cover the Southeast (more cities, deeper data, more curated lists) and using real user interactions and feedback to refine the experience. The goal is to turn First Crack into a companion people actually reach for when they're looking for good coffee, wherever they are.

Tech