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
- Backend: Elixir 1.19 / OTP 28, Phoenix 1.8 (REST, API-only), Ecto, PostgreSQL + PostGIS, Oban for background jobs, Guardian JWT auth, Bandit HTTP server.
- Frontend: React 19 + TypeScript on Vite 8, TanStack Query for all server state, React Router v7, Mapbox GL JS, Tailwind v4 design tokens, Framer Motion.
- AI: Claude Haiku (
claude-haiku-4-5) for submission moderation, called through a behaviour-wrapped client behind a circuit breaker. - Infra: Backend on Fly.io (us-east), frontend on Vercel, photos on Cloudflare R2, errors on Sentry, analytics on GoatCounter.
- Quality: Vitest + React Testing Library for units, Playwright for E2E across Chromium/Firefox/WebKit, Lighthouse CI budgets, Credo + Sobelow + Dialyxir on the Elixir side.
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.
- Advisory-only for user submissions. Even when Claude is highly confident, user submissions never auto-decide. The verdict, confidence, and reasons are recorded inline in the admin queue, but a human always makes the final call. False positives and false negatives both damage trust, and a real person submitting their favorite roaster deserves a real read.
- Auto-apply for crawler submissions. Crawler-sourced candidates with high confidence and no chain signals auto-approve and immediately enqueue enrichment. High-confidence chains and high-confidence non-specialty rejects auto-reject. Everything in between flags for review.
- Cost ceiling. A daily budget (Hammer-backed) caps total moderation calls per day. When the budget is exhausted, jobs return
:okwithout retrying, and submissions stay pending until the next window. - Circuit breaker, not silent failure. The Anthropic client sits behind a fuse (5 failures in 10s → open, 30s reset). When the circuit is open or the network blips, the worker leaves the submission pending so Oban will retry, instead of inventing a verdict.
- Feature-flagged.
AUTO_MODERATION_ENABLEDcan disable the whole worker mid-flight without a deploy, which is useful when iterating on the rubric or chasing unexpected behavior.
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
- Tuning the async pipeline. Figuring out which workers should exist, how often they should run, and how to keep them honest about data accuracy without stepping on each other took real iteration. The rule that workers flag rather than auto-update verified listings came directly out of that work.
- Native-feeling mobile web.I wanted the mobile experience to feel as close to a native app as I could get inside the browser's constraints. Sheet snap behavior, avoiding the iOS Safari focus-zoom on small inputs, sizing the cluster threshold for mobile pixel budgets, and keeping map, sheet, and filter state coherent across rotations and navigations was a long tail of small decisions.
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.