Wandrmark is a travel exploration app that combines POI discovery with gamification. Users can explore places near them, plan multi-stop routes, and earn rewards through the "Explorer Passport" system.
No auth. No database. No login. All user state lives in localStorage.
- Frontend: Next.js 14, TypeScript, Tailwind, Leaflet maps
- Backend: Express.js, Redis (optional — caching only), NVIDIA NIM AI
- APIs: Overpass (POIs), Nominatim (geocoding), OSRM (routing)
1. page.tsx mounts
├─ Sets default center: NYC (40.7128, -74.0060)
└─ Initializes state (mode=explorer, pois=[], plannerPois=[])
2. useEffect runs
├─ Checks browser geolocation
├─ Success: setMapCenter(user's location)
└─ Failure: Use default NYC location
3. Call load(mapCenter)
├─ usePOIs hook: sets loading=true
└─ Calls fetchPOIs(center, 1500m, categories)
4. Frontend → POST /api/proxy/overpass
├─ Backend checks Redis cache
├─ Miss: Query Overpass API
├─ Save to Redis (TTL: 1hr)
└─ Return POI data
5. Frontend maps OSM elements → POI objects
├─ Extract name, category, coordinates
├─ Filter nulls
└─ Limit to 30 POIs
6. setPois(results) → UI updates
├─ ExplorerSidebar shows list
├─ WayvMap shows markers
└─ Loading indicator disappears
7. PassportPanel initializes
├─ Load from localStorage: wandrmark_user_progress
└─ If none: Create new progress (level 1, 0 XP)
index.ts calls startCacheWarmer() if Redis is healthy
↓
checkInsightsEmpty() — checks if "New York" has AI insights cached
├─ Empty → warmTopCities() fires immediately (background)
│ Step 1/3: Nominatim geocoding for all cities
│ Step 2/3: Overpass POI fetch for all cities
│ Step 3/3: NVIDIA NIM AI insights for all cities
└─ Populated → skip, next run Sunday midnight UTC
Weekly cron: 0 0 * * 0 (Sunday midnight)
└─ warmTopCities() — refreshes all caches
AI insights auto-skip if TTL > 1 day (7-day cache)
User types "Paris" → Presses Enter
↓
handleSearch()
├─ setSearchLoading(true)
└─ geocodeSearch("Paris", 5)
↓
GET /api/proxy/nominatim/search?q=Paris&limit=5
├─ Backend checks Redis (TTL: 24hrs)
├─ Miss: Query Nominatim API
└─ Return 5 results
↓
Dropdown appears with cities
├─ 📍 Paris, Île-de-France, France
├─ 📍 Paris, Texas, USA
└─ ... (3 more)
↓
User clicks "Paris, France"
↓
onSearchResult(48.8566, 2.3522) ← map & POIs update
setInsightCityName("Paris, France") ← triggers city insights fetch
↓
POST /api/ai/city-insights { cityName: "Paris, France" }
├─ Backend checks Redis (TTL: 7 days, pre-warmed by cron)
├─ Hit: Return instantly
└─ Miss: Generate via NVIDIA NIM → cache → return
↓
ExplorerSidebar shows city insights card (above POI list):
├─ Overview paragraph
├─ Highlight chips (landmarks, experiences)
├─ 🏛️ Historical fact
└─ 💡 Local tip
Key Detail: load() is called immediately when a city is selected, before the map finishes animating. City insights fetch runs in parallel.
User clicks marker → onPoiClick(poi)
↓
setSelectedPoi(poi) → Modal opens
↓
gamificationService.visitPOI(poi)
↓
┌─ Already visited? → Return {isNew: false}
└─ First visit →
├─ Mark visited: visitedPOIs.add(poi.id)
├─ Create stamp:
│ ├─ reverseGeocode(coordinates) → "7th arr, Paris, FR"
│ ├─ Calculate rarity (common/rare/legendary)
│ └─ Save stamp to passport.stamps[]
├─ Update stats: poisVisited++
├─ Award XP: +10
├─ Check level up: xp >= xpToNextLevel?
├─ Check achievements: Visit 1/50/100 POIs?
├─ Mystery box: Every 10 POIs
└─ saveProgress() to localStorage
↓
PassportPanel updates in real-time
├─ Level bar increases
├─ New stamp appears
└─ Statistics update
User adds POIs to planner (clicks "+" or "Add to Planner")
↓
addToPlanner(poi)
├─ setPlannerPois([...prev, poi])
└─ Triggers:
├─ Map: Numbered markers (1,2,3...)
├─ Planner badge: "3 stops → View Route"
└─ Clear route (segments=[])
↓
User switches to Planner mode
↓
PlannerSidebar shows:
├─ Drag-to-reorder list
├─ Transport mode selector (🚶🚴🚗🚌)
└─ "Compute Route" button
↓
User selects transport mode & clicks "Compute Route"
↓
computeTheRoute()
├─ Validate: 2+ POIs with real coordinates
├─ setRouteLoading(true)
└─ For each POI pair (A→B, B→C, C→D):
└─ GET /route/v1/foot/{lon1},{lat1};{lon2},{lat2}
↓
OSRM returns:
{
distance: 1234.5 (meters)
duration: 987.6 (seconds)
geometry: [[lng,lat], [lng,lat], ...]
}
↓
Convert to RouteSegment:
{
from: POI_A,
to: POI_B,
distance: 1234.5,
duration: 987.6,
geometry: [{lat,lng}, ...]
}
↓
Aggregate all segments → setRouteSegments()
↓
Map renders:
├─ Blue polylines connecting POIs
└─ Auto-fit bounds to show full route
↓
Sidebar shows summary:
├─ Total: 3.2 km, 45 min
└─ Each segment with details
1. Levels & XP
- Start at level 1 (Tourist)
- Earn 10 XP per new POI
- Formula:
xpToNextLevel = 100 * 1.5^(level-1) - Titles: Tourist → Traveler → Explorer → Local Guide → City Expert → Legend
2. Stamps
- Earned when visiting new neighborhood
- Contains: neighborhood, city, country, coordinates, rarity
- Rarity: common (major city + hotspot), rare (major city), legendary (small town)
3. Achievements
- Predefined goals (visit 1/50/100 POIs, walk 26 miles, etc.)
- Tiers: bronze, silver, gold, platinum
- Auto-unlock when requirements met
4. Quests
- Daily challenges (e.g., visit 3 POIs today)
- Progress tracking (0-100%)
- Rewards: XP + mystery box
5. Mystery Boxes
- Earned every 10 POIs or quest completion
- Can be opened for AI-generated rewards
All data in localStorage:
wandrmark_user_progress: {
passport: {
stamps: [],
badges: [],
statistics: { poisVisited, citiesVisited, ... },
level: { level: 1, xp: 0, title: "Tourist" }
},
activeQuests: [],
achievements: [],
mysteryBoxes: []
}
wandrmark_visited_pois: ["poi-id-1", "poi-id-2", ...]┌─────────────────────────────────────────────────────┐
│ FRONTEND │
│ ┌────────────────────────────────────────────┐ │
│ │ page.tsx (Main orchestrator) │ │
│ │ • State: mode, mapCenter, selectedPoi, │ │
│ │ plannerPois, routeSegments │ │
│ └────────────────────────────────────────────┘ │
│ │ │
│ ┌────────┴──────────┬──────────┬──────────┐ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌────────┐ ┌──────────┐ ┌──────┐ ┌──────────┐ │
│ │Explorer│ │ WayvMap │ │Plan │ │Passport │ │
│ │Sidebar │ │(Leaflet) │ │Sidebar│ │Panel │ │
│ │+Insights│ └──────────┘ └──────┘ └──────────┘ │
│ └────────┘ │
│ │
│ ┌──────────────────── Services ─────────────────┐ │
│ │ • overpass.ts - Fetch POIs │ │
│ │ • nominatim.ts - Geocoding │ │
│ │ • routing.ts - OSRM routes │ │
│ │ • gamification - Passport system │ │
│ │ • api.ts - Backend AI calls │ │
│ └──────────────────────────────────────────────┘ │
└─────────────┬───────────────────────────────────────┘
│
▼ HTTP Requests
┌─────────────────────────────────────────────────────┐
│ BACKEND │
│ ┌──────────────────── Routes ──────────────────┐ │
│ │ • /api/proxy/overpass - POI proxy │ │
│ │ • /api/proxy/nominatim - Geocoding proxy │ │
│ │ • /api/proxy/osrm - Routing proxy │ │
│ │ • /api/ai/recommendations - AI suggestions │ │
│ │ • /api/ai/travel-tips - POI tips │ │
│ │ • /api/ai/city-insights - City facts/hist │ │
│ │ • /api/ai/neighborhood-fact - Stamp facts │ │
│ │ • /api/ai/historical-context - POI history │ │
│ │ • /api/ai/city-summary - Trip summary │ │
│ │ • /api/ai/usage - NIM usage stats │ │
│ │ • /api/cache/* - Cache mgmt │ │
│ │ • /api/feedback/* - Bug reports/stars│ │
│ └──────────────────────────────────────────────┘ │
│ │
│ ┌──────────────── Services ────────────────────┐ │
│ │ • cache.ts - Redis caching │ │
│ │ • nim.ts - NVIDIA NIM AI integration │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ ┌──────────────── Scheduler ───────────────────┐ │
│ │ • Startup: warm cache if empty │ │
│ │ • Weekly: Sunday midnight UTC (cron) │ │
│ └──────────────────────────────────────────────┘ │
└──────┬──────────────────────────────────────────────┘
│ │
▼ ▼
┌─────┐ ┌──────────────┐
│Redis│ │NVIDIA NIM │
│ │ │(cloud API) │
└─────┘ └──────────────┘
State:
mode: explorer | plannermapCenter: { lat, lng }selectedPoi: POI | nullplannerPois: POI[]routeSegments: RouteSegment[]
Key Callbacks:
handlePoiClick: Show modal + gamificationhandleMapMoved: Load POIs for new centerhandleSearchResult: Jump to city + load POIsaddToPlanner: Add POI to routecomputeTheRoute: Calculate route
Features:
- OpenStreetMap tiles
- POI markers (category-specific)
- Planner markers (numbered 1,2,3)
- Route polylines (blue lines)
- Auto-pan to selected POI
- Auto-fit bounds for routes
Important:
moveEndHandlerRefprevents duplicate POI loads during programmatic pans- Disabled for 500ms when center changes from search
Features:
- City search with geocoding dropdown
- City insights card (shown after city search): overview, highlights, historical fact, local tip — collapsible, shown above POI list
- POI list (filtered by categories)
- Favorite button (♥)
- Add to planner (+)
City insights flow:
- Triggered when user selects a geocode result
- Calls
POST /api/ai/city-insightswith the city name - Shows skeleton while loading, collapses via toggle
- Served from Redis cache (7-day TTL, pre-warmed by cron)
Features:
- Transport mode selector
- Drag-to-reorder POI list
- Remove POI (×)
- Compute route button
- Route summary (distance, duration, segments)
Features:
- Level badge with XP progress bar
- 5 tabs: Overview, Stamps, Quests, Achievements, Mystery Boxes
- Real-time updates when POI visited
Full interactive docs:
GET /api/docs(Swagger UI, protected byx-cache-secretin production).
Rate limit: 15 req / 15 min per IP.
| Endpoint | Input | Cache TTL | Description |
|---|---|---|---|
POST /recommendations |
selectedPois[], userPreferences?, mood? |
30 min | 3-5 nearby place suggestions |
POST /travel-tips |
poi |
1 hr | Description, tips, local insights for a POI |
POST /city-insights |
cityName |
7 days | Overview, highlights, history, local tip for a city |
POST /neighborhood-fact |
neighborhood, city |
7 days | Engaging fact for stamp collection |
POST /historical-context |
name, category, address |
7 days | 2-3 sentence historical background for a POI |
POST /city-summary |
cityName, neighborhoodsVisited[], poisVisited |
1 hr | Personalized trip summary |
GET /usage |
— | — | NIM call counts & estimated token usage (per-endpoint + daily) |
Rate limit: 30 req / min per IP.
| Endpoint | Cache TTL | Description |
|---|---|---|
POST /overpass |
1 hr | POI data via grid-based spatial cache keys |
GET /nominatim/search |
24 hrs | Forward geocoding |
GET /nominatim/reverse |
24 hrs | Reverse geocoding |
GET /osrm/route?profile=&coordinates= |
1 hr | Route calculation; profiles: foot, bike, car |
| Endpoint | Auth | Description |
|---|---|---|
GET /health |
— | Redis health check |
POST /warm |
x-cache-secret |
Trigger manual cache warm (mode: top / all / geocoding) |
DELETE /clear |
x-cache-secret |
Flush cache keys matching pattern |
GET /stats |
— | Key counts by namespace (overpass / nominatim / ai) |
GET /usage |
x-cache-secret |
Request counts per IP per day |
GET /usage/:ip |
x-cache-secret |
Request counts for a single IP |
| Endpoint | Auth | Description |
|---|---|---|
GET /stats |
— | Top cities (city-insights), top geocode searches, POI category counts, transport mode counts, daily activity |
What's tracked (fire-and-forget, zero latency impact):
- City insights (
POST /ai/city-insights) → city name added tocitiessorted set - Geocode search (
GET /proxy/nominatim/search?q=) → query added tosearchessorted set - Overpass POI fetch (
POST /proxy/overpass) → categories incremented incategoriessorted set - Route request (
GET /proxy/osrm/route) → transport mode incremented intransportsorted set
All counters persist in Redis sorted sets (no TTL). Daily totals are stored in a separate hash per day.
| Endpoint | Auth | Description |
|---|---|---|
GET /stats |
— | Aggregate counts: { stars, bugReports } (O(1), no data fetch) |
POST /bug |
— | Submit a bug report (5 req/hr rate limit) |
GET /bugs |
x-cache-secret |
List all bug reports |
GET /star |
— | Get total star count + whether caller IP has starred |
POST /star |
— | Toggle star for caller IP |
| Endpoint | Description |
|---|---|
GET /api/health |
Server health: Redis status, NIM config |
GET /api/docs |
Swagger UI (protected in production) |
The cache warmer runs in 3 steps for all cities in backend/src/data/cities.ts (~200 cities):
Step 1/3: Nominatim geocoding
• Rate: 1 req/sec (Nominatim limit)
• TTL: 24 hours
• Skip if TTL > 12 hours
Step 2/3: Overpass POI fetch
• Rate: 1 req/5 sec
• TTL: ~2 weeks (OVERPASS_TTL × 14)
• Sorted by cache miss count (most-missed first)
Step 3/3: NVIDIA NIM AI city insights
• Rate: 1 req/500ms
• TTL: 7 days
• Skip automatically if TTL > 1 day
Scheduling:
- On startup: runs immediately if AI insights cache is empty (Redis key for "New York" not found)
- Weekly cron: every Sunday at 00:00 UTC — refreshes expired entries
Frontend → POST /api/proxy/overpass
↓
Backend checks Redis: overpass:grid:{lat}:{lng}:{radius}:{categories}
├─ Hit: Return cached
└─ Miss:
↓
POST https://overpass-api.de/api/interpreter
query: [out:json]...(node["amenity"="restaurant"]...)...
↓
Overpass returns OSM elements
↓
Save to Redis (TTL: ~2 weeks for pre-warmed, 1hr for live)
↓
Return to frontend
↓
Frontend maps elements to POI objects
User selects city from geocode dropdown
↓
Frontend → POST /api/ai/city-insights { cityName: "Paris, France" }
↓
Backend normalizes: "Paris, France" → "paris" (cache key)
↓
Check Redis: wandrmark:ai:city-insights:paris
├─ Hit: Return { overview, highlights, historicalFact, localTip, cached: true }
└─ Miss:
↓
NVIDIA NIM prompt: "Provide travel insights for: Paris..."
JSON response parsed → fallback on parse error
↓
Save to Redis (TTL: 7 days)
↓
Return { ...insights, cached: false }
Frontend → GET /api/proxy/nominatim/search?q=Paris
↓
Backend checks Redis
├─ Hit: Return cached
└─ Miss:
↓
GET https://nominatim.openstreetmap.org/search
↓
Save to Redis (TTL: 24hrs)
↓
Return to frontend
Frontend → GET /api/proxy/osrm/route?profile=foot&coordinates=2.3522,48.8566;2.2945,48.8584
↓
Backend checks Redis (TTL: 1 hr)
├─ Hit: Return cached
└─ Miss:
↓
GET http://router.project-osrm.org/route/v1/foot/{coordinates}
?overview=full&geometries=geojson
↓
OSRM returns route with geometry
↓
Save to Redis (TTL: 1 hr)
↓
Return to frontend
↓
Frontend converts GeoJSON [lng,lat] → LatLng {lat,lng}
1. Add type:
// types/index.ts
export type POICategory = "restaurant" | "cafe" | "attraction" |
"park" | "museum" | "hotel";2. Add config:
// utils/constants.ts
hotel: {
emoji: "🏨",
markerColor: "#EC4899",
bgColor: "bg-pink-500/[0.15]",
borderColor: "border-pink-500/[0.3]",
textColor: "text-pink-300",
}3. Update Overpass query:
// services/overpass.ts
case "hotel":
queries.push(`node["tourism"="hotel"](around:${radius},...);`);
queries.push(`way["tourism"="hotel"](around:${radius},...);`);
break;4. Update mapping:
// services/overpass.ts
else if (tags.tourism === "hotel") category = "hotel";5. Add to defaults:
// hooks/usePOIs.ts
const [activeCategories] = useState([
"restaurant", "cafe", "attraction", "park", "museum", "hotel"
]);Done! Hotels now appear everywhere.
Check:
- Console for errors
- Network tab:
/api/proxy/overpassrequest - Redis connection:
docker compose ps - Overpass query format in logs
Common fixes:
- Reduce radius (1000 instead of 1500)
- Restart Redis:
docker compose restart redis - Clear cache:
docker exec -it wandrmark-redis redis-cli FLUSHALL
Check:
- Only appears after searching and selecting a city from the geocode dropdown
- Requires
NVIDIA_API_KEYto be set (otherwise silently fails) - Network tab:
POST /api/ai/city-insights - Backend logs:
[CACHE HIT]or[CACHE MISS]for city insights
Check:
- Leaflet CSS imported:
import "leaflet/dist/leaflet.css" - Container has height:
height: 100vh - Ref is set:
<div ref={containerRef} />
Check:
- localStorage available:
localStorage.getItem('wandrmark_user_progress') - Quota not exceeded
- saveProgress() is called (add console.log)
Check:
- POI coordinates valid (not 0,0)
- OSRM endpoint accessible
- Transport mode supported
1. Memoize expensive components:
export default React.memo(POICard);2. Debounce map movements:
const handleMapMoved = debounce((center) => load(center), 500);3. Virtualize long lists:
npm install react-window4. Cache markers instead of recreating:
const markerCache = useRef(new Map());
// Reuse existing markers, only create new oneswandrmark/
├── frontend/
│ ├── src/
│ │ ├── app/
│ │ │ ├── page.tsx # Main app component
│ │ │ └── layout.tsx # Root layout
│ │ ├── components/
│ │ │ ├── Navbar.tsx
│ │ │ ├── ExplorerSidebar.tsx # POI list + city search + insights
│ │ │ ├── PlannerSidebar.tsx
│ │ │ ├── WayvMap.tsx # Leaflet map
│ │ │ ├── PassportPanel.tsx # Gamification UI
│ │ │ ├── POIDetailCard.tsx
│ │ │ ├── AIRecommendPanel.tsx
│ │ │ └── CategoryFilter.tsx
│ │ ├── hooks/
│ │ │ ├── usePOIs.ts # POI state management
│ │ │ └── useFavorites.ts # Favorites localStorage
│ │ ├── services/
│ │ │ ├── overpass.ts # POI fetching
│ │ │ ├── nominatim.ts # Geocoding
│ │ │ ├── routing.ts # OSRM routing
│ │ │ ├── gamification.ts # Passport system
│ │ │ └── api.ts # Backend AI calls (incl. city insights)
│ │ ├── types/
│ │ │ ├── index.ts # Main types
│ │ │ └── gamification.ts # Passport types
│ │ └── utils/
│ │ └── constants.ts # Category configs
│ └── package.json
├── backend/
│ ├── src/
│ │ ├── routes/
│ │ │ ├── proxy.ts # API proxies (Overpass + Nominatim + OSRM)
│ │ │ ├── ai.ts # All AI endpoints incl. city-insights + usage
│ │ │ ├── cache.ts # Cache management endpoints
│ │ │ ├── feedback.ts # Bug reports and star ratings
│ │ │ └── analytics.ts # Usage analytics (cities, searches, categories, transport)
│ │ ├── services/
│ │ │ ├── cache.ts # Redis wrapper + CacheKeys + TTLs
│ │ │ ├── nim.ts # NVIDIA NIM AI integration
│ │ │ ├── nimUsage.ts # NIM call tracking (Redis counters)
│ │ │ ├── usage.ts # Per-IP request tracking
│ │ │ ├── feedback.ts # Bug report + star storage
│ │ │ └── analytics.ts # Analytics sorted sets + daily hashes
│ │ ├── scripts/
│ │ │ ├── warmCache.ts # Cache warming orchestrator (3 steps)
│ │ │ └── warmGeocoding.ts # Nominatim geocoding warmer
│ │ ├── data/
│ │ │ └── cities.ts # ~200 major cities list
│ │ ├── scheduler.ts # Cron (weekly) + startup empty-check
│ │ └── index.ts # Express server
│ └── package.json
└── docker-compose.yml # Redis + RedisInsight
# Backend (.env)
NVIDIA_API_KEY=nvapi-your-key-here # Required for AI features
NIM_MODEL=meta/llama-3.1-8b-instruct
NIM_BASE_URL=https://integrate.api.nvidia.com/v1
NIM_TIMEOUT_MS=30000
PORT=3001
REDIS_URL=redis://localhost:6379 # Optional
# Frontend (.env.local)
NEXT_PUBLIC_API_BASE_URL=http://localhost:3001/api
NEXT_PUBLIC_OSRM_URL=http://router.project-osrm.org# Install
npm install
# Configure (set NVIDIA_API_KEY in .env)
cp .env.example .env
cp .env backend/.env
# Start infrastructure (Redis)
docker compose up -d
# Start app
npm run dev
# → Frontend: http://localhost:3000
# → Backend: http://localhost:3001
# Optional: manually trigger cache warm (geocoding + POIs + AI insights)
cd backend && npm run warm-cache
# View Redis data
# Open http://localhost:5540 (RedisInsight)What happens when app loads:
- Get user location → Load nearby POIs → Show on map
- POIs fetched from Overpass API (cached in Redis)
- Backend checks if AI insights cache is empty — warms all cities if so
- Passport system loads from localStorage
What happens when user searches city:
- Geocode query → Get coordinates → Jump to location
- Immediately fetch POIs for new location
- Fetch AI city insights (overview, highlights, history, local tip)
- Insights served from Redis cache (7-day TTL, pre-warmed by cron)
What happens when user clicks POI:
- Show detail modal
- Check if first visit → Create stamp → Award XP
- Check level up → Check achievements → Check mystery box
- Save progress to localStorage
- Update Passport UI in real-time
What happens when user computes route:
- Validate 2+ POIs with real coordinates
- For each pair: Query OSRM for route segment
- Aggregate segments → Calculate totals
- Draw polylines on map → Fit bounds
- Show summary in sidebar
Key files to understand:
frontend/src/app/page.tsx— Main state & logicfrontend/src/hooks/usePOIs.ts— POI loading & filteringfrontend/src/services/gamification.ts— Passport systemfrontend/src/components/ExplorerSidebar.tsx— POI list + city insightsfrontend/src/components/WayvMap.tsx— Map renderingbackend/src/routes/proxy.ts— API proxy with cachingbackend/src/routes/ai.ts— All AI endpointsbackend/src/services/nim.ts— NVIDIA NIM wrapperbackend/src/scripts/warmCache.ts— Cache warming (3 steps)backend/src/scheduler.ts— Startup check + weekly cron
With this guide, you should be able to navigate the codebase, understand data flows, add features, and debug issues effectively.