A Pokédex for public libraries. Walk into one of Munich's 110 libraries, check in, and unlock it as a collectible with a rarity tier.
| Collection | Library detail | Check-in | Map |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
Landing page: bibdex.lenhard.xyz · iOS build in testing, not yet on the App Store.
Munich has 110 public and university libraries. Most students know two of them. The good ones stay empty during exam season while everyone fights for a seat at the Gasteig.
BibDex treats them as a collection. Each library has a rarity (common, rare, epic, legendary), you unlock it by physically standing in it, and repeat visits level it up. The reward loop is deliberately cheap to build and expensive to fake: check-in requires GPS proximity, so the only way to fill your Dex is to actually go.
Everything is real: a 110-library dataset with verified coordinates and opening hours, 42 of them curated and live in the app so far, plus accounts, persistent progress, and an admin console to curate the rest.
Discovery and check-in. expo-location finds the nearest library by Haversine distance and offers a one-tap check-in from the home screen. Four-hour cooldown per library. First unlock is worth 300 knowledge points, repeat visits 50.
Progression. Knowledge points, per-library levels and XP, and a daily streak computed from the last check-in date. All of it survives reinstalls.
Accounts. Email/password and Google Sign-In, a name-collection onboarding flow for new users, editable profile, and account deletion that actually cleans up Firestore.
Map. Rarity-coloured pins over Munich with a filter sheet for status and rarity, plus a blur-backed control stack.
Admin console. A separate Vite app at admin/ for CRUD over the library catalogue, with search, filter, multi-select, bulk archive/restore, and Google Sign-In gated on a single UID.
Public site. Static German-language landing page and legal pages (Impressum, Datenschutzerklärung, Nutzungsbedingungen) under web/, deployed to a second Firebase Hosting target.
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Expo RN app │ │ Firestore │ │ Vite admin │
│ (iOS) │────▶│ │◀────│ console │
│ │ │ libraries │ │ │
│ Zustand store │◀────│ users/{uid} │ │ Google auth │
│ 2× onSnapshot │ │ ├ libraryProg. │ │ + UID gate │
└─────────────────┘ │ └ checkIns │ └─────────────────┘
└──────────────────┘
▲
firestore.rules ── single authorization boundary
The client holds no server. libraryStore.js opens two onSnapshot subscriptions, one on the global libraries collection and one on users/{uid}/libraryProgress, and merges them into a single list. Neither collection knows about the other, so a curator archiving a library in the admin console propagates to every device without touching user progress.
A check-in writes three documents in one writeBatch: an immutable event in users/{uid}/checkIns, an upsert on the per-library progress doc, and counter increments on the user doc. The local store updates optimistically first and rolls back if the batch fails.
Stored aggregates over recomputation. totalKP, totalCheckIns, unlockedCount and streak live on the user document and are mutated with Firestore's increment(). Deriving them would mean reading the whole checkIns subcollection on every app launch, which grows without bound and costs a read per event. The trade is that the aggregates can drift if a write half-fails; batching all three paths together is what keeps that from happening.
Optimistic writes. The check-in animation fires before Firestore acknowledges anything. On a phone at the back of a library basement, waiting for a round trip would make the core interaction feel broken. The rollback path in checkIn() restores both the library entry and the profile counters on failure.
React Native over Flutter. BibDex is closer to a game than a utility, which argues for Flutter's animation story. It went to React Native anyway, because the app leans hard on platform-native chrome: real UIKit tab bars via @bottom-tabs/react-navigation, native stack navigation with iOS swipe-back, and large titles that coordinate with scroll position. Flutter draws its own widgets and would have meant reimplementing all of that.
Firebase over Supabase or a custom backend. One developer, no devops budget, and a data model that is mostly key-value lookups rather than joins. Firestore's free tier covers the MVP at zero cost. The lock-in is real and acknowledged; the reasoning and the cost modelling are written up in docs/architecture-decisions.md.
Platform maps over OpenStreetMap tiles. The original plan was OSM tiles through a custom UrlTile layer to avoid Google Maps billing. In practice react-native-maps on the platform default gives better gesture handling and clustering for free, and the map is not the primary surface. Revisit if Android usage makes billing bite.
One source of truth for the design system. Rarity colours, glows and labels live in a single RARITY map, and every neutral colour is a token in src/theme/colors.js with a light and a dark value. No component hardcodes a hex for anything neutral, which is why full dark mode was a day of work rather than a rewrite.
Requires Node 18+, and Xcode for the iOS build.
git clone https://github.com/moOritzl/BibDex.git
cd BibDex
npm install
npx expo run:ios # or: npx expo startThe admin console is a separate app:
cd admin
npm install
npm run dev # http://localhost:5173The public site is static, no build step:
cd web
python3 -m http.server 8000Note that firebase.json sets cleanUrls: true, so live URLs are /privacy while the local server needs /privacy.html.
On the committed Firebase config: the apiKey in src/services/firebase.js and admin/src/firebase.js is checked in deliberately. Firebase web API keys are public identifiers, not secrets; they identify the project and nothing more. Access control lives entirely in firestore.rules, which is also in this repo and is the only thing standing between a client and the data.
src/
├── screens/ # home, collection, detail, map, login, onboarding, profile
├── components/ # 10 presentational components incl. CheckInModal, LibraryCard, RarityBadge
├── store/ # libraryStore.js — Zustand, subscriptions, check-in batch
├── services/ # firebase.js, libraryData.js (generated), mockData.js
└── theme/ # colors.js (tokens + RARITY), useTheme.js
admin/ # Vite + React admin console
web/ # static landing + legal pages
scripts/ # convertLibraries.js, seedFirestore.js
firestore.rules # the authorization boundary
Built with Claude Code as a pair-programming tool. CLAUDE.md is the working spec that makes that productive: the architecture, the design tokens, the data model, and a running list of the gotchas that bit me (native tab navigator quirks, iOS shadow clipping under overflow: hidden, Reanimated peer deps). It is more detailed than this README on purpose, because it is written for a collaborator who needs to not break things.
The architectural calls above are mine. The typing was frequently not.
- Wire the challenges screen to live
libraryListstate; it currently renders static values. - Cloud Function to delete the
users/{uid}/checkInssubcollection on account deletion. The client SDK cannot batch-delete documents whose IDs it does not know, so this cleanup is incomplete today. - Android build and testing. Everything so far is iOS-only.
- Leaderboards, and expanding the catalogue beyond Munich.
All rights reserved. See LICENSE.
Moritz Lenhard · contact@lenhard.xyz



