Dishd

Inspiration

The "where do you wanna eat?" group text — we've all spent 45 minutes negotiating dinner, lost to indecision and dueling Yelp tabs. We wanted Tinder's swipe-to-decide UX but for a group's collective taste, so picking a restaurant feels like a game instead of a chore.


What it does

One person spins up a session, drops a pin on the map, and gets a 4-digit code. Friends join from anywhere and everyone swipes yes/no on real nearby restaurants pulled from Google Places.

The instant every active member yes-swipes the same spot, an instant-match overlay fires with confetti.

If there's no unanimous match, you get a Top 3 leaderboard ranked by agreement %, each with a Gemini-powered personalized "why this fits you" note.

Solo mode is built in for when you're picking lunch alone.


How we built it

iOS App

  • SwiftUI (iOS 16+)
  • URLSessionWebSocketTask for realtime
  • Keychain for tokens
  • MapKit for pin + result map
  • iMessage app extension so invites work as a blue bubble

Backend

  • FastAPI (async)
  • MongoDB Atlas via Beanie ODM
  • JWT access + refresh tokens
  • slowapi rate limiting
  • Full security middleware:
    • CSP
    • HSTS
    • size limits
    • startup sanity checks

Realtime

Per-session WebSocket rooms broadcast:

  • member_joined
  • swipe_progress
  • instant_match
  • phase_change
  • top3_ready

AI Layer

Gemini 2.5 Flash generates:

  • vibe blurbs for restaurant cards
  • personalized fit narratives for each Top-3 pick

Cost Control

A 6-hour TTL cache over Google Places nearby-search queries keyed by rounded lat/lng, so a crowd in the same neighborhood hits Places once.


Challenges we ran into

Beanie + UUIDs

UUID fields are stored as BSON Binary subtype 4, so raw-dict queries with str(uuid) silently matched nothing. Cost us hours.

SwiftUI Navigation

Using @Environment(\.dismiss) inside a fullScreenCover + NavigationStack caused the nav stack to pop back to a stale swipe view that re-ran its .task, hit a 500, and landed on a mock list.

Fixed by routing every "close" action through a single onClose callback owned by the cover root.

Match-popup Race Conditions

The swipe ack and WS instant_match event both fired for the same yes-swipe, while three separate flows could request navigation to results.

Every navigation path now goes through one-shot guards.

Google Places Deprecation

The legacy Places API got deprecated mid-build and started returning REQUEST_DENIED.

We shipped:

  • a 20s timeout
  • mock fallback data

So the swipe stack is never empty during demos.

Async Generator Bug

Using await inside generator comprehensions returns an async generator that a sync for loop cannot iterate.

We hit this 500 twice before switching to explicit asyncio.gather for member fan-outs.


Accomplishments that we're proud of

  • A genuinely multiplayer, real-time app that works across multiple devices — not a demo with hardcoded state.
  • A production-shaped stack:
    • containerized backend
    • env-driven config
    • per-route rate limits
    • JWT refresh
    • push notifications
    • security headers
    • graceful degradation when the LLM key is missing
  • The iMessage extension — joining a session straight from a blue bubble feels like magic.
  • A query cache that keeps Google Places spend bounded even when judges hammer it.

What we learned

ODMs Aren't Free

Mongo's BSON quirks — especially UUID Binary subtype 4 — can absolutely bite you in production.

SwiftUI Navigation is Complex

NavigationStack + fullScreenCover interactions become messy quickly.

Explicit callbacks beat @Environment(\.dismiss) every time.

WebSocket Event Design Matters

Deduplication-safe events save you from race-condition whack-a-mole.

AI Features Need Graceful Degradation

DishMatch boots fine without a Gemini key and falls back to rule-based narratives, so the core flow never depends on a network call to an LLM.


What's next for Dishd

Atlas Vector Search

Replace yes-count math with a vector-based group welfare function over Gemini embeddings.

Already isolated behind the MatchingService boundary, so it's effectively an env-var flip.

Sudden-Death Tie-Breaker

A 3-second round between the Top 3 picks — first to N taps wins.

Live Preference Chips

Flash real-time chips like:

"Sarah liked this too"

on cards your friends already swiped.

Calendar + Reservation Hooks

Drop a match directly into:

  • iMessage
  • Google Calendar

with reservation info pre-filled.

Multi-City + Group Memory

Learn each crew's standing preferences across sessions, so the second time you swipe with the same friends, the stack already knows.

Share this project:

Updates