IMPORTANT: update claude.md as things change and it becomes outdated.
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Ballot is a React Native mobile app built with Expo Router for discovering and curating local political/civic events. Features card-based browsing with advanced gesture interactions (swipe, flip, delete) and saved events management.
# Install dependencies
npm install
# Start development server
npm start
# Run on specific platform
npm run ios # iPhone 16 Pro simulator
npm run android # Android emulator
npm run web # Web browserThe app is configured with server-side export for web deployment (supports server functions and API routes).
# Export web app to dist/ directory (run before every deploy)
npx expo export --platform web
# Deploy to EAS Hosting
eas deployConfiguration: app.config.js has expo.web.output: "server" for full server-side capabilities.
The app is configured for iOS publishing with EAS Build.
# Build for iOS production
eas build --platform ios --profile production
# Submit to App Store (after build completes)
eas submit --platform ios --profile productionBuild Profiles (defined in eas.json):
development: Development client with simulator supportpreview: Internal distribution for TestFlightproduction: App Store production builds with auto-increment
Before first iOS submission, update eas.json with:
appleId: Your Apple ID emailascAppId: App Store Connect app IDappleTeamId: Your Apple Developer Team ID
Bundle Identifier: com.ballot.app (set in both app.config.js and eas.json)
# Build for Android production
eas build --platform android --profile production
# Submit to Google Play Store
eas submit --platform android --profile productionBefore first Android submission, add Google Play service account key to eas.json:
- Generate service account JSON from Google Play Console
- Update
serviceAccountKeyPathineas.json
Package Name: com.ballot.app
- Development: Use
eas build --profile developmentfor testing with development client - Preview: Use
eas build --profile previewfor internal testing (TestFlight/Internal tracks) - Production: Use
eas build --profile productionfor App Store/Google Play submission
Important: Always run npx expo export --platform web before deploying web changes.
- Framework: React Native 0.81.5 with Expo ~54.0.18
- Routing: Expo Router ~6.0.13 (file-based routing)
- Language: TypeScript (strict mode)
- Animation: react-native-reanimated ~4.1.1 (60fps native thread)
- Gestures: react-native-gesture-handler ~2.22.1
- State: Context API + useReducer (no Redux)
- SVG: react-native-svg with metro transformer
- Storage: @react-native-async-storage/async-storage ~2.2.0 (event caching & persistence)
- AI: OpenRouter API with Perplexity Sonar Pro model (civic event generation)
- Environment: expo-constants + dotenv (API key management)
The project uses .env.local for API keys and configuration:
OPENROUTER_API_KEY=... # Required for AI-powered event discovery (OpenRouter API)
UNSPLASH_ACCESS_KEY=... # Required for dynamic event images (Unsplash API)
SUPABASE_URL=... # Future backend integration
SUPABASE_KEY=... # Future backend integrationLoading mechanism:
app.config.jsloads.env.localvia dotenvconfig/env.tsexposes vars via expo-constants- Falls back to 3 sample events if OpenRouter API key is missing/invalid
app/_layout.tsx: Root layout with EventsContext, LocationProvider, and DiscoveryEventsContextapp/(tabs)/: Tab navigation groupindex.tsx: Home screen (AI-powered event discovery)two.tsx: Events screen (saved events)
- Typed routes enabled (
typedRoutes: truein app.json)
This app has a complex gesture hierarchy that requires careful coordination:
-
SwipeActionCard (horizontal): Wraps event cards on home screen
- Right swipe (+30% threshold) = Add to saved events
- Left swipe (-30% threshold) = Dismiss card
- Shows "Add"/"Delete" badges during swipe
- Location:
components/SwipeActionCard.tsx
-
FlippableCard (3D flip): Card flip animation
- Front/back view switching with perspective transform
- 300ms animation, platform-specific cameraDistance for Android
- Location:
components/FlippableCard.tsx
-
SwipeableEventCard (vertical): iOS-style swipe-to-delete on events screen
- Partial swipe (< 60%) reveals red delete button
- Full swipe (>= 60%) auto-deletes with confirmation
- Location:
components/SwipeableEventCard.tsx
IMPORTANT: When modifying gestures, test all three layers together. Gesture conflicts can break the UX.
The app uses two separate contexts for different concerns:
Location: contexts/events-context.tsx
Purpose: Manages user's personally saved events (curated collection)
// Provided methods
addSavedEvent(event) // Adds event with AsyncStorage persistence
removeSavedEventById(id) // Removes by ID, updates AsyncStorage
hasSavedEvent(id) // Check if event is saved
savedEvents // Array of saved eventsStorage:
- Persisted to AsyncStorage at key
@ballot:saved_events - Survives app restarts
- Synchronized on every add/remove operation
Location: contexts/discovery-events-context.tsx
Purpose: Manages location-based event discovery via Perplexity AI
// State
discoveredEvents: DiscoveredEvent[] // AI-generated events for current location
isLoading: boolean // Fetch in progress
error: string | null // Error message if fetch failed
cacheHit: boolean // Whether data came from cache
cacheAge: number | null // Time since cache was written (ms)
currentLocation: string | null // Location of current discovery
lastFetchTime: number | null // Unix timestamp of last fetch
// Methods
fetchEvents(location, forceRefresh?) // Fetch with 12-hour cache support
refreshEvents(location) // Force bypass cache
clearEvents() // Reset discovery state
getCacheAgeDisplay() // Format cache age (e.g., "2h ago")Cache Strategy:
- TTL: 12 hours (configurable in
utils/event-cache.ts) - Storage key:
@ballot:event-cache:{normalized-location} - Auto-expiry: Stale caches deleted on load
- Bypass: Use
refreshEvents()orforceRefresh: true
Error Handling:
- Network failures: 3 retry attempts with exponential backoff
- Rate limiting: Respects
Retry-Afterheader - Complete API failure: Falls back to 3 sample events (see
utils/event-generation.ts)
GestureHandlerRootView
└─ EventsProvider (saved events with AsyncStorage)
└─ LocationProvider (GPS + manual location)
└─ DiscoveryEventsProvider (AI-generated events with caching)
└─ ThemeProvider
└─ Stack NavigatorThe back of each card features:
- Scrollable content: Event details (venue, address, organizer, website) and AI-generated impact statement
- Custom always-visible scrollbar: iOS-style thin scrollbar (4px) with draggable thumb on the right side (
components/CustomScrollbar.tsx)- Built with Reanimated v4 Gesture API + Gesture Handler for smooth 60fps performance
- Thumb size adapts to content ratio, auto-hides when content fits without scrolling
- User can drag the thumb to scroll, with smooth spring animations on interaction
- Minimum thumb height: 50px, semi-transparent white (60% opacity with subtle shadow)
- Scales up 1.2x and increases opacity to 80% when dragging for visual feedback
- Used in both
HomeEventCard(back face) andSavedEventDetailsModalfor consistency
- Fixed bottom button: "Close Details" button styled identically to "View Details" on front
- Nested scroll behavior: When scrolled to top/bottom edges, vertical swipes pass through to parent VirtualizedList for card navigation
- Gesture coordination: Horizontal swipes (add/delete) work independently of vertical scrolling and scrollbar dragging
Custom Scrollbar Implementation:
- Track scroll position via
useAnimatedScrollHandleron Animated.ScrollView - Calculate thumb position:
interpolate(scrollY, [0, scrollableHeight], [0, maxThumbY]) - Calculate thumb size:
max(50px, (viewportHeight / contentHeight) * trackHeight) - Pan gesture converts thumb drag delta to scroll position via
scrollRatio = scrollableHeight / maxThumbY - All hooks called before conditional returns to satisfy React's rules of hooks
The home screen uses a Quizlet-style infinite card swiping interface built with VirtualizedList:
// Pattern in app/(tabs)/index.tsx
VISIBLE_EVENTS = EVENTS.filter(e => !dismissedIds.has(e.id))
INITIAL_INDEX = (INFINITE_COUNT/2) - (INFINITE_COUNT/2) % EVENTS_LENGTH
// Access via: data[index % data.length]Features:
- Full-screen vertical paging (one card at a time)
- Modulo mapping creates infinite scroll illusion
- Dismissed cards filtered out dynamically
- Horizontal swipe gestures: right = save, left = dismiss
windowSize=3for performance (only 3 cards rendered)
Component Hierarchy:
AnimatedEventCard
└─ HomeEventCard
└─ SwipeActionCard (horizontal swipe gestures)
└─ FlippableCard (3D flip animation)
Why this pattern: Simulates Tinder/Quizlet UX without re-rendering entire list
The app features a comprehensive AI thinking animation system that makes the search process engaging and informative:
- Character-by-character text animation similar to ChatGPT
- Blinking cursor during typing
- Configurable speed, styling, and completion callbacks
- Used for "Found X exciting events in [location]!" messages
- Animated dots that bounce and scale with staggered timing
- Used during AI processing to show active thinking
- Customizable size, color, spacing, and speed
- Shows AI model information (Perplexity Sonar Pro)
- Displays temperature settings with descriptive labels (Precise/Balanced/Creative)
- Shows token limits and current processing location
- Includes brain icon with thinking dots animation
- Staggered entrance animations for event cards after AI generation
- Cards slide up, scale in, and fade in with spring physics
- 200ms delay between each card for natural progression
- Search Triggered: Basic location input with smooth expansion animation
- AI Thinking:
AIThinkingParameterscomponent shows brain icon, thinking dots, model info, and location - Results Found:
StreamingTextcomponent displays "Found X exciting events in [location]!" with character-by-character animation - Cards Appear:
AnimatedEventCardcomponents entrance with staggered spring animations (translateY + scale + opacity)
Why this animation system: Creates engaging, ChatGPT-like experience that communicates AI processing while maintaining visual interest and providing transparency about what's happening.
SVGs are imported as React components via metro transformer:
import HomeIcon from '@/assets/images/home-icon.svg';
<HomeIcon width={24} height={24} opacity={focused ? 1 : 0.5} />Setup: metro.config.js configured with react-native-svg-transformer
File: utils/perplexity-api.ts
Provider: OpenRouter (https://openrouter.ai)
Model: perplexity/sonar-pro (reasoning-capable search model via OpenRouter)
Endpoint: https://openrouter.ai/api/v1/chat/completions
Request Configuration:
{
model: "perplexity/sonar-pro",
temperature: 0.2, // Low temp for consistent structured output
max_tokens: 5000, // Large context for detailed event data
headers: {
'Authorization': `Bearer ${apiKey}`,
'HTTP-Referer': 'https://ballot.app', // Optional: for OpenRouter rankings
'X-Title': 'Ballot', // Optional: for OpenRouter rankings
}
}Retry Logic:
- Max 3 attempts for network errors
- Exponential backoff: 1s, 2s, 4s
- Rate limit (429): Respects
Retry-Afterheader - Timeout: 30 seconds per request
Prompt Architecture:
- System: Instructs to return valid JSON only
- User: Location + date range + event type filters + max count
- Output: JSON schema with cards array (name, date, time, address, ai_overview, link, source_urls, tags, unsplash_image_keyword)
File: utils/event-generation.ts
- Call OpenRouter API with Perplexity Sonar Pro model and location
- Parse response (handles
<think>tags, markdown code fences) - Validate each event via
validateEventData() - Fetch Unsplash images in parallel: Use AI-provided
unsplash_image_keywordfor each event - Transform
PerplexityEventData→DiscoveredEventwith dynamic images - Generate stable hash-based IDs:
event-{hash(title+date+location)}-{timestamp} - If Unsplash fetch fails: fallback to random local images (event1-3)
- Filter invalid events, return array
Fallback Events: If API fails completely, returns 3 hardcoded sample events for Phoenix/Austin/Seattle
File: utils/unsplash-api.ts
Provider: Unsplash API (https://unsplash.com/developers)
Endpoint: https://api.unsplash.com/photos/random
How it works:
- Perplexity AI generates
unsplash_image_keywordfor each event (e.g., "town-hall", "political-rally", "civic-meeting") - Unsplash API fetches random image matching keyword with
orientation=landscapeandcontent_filter=high - Image URL stored in
imageUrlfield ofDiscoveredEvent - UI component (
HomeEventCard) prioritizesimageUrlover local fallback images
Error Handling:
- Timeout: 5 seconds per image request
- Rate limiting (403): Falls back to local images
- No results (404): Falls back to local images
- Network errors: Falls back to local images
- All fetches run in parallel for performance
Image Priority:
// In HomeEventCard.tsx
const imageSource = imageUrl ? { uri: imageUrl } : image;Fallback Images: Local images (event1.png, event2.png, event3.png) assigned randomly when Unsplash fails
File: utils/event-cache.ts
Cache Entry Structure:
{
location: string, // Normalized location string
events: SerializedEvent[], // Array of serialized events
timestamp: number, // When cached (Unix ms)
version: number // Schema version (currently 1)
}Cache Operations:
saveCachedEvents(location, events)- Write to AsyncStorageloadCachedEvents(location, ttl?)- Load with TTL check (default 12h)clearCachedEvents(location)- Delete specific location cacheclearAllCaches()- Wipe all event caches
TTL Calculation:
const age = Date.now() - cacheEntry.timestamp;
if (age > ttl) {
// Expired - delete and return null
await clearCachedEvents(location);
return null;
}Location Normalization: Lowercased, trimmed, consistent formatting for cache key matching
Why three types? Different representations for API, runtime, and storage.
File: types/event.ts
{
name: string;
date: string; // ISO 8601 or human-readable
time: { start: string; end?: string; };
address: {
venue?: string;
street?: string;
city: string;
state: string;
postal_code?: string;
country?: string;
};
location?: { lat: number; lon: number; };
ai_overview: string;
link?: string;
source_urls?: string[];
tags?: string[];
unsplash_image_keyword?: string; // AI-generated keyword for image search
}{
id: string; // Generated hash-based ID
title: string;
location: string; // Display-formatted location string
date: string; // Human-readable date
time: string; // Human-readable time range
image: ImageSourcePropType; // require() statement (fallback, not serializable)
imageKey: string; // For serialization (e.g., "event1")
imageUrl?: string; // Unsplash URL (prioritized over local image)
aiOverview: string;
link?: string;
sourceUrls?: string[];
tags?: string[];
venue?: string;
address?: string;
}{
id: string;
title: string;
location: string;
date: string;
time: string;
imageKey: string; // NOT ImageSourcePropType - uses key instead
aiOverview: string;
link?: string;
sourceUrls?: string[];
tags?: string[];
venue?: string;
address?: string;
version?: number; // For future migrations
}Transformation Flow:
Perplexity API Response (with unsplash_image_keyword)
↓ (parsePerplexityResponse)
PerplexityEventData[]
↓ (fetchMultipleUnsplashImages) - parallel image fetching
[imageUrl1, imageUrl2, ...] or [null, null, ...] on failure
↓ (transformToDiscoveredEvent with imageUrl)
DiscoveredEvent[] (with imageUrl if successful, fallback to local images)
Deserialization (when loading from cache):
SerializedEvent[]
↓ (deserializeEvent - maps imageKey → ImageSourcePropType)
DiscoveredEvent[]
↓ (displayed in UI)
Problem: Cannot serialize require('@/assets/images/event1.jpg') to AsyncStorage (it's a function call, not JSON-serializable)
Solution: Two-way mapping system
File: utils/image-mapping.ts
// Available image keys
type ImageKey = "event1" | "event2" | "event3" | "event4" | "event5" | "event-image";
// Mapping functions
getImageFromKey(key: ImageKey): ImageSourcePropType
getKeyFromImage(image: ImageSourcePropType): ImageKeyUsage:
// When saving to cache (DiscoveredEvent → SerializedEvent)
const serialized: SerializedEvent = {
...event,
imageKey: getKeyFromImage(event.image), // Convert require() → "event1"
// Remove image field (not serializable)
};
// When loading from cache (SerializedEvent → DiscoveredEvent)
const discovered: DiscoveredEvent = {
...serialized,
image: getImageFromKey(serialized.imageKey), // Convert "event1" → require()
};Important: Always use getKeyFromImage() and getImageFromKey() for bidirectional conversion. Never hardcode image keys in event objects.
CRITICAL: Must include react-native-reanimated/plugin as last plugin for worklets to function.
userInterfaceStyle: "dark"- Dark theme only, no light modenewArchEnabled: true- React Native New Architecture enabledexperiments.typedRoutes: true- Type-safe routing
- Path alias:
@/*maps to project root - Use
@/for all imports to components, contexts, constants
- Defines build profiles for development, preview, and production
- Contains iOS/Android package identifiers (
com.ballot.app) - Before first submission, update Apple IDs and service account keys
autoIncrement: truefor iOS builds (automatic build number increment)
expo.web.output: "server"- Server-side export for web (supports API routes)- Environment variables exposed via
extrafield - Loads
.env.localvia dotenv at build time
- Write concise, technical TypeScript code
- Use functional and declarative programming; avoid classes
- Use descriptive variable names with auxiliary verbs (isLoading, hasError)
- Structure files: exported component, subcomponents, helpers, static content, types
- Use TypeScript for all code; prefer interfaces over types
- Avoid enums; use maps instead
- Use functional components with TypeScript interfaces
- Strict mode enabled
- Use "function" keyword for pure functions
- Avoid unnecessary curly braces in conditionals
- Use declarative JSX
- Minimize useState/useEffect; prefer context and reducers
- Memoize expensive operations with useMemo/useCallback
- Use
react-native-reanimatedfor all animations (not Animated API) - Use
useSharedValuefor gesture tracking (runs on native thread) - Use
withSpringfor natural physics-based animations - Use
withTimingfor precise timed animations
- Use lowercase with dashes for directories (e.g.,
home-event-card/) - Favor named exports for components
- Platform-specific files:
file.ts/file.web.ts
- Handle errors at beginning of functions
- Use early returns for error conditions
- Avoid unnecessary else statements; use if-return pattern
- No global error boundaries implemented yet
- Use
VirtualizedListwithwindowSize,maxToRenderPerBatchfor lists - Use
removeClippedSubviewson long lists - Remote images (Unsplash) use expo-image with blurhash placeholder for smooth loading
- Local fallback images load instantly
- Unsplash images fetched in parallel (all at once) for better performance
- 5-second timeout per image to prevent long waits
- Supabase Integration: Environment variables present but not yet implemented
- Search Radius: No distance-based filtering (Perplexity returns any events in location)
- No Tests: Test infrastructure not set up (only
react-test-rendererin devDeps) - Fallback Images: Dynamic Unsplash images implemented, but fallback pool limited to 3 static images (event1-3). Plan to expand to 15 curated fallback images.
- Dark Mode Only: No light theme support
- Event Updates: No caching implemented - events fetched fresh each time
- API Rate Limits: Both OpenRouter and Unsplash have rate limits and costs per request
Note: Home screen now uses DiscoveredEvent type from types/event.ts (see Three-Tier Type System).
Saved events (EventsContext) use this structure:
{
id: string;
title: string;
location: string;
date: string;
time: string;
image: ImageSourcePropType; // Fallback local image
imageKey: string; // For local image mapping
imageUrl?: string; // Unsplash URL (prioritized if present)
aiOverview: string;
link?: string;
sourceUrls?: string[];
tags?: string[];
venue?: string;
address?: string;
organizer?: string;
websiteLink?: string;
impactStatement?: string;
qaPairs?: EventQAPair[];
}When adding new event images:
- Add image file to
assets/images/(e.g.,event6.jpg) - Update
utils/image-mapping.ts:- Add new key to
ImageKeytype - Add mapping in
IMAGE_MAPobject - Add reverse mapping in
getKeyFromImage()
- Add new key to
- Update
getRandomImage()inutils/event-generation.tsto include new image
When modifying event generation logic:
- Update prompt in
generateEventsForLocation()(utils/event-generation.ts) - Adjust
validateEventData()if changing required fields - Update JSON schema if adding new fields (especially for Perplexity prompt)
- Check error handling with invalid API responses
When working with Unsplash images:
- Keywords are AI-generated via
unsplash_image_keywordfield in Perplexity response - To modify keyword suggestions, update the prompt in
createEventGenerationPrompt()(utils/event-generation.ts) - Images fetch in parallel during event generation - no separate API calls needed
- If Unsplash API fails/rate-limited, app automatically falls back to local images (event1-3)
- Test with invalid API key to verify fallback behavior works correctly
When modifying gestures:
- Test all three gesture layers together
- Check
simultaneous()handlers in gesture configs - Verify spring animations don't conflict
- Test on both iOS and Android
When adding new screens:
- Create file in
app/directory (file-based routing) - Access via
router.push('/screen-name') - Add to
_layout.tsxif needs special layout
Why Context API instead of Redux? Small app with simple state. useReducer provides enough structure without extra dependencies.
Why VirtualizedList instead of FlatList? Allows custom virtualization logic for infinite scrolling pattern. FlatList doesn't support modulo-based index mapping.
Why Reanimated instead of Animated API? Reanimated runs on native thread (60fps), critical for gesture-driven animations. Industry standard for modern React Native apps.
Why Gesture Handler instead of PanResponder? Better performance, better conflict resolution with ScrollView, seamless integration with Reanimated.
Why OpenRouter with Perplexity Sonar Pro? OpenRouter provides unified API access to multiple LLM providers with better rate limits and easier management. Perplexity's Sonar Pro model excels at real-time web search and structured output. Returns citations and sources, critical for civic event verification.
Why Unsplash for images? Dynamic, contextually-relevant images provide better UX than static placeholders. Perplexity AI generates optimal search keywords for each event type (e.g., "town-hall", "political-rally"), ensuring images match event context. Free tier supports 50 requests/hour, sufficient for typical usage. Graceful fallback to local images ensures app never breaks.
Why separate DiscoveryEventsContext from EventsContext? Clear separation of concerns: discovery (ephemeral, location-based, cached) vs saved (persistent, user-curated, no expiry).
Why three-tier type system? API format (PerplexityEventData) differs from UI needs (DiscoveredEvent) and storage constraints (SerializedEvent). Each tier optimized for its domain.
Why hash-based event IDs? Deterministic IDs prevent duplicates when refreshing same location. Same event = same hash = deduplication works automatically.
20975ea - Local storage (AsyncStorage integration)
6d5de78 - Location (GPS-based event discovery)
56a1561 - Fixed location button
a74b996 - Add refresh functionality
2ffa4d1 - Quizlet-like card swiping fixed
b857224 - Quizlet-like card swiping
1ebd1be - Flippable card implementation
The app has completed major AI integration (OpenRouter API with Perplexity Sonar Pro for event discovery) and Unsplash image integration for dynamic event imagery. Core discovery flow with AI-powered event generation and image fetching is production-ready.