diff --git a/.gitignore b/.gitignore index f8c6c2e..38bcd4a 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ app-example # generated native folders /ios /android +.env diff --git a/app/(tabs)/explore.tsx b/app/(tabs)/explore.tsx index 475b987..480ebc8 100644 --- a/app/(tabs)/explore.tsx +++ b/app/(tabs)/explore.tsx @@ -1,109 +1,120 @@ -import * as Location from 'expo-location'; -import { router, useFocusEffect } from 'expo-router'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import { ActivityIndicator, StyleSheet, View } from 'react-native'; -import MapView, { Callout, Marker, PROVIDER_DEFAULT } from 'react-native-maps'; +import * as Location from "expo-location"; +import { router, useFocusEffect } from "expo-router"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { ActivityIndicator, StyleSheet, View } from "react-native"; +import MapView, { Callout, Marker, PROVIDER_DEFAULT } from "react-native-maps"; -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { FoodLocation, foodLocations } from '@/constants/locations'; -import { formatDistance, getDistance } from '@/utils/distance'; +import { ThemedText } from "@/components/themed-text"; +import { ThemedView } from "@/components/themed-view"; +import { FoodLocation, foodLocations } from "@/constants/locations"; +import { formatDistance, getDistance } from "@/utils/distance"; import { categorizePlace, formatOSMAddress, onOSMCacheCleared, searchNearbyFoodLocations, -} from '@/utils/osm-api'; +} from "@/utils/osm-api"; const STALE_MS = 5 * 60 * 1000; export default function TabTwoScreen() { const [locations, setLocations] = useState(foodLocations); const [loading, setLoading] = useState(true); - const [userLocation, setUserLocation] = useState<{ latitude: number; longitude: number } | null>(null); + const [userLocation, setUserLocation] = useState<{ + latitude: number; + longitude: number; + } | null>(null); const lastLoadedRef = useRef(0); const hasLoadedRef = useRef(false); - const loadLocations = useCallback(async (opts?: { force?: boolean }) => { - const isStale = Date.now() - lastLoadedRef.current > STALE_MS; - if (!opts?.force && hasLoadedRef.current && !isStale) return; + const loadLocations = useCallback( + async (opts?: { force?: boolean }) => { + const isStale = Date.now() - lastLoadedRef.current > STALE_MS; + if (!opts?.force && hasLoadedRef.current && !isStale) return; - // When forcing or first load with empty data, show spinner - if ((!hasLoadedRef.current || opts?.force) && locations.length === 0) setLoading(true); + // When forcing or first load with empty data, show spinner + if ((!hasLoadedRef.current || opts?.force) && locations.length === 0) + setLoading(true); - try { - const { status } = await Location.requestForegroundPermissionsAsync(); - if (status !== 'granted') { - setLoading(false); - return; - } + try { + const { status } = await Location.requestForegroundPermissionsAsync(); + if (status !== "granted") { + setLoading(false); + return; + } - const location = await Location.getCurrentPositionAsync({}); - setUserLocation({ - latitude: location.coords.latitude, - longitude: location.coords.longitude, - }); + const location = await Location.getCurrentPositionAsync({}); + setUserLocation({ + latitude: location.coords.latitude, + longitude: location.coords.longitude, + }); - console.log('Fetching OSM data...'); - // Fetch real data from OSM - const osmPlaces = await searchNearbyFoodLocations( - location.coords.latitude, - location.coords.longitude, - undefined, - opts?.force ? { force: true } : undefined - ); + console.log("Fetching OSM data..."); + // Fetch real data from OSM + const osmPlaces = await searchNearbyFoodLocations( + location.coords.latitude, + location.coords.longitude, + 10, + opts?.force ? { force: true } : undefined + ); - console.log(`Found ${osmPlaces.length} OSM places`); + console.log(`Found ${osmPlaces.length} OSM places`); - if (osmPlaces.length > 0) { - const mappedLocations: FoodLocation[] = osmPlaces.map((place, index) => ({ - id: place.place_id || `osm-${index}`, - name: place.display_name.split(',')[0], - address: formatOSMAddress(place), - type: categorizePlace(place), - coordinate: { - latitude: parseFloat(place.lat), - longitude: parseFloat(place.lon), - }, - distance: formatDistance( - getDistance( - location.coords.latitude, - location.coords.longitude, - parseFloat(place.lat), - parseFloat(place.lon) - ) - ), - })); + if (osmPlaces.length > 0) { + const mappedLocations: FoodLocation[] = osmPlaces.map( + (place, index) => ({ + id: place.place_id || `osm-${index}`, + name: place.display_name.split(",")[0], + address: formatOSMAddress(place), + type: categorizePlace(place), + coordinate: { + latitude: parseFloat(place.lat), + longitude: parseFloat(place.lon), + }, + distance: formatDistance( + getDistance( + location.coords.latitude, + location.coords.longitude, + parseFloat(place.lat), + parseFloat(place.lon) + ) + ), + }) + ); - setLocations(mappedLocations); - } else { - // Fall back to static list with computed distances so we always show something - const withDistances = foodLocations.map((loc) => ({ - ...loc, - calculatedDistance: getDistance( - location.coords.latitude, - location.coords.longitude, - loc.coordinate.latitude, - loc.coordinate.longitude - ), - })); - const sorted = withDistances.sort((a, b) => a.calculatedDistance - b.calculatedDistance); - setLocations( - sorted.map((loc) => ({ + setLocations(mappedLocations); + } else { + // Fall back to static list with computed distances so we always show something + const withDistances = foodLocations.map((loc) => ({ ...loc, - distance: formatDistance(loc.calculatedDistance), - })) - ); + calculatedDistance: getDistance( + location.coords.latitude, + location.coords.longitude, + loc.coordinate.latitude, + loc.coordinate.longitude + ), + })); + const sorted = withDistances.sort( + (a, b) => a.calculatedDistance - b.calculatedDistance + ); + setLocations( + sorted.map((loc) => ({ + ...loc, + distance: formatDistance(loc.calculatedDistance), + })) + ); + } + } catch (error) { + console.error("Error loading locations:", error); + // Fall back to static data + } finally { + hasLoadedRef.current = true; + lastLoadedRef.current = Date.now(); + setLoading(false); } - } catch (error) { - console.error('Error loading locations:', error); - // Fall back to static data - } finally { - hasLoadedRef.current = true; - lastLoadedRef.current = Date.now(); - setLoading(false); - } - }, [locations]); + }, + [locations] + ); useFocusEffect( useCallback(() => { @@ -123,9 +134,11 @@ export default function TabTwoScreen() { return unsubscribe; }, [loadLocations]); - const mapRegion = userLocation || { - latitude: 33.7676, - longitude: -84.3908, + const mapRegion = { + latitude: userLocation?.latitude ?? 33.7676, + longitude: userLocation?.longitude ?? -84.3908, + latitudeDelta: 0.15, + longitudeDelta: 0.15, }; return ( @@ -133,11 +146,7 @@ export default function TabTwoScreen() { { router.push({ - pathname: '/option/[id]', + pathname: "/option/[id]", params: { id: location.id, name: location.name, @@ -163,26 +172,36 @@ export default function TabTwoScreen() { }} > - {location.name} + + {location.name} + - {location.type} + + {location.type} + {location.distance && ( - {location.distance} + + {location.distance} + )} - Tap for details โ†’ + + Tap for details โ†’ + ))} - + Explore - {loading ? 'Loading nearby options...' : `${locations.length} food options nearby`} + {loading + ? "Loading nearby options..." + : `${locations.length} food options nearby`} {loading && } @@ -198,14 +217,14 @@ const styles = StyleSheet.create({ flex: 1, }, floatingHeader: { - position: 'absolute', + position: "absolute", top: 60, left: 20, right: 20, paddingVertical: 16, paddingHorizontal: 20, borderRadius: 16, - shadowColor: '#000', + shadowColor: "#000", shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.15, shadowRadius: 12, @@ -226,22 +245,22 @@ const styles = StyleSheet.create({ gap: 6, }, calloutTitle: { - fontWeight: '700', + fontWeight: "700", fontSize: 16, marginBottom: 4, }, calloutBadge: { - backgroundColor: '#e0f2fe', + backgroundColor: "#e0f2fe", paddingHorizontal: 10, paddingVertical: 4, borderRadius: 12, - alignSelf: 'flex-start', + alignSelf: "flex-start", marginBottom: 2, }, calloutBadgeText: { - color: '#0369a1', + color: "#0369a1", fontSize: 12, - fontWeight: '600', + fontWeight: "600", }, calloutDistance: { fontSize: 13, @@ -250,8 +269,8 @@ const styles = StyleSheet.create({ }, calloutTap: { fontSize: 12, - color: '#2563eb', - fontWeight: '700', + color: "#2563eb", + fontWeight: "700", marginTop: 6, }, }); diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 3f889e8..08e53b9 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,23 +1,31 @@ -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { FoodLocation } from '@/constants/locations'; -import { formatDistance, getDistance } from '@/utils/distance'; +import { ThemedText } from "@/components/themed-text"; +import { ThemedView } from "@/components/themed-view"; +import { FoodLocation } from "@/constants/locations"; +import { formatDistance, getDistance } from "@/utils/distance"; import { categorizePlace, formatOSMAddress, searchNearbyFoodLocations, -} from '@/utils/osm-api'; -import * as Location from 'expo-location'; -import { useRouter } from 'expo-router'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { FlatList, Pressable, RefreshControl, ScrollView, StyleSheet, TextInput, View } from 'react-native'; +} from "@/utils/osm-api"; +import * as Location from "expo-location"; +import { useRouter } from "expo-router"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + FlatList, + Pressable, + RefreshControl, + ScrollView, + StyleSheet, + TextInput, + View, +} from "react-native"; const DEFAULT_COORDINATE = { latitude: 33.7676, longitude: -84.3908 }; const sortByDistance = (locations: FoodLocation[]) => [...locations].sort((a, b) => { - const distA = parseFloat(a.distance || '0'); - const distB = parseFloat(b.distance || '0'); + const distA = parseFloat(a.distance || "0"); + const distB = parseFloat(b.distance || "0"); return distA - distB; }); @@ -26,17 +34,22 @@ export default function HomeScreen() { const [refreshing, setRefreshing] = useState(false); const [sortedLocations, setSortedLocations] = useState([]); // NEW: query/filter + last updated - const [query, setQuery] = useState(''); - const filters = ['All', 'Pantry', 'Grocery', 'Market', 'Food Bank'] as const; - const [activeFilter, setActiveFilter] = useState('All'); + const [query, setQuery] = useState(""); + const filters = ["All", "Pantry", "Grocery", "Market", "Food Bank"] as const; + const [activeFilter, setActiveFilter] = + useState<(typeof filters)[number]>("All"); const [lastUpdated, setLastUpdated] = useState(null); const hasInitialLoad = useRef(false); const isInitializing = useRef(true); // NEW: track true initialization + const inflightRef = useRef | null>(null); const router = useRouter(); const formattedLastUpdated = useMemo(() => { - if (!lastUpdated) return ''; + if (!lastUpdated) return ""; try { - return new Date(lastUpdated).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + return new Date(lastUpdated).toLocaleTimeString([], { + hour: "numeric", + minute: "2-digit", + }); } catch { return new Date(lastUpdated).toISOString(); } @@ -44,31 +57,31 @@ export default function HomeScreen() { // Minimal emoji for type indicator const typeEmoji = useCallback((t?: string) => { - if (!t) return '๐Ÿฌ'; + if (!t) return "๐Ÿฌ"; const normalized = t.toLowerCase(); const map: Record = { - 'food bank': '๐Ÿฅซ', - 'food pantry': '๐Ÿฅซ', - 'soup kitchen': '๐Ÿฒ', - 'meal delivery': '๐Ÿšš', - 'community center': '๐Ÿ˜๏ธ', - 'place of worship': 'โ›ช', - charity: '๐Ÿ’', - 'social facility': '๐Ÿค', - supermarket: '๐Ÿ›’', - 'grocery store': '๐Ÿ›’', - greengrocer: '๐Ÿฅฆ', - 'convenience store': '๐Ÿช', - bakery: '๐Ÿฅ', - deli: '๐Ÿฅช', - market: '๐Ÿงบ', - 'farmers market': '๐Ÿงบ', + "food bank": "๐Ÿฅซ", + "food pantry": "๐Ÿฅซ", + "soup kitchen": "๐Ÿฒ", + "meal delivery": "๐Ÿšš", + "community center": "๐Ÿ˜๏ธ", + "place of worship": "โ›ช", + charity: "๐Ÿ’", + "social facility": "๐Ÿค", + supermarket: "๐Ÿ›’", + "grocery store": "๐Ÿ›’", + greengrocer: "๐Ÿฅฆ", + "convenience store": "๐Ÿช", + bakery: "๐Ÿฅ", + deli: "๐Ÿฅช", + market: "๐Ÿงบ", + "farmers market": "๐Ÿงบ", }; if (map[normalized]) return map[normalized]; - if (/bank|pantry|fridge/.test(normalized)) return '๐Ÿฅซ'; - if (/market|farmer/.test(normalized)) return '๐Ÿงบ'; - if (/grocery|supermarket|store/.test(normalized)) return '๐Ÿ›’'; - return '๐Ÿฌ'; + if (/bank|pantry|fridge/.test(normalized)) return "๐Ÿฅซ"; + if (/market|farmer/.test(normalized)) return "๐Ÿงบ"; + if (/grocery|supermarket|store/.test(normalized)) return "๐Ÿ›’"; + return "๐Ÿฌ"; }, []); // Helper: parse "0.8 mi" or "500 ft" into miles @@ -80,90 +93,91 @@ export default function HomeScreen() { }; const getCurrentLocation = useCallback(async (force?: boolean) => { + if (inflightRef.current) { + return inflightRef.current; + } + // Always show loader on initial load or forced refresh if (!hasInitialLoad.current || force) { setLoading(true); } - try { - const { status } = await Location.requestForegroundPermissionsAsync(); - if (status !== 'granted') { - console.log('Location permission not granted'); - setLoading(false); - hasInitialLoad.current = true; - isInitializing.current = false; - return; - } - - console.log('Getting current position...'); - const locationData = await Location.getCurrentPositionAsync({}); - console.log('Current position:', locationData.coords.latitude, locationData.coords.longitude); + const run = (async () => { + try { + const { status } = await Location.requestForegroundPermissionsAsync(); + if (status !== "granted") { + console.log("Location permission not granted"); + return; + } + + console.log("Getting current position..."); + const locationData = await Location.getCurrentPositionAsync({ + accuracy: Location.Accuracy.Balanced, + }); + console.log( + "Current position:", + locationData.coords.latitude, + locationData.coords.longitude + ); - // Only fetch OSM data with larger radius - console.log('Fetching OSM data...'); - - // Add timeout protection - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error('Request timeout after 30s')), 30000); - }); - - const osmPlaces = await Promise.race([ - searchNearbyFoodLocations( + console.log("Fetching OSM data..."); + const osmPlaces = await searchNearbyFoodLocations( locationData.coords.latitude, locationData.coords.longitude, - 10, // Increased from default 5km to 10km - force ? { force: true } : undefined // bypass caches on manual refresh - ), - timeoutPromise - ]); - - console.log(`Fetched ${osmPlaces.length} places from OSM`); + 10, + force ? { force: true } : undefined + ); - if (osmPlaces.length === 0) { - console.warn('No OSM places found in the area'); - setSortedLocations([]); + console.log(`Fetched ${osmPlaces.length} places from OSM`); + + if (osmPlaces.length === 0) { + console.warn("No OSM places found in the area"); + setSortedLocations([]); + setLastUpdated(Date.now()); + return; + } + + const nextLocations = osmPlaces.map((place, index) => { + const calcDist = getDistance( + locationData.coords.latitude, + locationData.coords.longitude, + parseFloat(place.lat), + parseFloat(place.lon) + ); + return { + id: place.place_id || `osm-${index}`, + name: place.display_name.split(",")[0], + address: formatOSMAddress(place), + type: categorizePlace(place), + coordinate: { + latitude: parseFloat(place.lat), + longitude: parseFloat(place.lon), + }, + distance: formatDistance(calcDist), + }; + }); + + console.log(`Mapped ${nextLocations.length} locations`); + const sorted = sortByDistance(nextLocations); + console.log("Setting sorted locations..."); + setSortedLocations(sorted); setLastUpdated(Date.now()); + console.log("Location fetch complete"); + } catch (error) { + console.error("Error fetching places:", error); + // Don't set fallback data on error + } finally { hasInitialLoad.current = true; isInitializing.current = false; setLoading(false); - return; } + })(); - const nextLocations = osmPlaces.map((place, index) => { - const calcDist = getDistance( - locationData.coords.latitude, - locationData.coords.longitude, - parseFloat(place.lat), - parseFloat(place.lon) - ); - return { - id: place.place_id || `osm-${index}`, - name: place.display_name.split(',')[0], - address: formatOSMAddress(place), - type: categorizePlace(place), - coordinate: { - latitude: parseFloat(place.lat), - longitude: parseFloat(place.lon), - }, - distance: formatDistance(calcDist), - }; - }); - - console.log(`Mapped ${nextLocations.length} locations`); - const sorted = sortByDistance(nextLocations); - console.log('Setting sorted locations...'); - setSortedLocations(sorted); - setLastUpdated(Date.now()); - hasInitialLoad.current = true; - isInitializing.current = false; - console.log('Location fetch complete'); - } catch (error) { - console.error('Error getting location:', error); - hasInitialLoad.current = true; - isInitializing.current = false; - // Don't set fallback data on error + inflightRef.current = run; + try { + await run; } finally { - setLoading(false); + inflightRef.current = null; } }, []); // Empty deps is fine - we use refs for tracking state @@ -194,41 +208,56 @@ export default function HomeScreen() { const q = query.trim().toLowerCase(); let list = [...sortedLocations]; - if (activeFilter !== 'All') { + if (activeFilter !== "All") { list = list.filter((l) => { - const t = (l.type || '').toLowerCase(); - if (activeFilter === 'Pantry') return /pantry|fridge/.test(t); - if (activeFilter === 'Grocery') return /grocery|supermarket|store/.test(t); - if (activeFilter === 'Market') return /market|farmer/.test(t); - if (activeFilter === 'Food Bank') return /bank/.test(t); + const t = (l.type || "").toLowerCase(); + if (activeFilter === "Pantry") return /pantry|fridge/.test(t); + if (activeFilter === "Grocery") + return /grocery|supermarket|store/.test(t); + if (activeFilter === "Market") return /market|farmer/.test(t); + if (activeFilter === "Food Bank") return /bank/.test(t); return true; }); } if (q.length) { - list = list.filter((l) => (l.name || '').toLowerCase().includes(q) || (l.address || '').toLowerCase().includes(q)); + list = list.filter( + (l) => + (l.name || "").toLowerCase().includes(q) || + (l.address || "").toLowerCase().includes(q) + ); } return list; }, [sortedLocations, activeFilter, query]); // Dynamic Food Access Score based on nearest distance - const nearestMi = useMemo(() => toMiles(filteredLocations[0]?.distance || sortedLocations[0]?.distance), [filteredLocations, sortedLocations]); + const nearestMi = useMemo( + () => + toMiles(filteredLocations[0]?.distance || sortedLocations[0]?.distance), + [filteredLocations, sortedLocations] + ); const score = useMemo(() => { const miles = nearestMi; // Don't show UNKNOWN during initial load if (!hasInitialLoad.current || !Number.isFinite(miles)) { - return { label: 'LOADING', pct: 0.25, color: '#6b7280', hint: 'Finding food options near you...' }; + return { + label: "LOADING", + pct: 0.25, + color: "#6b7280", + hint: "Finding food options near you...", + }; } const pct = Math.max(0.06, 1 - Math.min(miles / 3, 1)); - const label = miles <= 0.5 ? 'HIGH' : miles <= 1.5 ? 'MEDIUM' : 'LOW'; - const color = label === 'HIGH' ? '#15803d' : label === 'MEDIUM' ? '#f59e0b' : '#b91c1c'; + const label = miles <= 0.5 ? "HIGH" : miles <= 1.5 ? "MEDIUM" : "LOW"; + const color = + label === "HIGH" ? "#15803d" : label === "MEDIUM" ? "#f59e0b" : "#b91c1c"; const hint = - label === 'HIGH' - ? 'Plenty of options within a short walk.' - : label === 'MEDIUM' - ? 'Some options are nearby.' - : 'Few fresh food options within walking distance.'; + label === "HIGH" + ? "Plenty of options within a short walk." + : label === "MEDIUM" + ? "Some options are nearby." + : "Few fresh food options within walking distance."; return { label, pct, color, hint }; }, [nearestMi, hasInitialLoad.current]); @@ -255,16 +284,31 @@ export default function HomeScreen() { returnKeyType="search" /> {query.length > 0 && ( - setQuery('')} style={styles.clearBtn}> + setQuery("")} style={styles.clearBtn}> โœ• )} - + {filters.map((f) => ( - setActiveFilter(f)} style={[styles.chip, activeFilter === f && styles.chipActive]}> - {f} + setActiveFilter(f)} + style={[styles.chip, activeFilter === f && styles.chipActive]} + > + + {f} + ))} @@ -273,11 +317,22 @@ export default function HomeScreen() { {/* FOOD ACCESS SCORE CARD */} Food Access Score - + {score.label} - + {score.hint} @@ -288,8 +343,12 @@ export default function HomeScreen() { {lastUpdated !== null && !isInitializing.current && ( - Showing {filteredLocations.length} result{filteredLocations.length === 1 ? '' : 's'} - {filteredLocations.length !== sortedLocations.length ? ` (of ${sortedLocations.length} total)` : ''} ยท Updated {formattedLastUpdated} + Showing {filteredLocations.length} result + {filteredLocations.length === 1 ? "" : "s"} + {filteredLocations.length !== sortedLocations.length + ? ` (of ${sortedLocations.length} total)` + : ""}{" "} + ยท Updated {formattedLastUpdated} )} @@ -302,26 +361,36 @@ export default function HomeScreen() { item.id} - refreshControl={} + refreshControl={ + + } onEndReached={onEndReached} onEndReachedThreshold={0.2} contentContainerStyle={{ paddingBottom: 28 }} ListEmptyComponent={ - + No locations found nearby. - { setQuery(''); setActiveFilter('All'); }} style={styles.resetBtn}> - Clear filters + { + setQuery(""); + setActiveFilter("All"); + }} + style={styles.resetBtn} + > + + Clear filters + } renderItem={({ item }) => ( router.push({ - pathname: '/option/[id]', + pathname: "/option/[id]", params: { id: item.id, name: item.name, @@ -331,27 +400,41 @@ export default function HomeScreen() { }, }) } - style={({ pressed }) => [styles.optionCard, styles.cardElevated, pressed && styles.cardPressed]} + style={({ pressed }) => [ + styles.optionCard, + styles.cardElevated, + pressed && styles.cardPressed, + ]} > - {typeEmoji(item.type)} + + {typeEmoji(item.type)} + - + {item.name} - {item.distance} + + {item.distance} + - {item.type} + + {item.type} + @@ -367,7 +450,7 @@ export default function HomeScreen() { style={styles.chevronButton} onPress={() => { router.push({ - pathname: '/option/[id]', + pathname: "/option/[id]", params: { id: item.id, name: item.name, @@ -412,14 +495,14 @@ const styles = StyleSheet.create({ marginBottom: 4, }, searchBox: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: '#f3f4f6', + flexDirection: "row", + alignItems: "center", + backgroundColor: "#f3f4f6", borderRadius: 12, paddingHorizontal: 12, paddingVertical: 8, borderWidth: 1, - borderColor: '#e5e7eb', + borderColor: "#e5e7eb", }, searchIcon: { fontSize: 16, @@ -436,11 +519,11 @@ const styles = StyleSheet.create({ width: 26, height: 26, borderRadius: 13, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: '#e5e7eb', + alignItems: "center", + justifyContent: "center", + backgroundColor: "#e5e7eb", }, - clearTxt: { fontWeight: '700', opacity: 0.7 }, + clearTxt: { fontWeight: "700", opacity: 0.7 }, chipsRow: { gap: 8, paddingTop: 10, @@ -449,28 +532,28 @@ const styles = StyleSheet.create({ paddingHorizontal: 12, paddingVertical: 6, borderRadius: 999, - backgroundColor: '#f3f4f6', + backgroundColor: "#f3f4f6", borderWidth: 1, - borderColor: '#e5e7eb', + borderColor: "#e5e7eb", marginRight: 8, }, chipActive: { - backgroundColor: '#1a73e8', - borderColor: '#1a73e8', + backgroundColor: "#1a73e8", + borderColor: "#1a73e8", }, - chipText: { fontSize: 13, color: '#374151' }, - chipTextActive: { color: 'white', fontWeight: '600' }, + chipText: { fontSize: 13, color: "#374151" }, + chipTextActive: { color: "white", fontWeight: "600" }, // Score card scoreCard: { padding: 16, borderRadius: 12, borderWidth: 1, - borderColor: '#e5e5e5', - backgroundColor: 'white', + borderColor: "#e5e5e5", + backgroundColor: "white", }, elevated: { - shadowColor: '#000', + shadowColor: "#000", shadowOpacity: 0.06, shadowRadius: 8, shadowOffset: { width: 0, height: 4 }, @@ -478,18 +561,18 @@ const styles = StyleSheet.create({ }, scoreValue: { fontSize: 30, - fontWeight: '700', + fontWeight: "700", marginVertical: 4, }, progressBar: { height: 8, borderRadius: 6, - backgroundColor: '#eef2f7', - overflow: 'hidden', + backgroundColor: "#eef2f7", + overflow: "hidden", marginVertical: 8, }, progressFill: { - height: '100%', + height: "100%", borderRadius: 6, }, scoreDescription: { @@ -501,7 +584,7 @@ const styles = StyleSheet.create({ }, resultsMeta: { fontSize: 12, - color: '#6b7280', + color: "#6b7280", marginBottom: 4, }, @@ -510,12 +593,12 @@ const styles = StyleSheet.create({ padding: 14, borderRadius: 12, borderWidth: 0, - borderColor: 'transparent', - backgroundColor: 'white', + borderColor: "transparent", + backgroundColor: "white", marginVertical: 6, }, cardElevated: { - shadowColor: '#000', + shadowColor: "#000", shadowOpacity: 0.04, shadowRadius: 6, shadowOffset: { width: 0, height: 3 }, @@ -526,8 +609,8 @@ const styles = StyleSheet.create({ opacity: 0.98, }, cardRow: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", }, leading: { marginRight: 12, @@ -536,15 +619,15 @@ const styles = StyleSheet.create({ width: 36, height: 36, borderRadius: 18, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: '#f3f4f6', + alignItems: "center", + justifyContent: "center", + backgroundColor: "#f3f4f6", }, leadingEmoji: { fontSize: 18 }, middle: { flex: 1 }, titleRow: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", }, optionTitle: { flex: 1, @@ -556,31 +639,31 @@ const styles = StyleSheet.create({ paddingHorizontal: 8, paddingVertical: 3, borderRadius: 999, - backgroundColor: '#f3f4f6', - color: '#374151', + backgroundColor: "#f3f4f6", + color: "#374151", }, subRow: { marginTop: 4, marginBottom: 2, }, metaRow: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", }, metaDot: { width: 6, height: 6, borderRadius: 3, - backgroundColor: '#a3a3a3', + backgroundColor: "#a3a3a3", marginRight: 6, }, subtleText: { fontSize: 12, - color: '#6b7280', + color: "#6b7280", }, addrRow: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", marginTop: 4, }, addrIcon: { @@ -589,45 +672,41 @@ const styles = StyleSheet.create({ opacity: 0.7, }, optionAddress: { - },flex: 1, - optionAddress: { - flex: 1,ton: { - },marginLeft: 8, - chevronButton: {tal: 2, - marginLeft: 8,l: 2, + flex: 1, + }, + chevronButton: { + marginLeft: 8, paddingHorizontal: 2, paddingVertical: 2, - },color: '#9ca3af', - chevron: {ht: '700', - color: '#9ca3af', - fontWeight: '700', + }, + chevron: { + color: "#9ca3af", + fontWeight: "700", fontSize: 18, lineHeight: 18, - }, ADD: loading + skeleton styles (fix corrupted section) - loadingContainer: { - // ADD: loading + skeleton styles (fix corrupted section) + }, loadingContainer: { padding: 16, - alignItems: 'center',textAlign: 'center', - justifyContent: 'center',80', - },6, - loadingText: {20, + alignItems: "center", + justifyContent: "center", + }, + loadingText: { fontSize: 16, opacity: 0.7, - },backgroundColor: '#1a73e8', - skeletonCard: {rizontal: 14, + }, + skeletonCard: { height: 72, borderRadius: 12, - backgroundColor: '#f3f4f6', + backgroundColor: "#f3f4f6", marginVertical: 6, - }, Keep existing legacy refs (deduplicated) - resetBtn: { optionType: { opacity: 0.7, marginTop: 2 }, - backgroundColor: '#1a73e8',m: 2 }, - paddingHorizontal: 14,tWeight: '600' }, + }, + resetBtn: { + backgroundColor: "#1a73e8", + paddingHorizontal: 14, paddingVertical: 8, - borderRadius: 999, }, - // Keep existing legacy refs (deduplicated) + borderRadius: 999, + }, optionType: { opacity: 0.7, marginTop: 2 }, optionDistance: { opacity: 0.6, marginBottom: 2 }, - directionsTextLegacy: { color: 'white', fontWeight: '600' }, + directionsTextLegacy: { color: "white", fontWeight: "600" }, }); diff --git a/babel.config.js b/babel.config.js index e7d3e59..9c3238c 100644 --- a/babel.config.js +++ b/babel.config.js @@ -2,20 +2,19 @@ module.exports = function (api) { api.cache(true); return { - presets: ['babel-preset-expo'], + presets: ["babel-preset-expo"], plugins: [ - 'expo-router/babel', [ - 'module-resolver', + "module-resolver", { - root: ['./'], + root: ["./"], alias: { - '@': './', + "@": "./", }, - extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'], + extensions: [".ts", ".tsx", ".js", ".jsx", ".json"], }, ], - 'react-native-reanimated/plugin', + "react-native-reanimated/plugin", ], }; }; diff --git a/lib/supabase.ts b/lib/supabase.ts new file mode 100644 index 0000000..50b584a --- /dev/null +++ b/lib/supabase.ts @@ -0,0 +1,27 @@ +// app/lib/supabase.ts +import { createClient, SupabaseClient } from "@supabase/supabase-js"; +import "react-native-url-polyfill/auto"; + +// Support both EXPO_PUBLIC_SUPABASE_ANON_KEY (preferred) and EXPO_PUBLIC_SUPABASE_KEY (legacy) +const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL; +const supabaseAnonKey = + process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY ?? + process.env.EXPO_PUBLIC_SUPABASE_KEY; + +if (!supabaseUrl || !supabaseAnonKey) { + const missing = [ + !supabaseUrl && "EXPO_PUBLIC_SUPABASE_URL", + !supabaseAnonKey && + "EXPO_PUBLIC_SUPABASE_ANON_KEY (or EXPO_PUBLIC_SUPABASE_KEY)", + ] + .filter(Boolean) + .join(", "); + throw new Error( + `Supabase env missing: ${missing}. Add to your .env or app config.` + ); +} + +export const supabase: SupabaseClient = createClient( + supabaseUrl, + supabaseAnonKey +); diff --git a/package-lock.json b/package-lock.json index 41378f7..6df0c80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", + "@supabase/supabase-js": "^2.81.1", "expo": "~54.0.23", "expo-blur": "~15.0.7", "expo-constants": "~18.0.10", @@ -36,6 +37,7 @@ "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", + "react-native-url-polyfill": "^3.0.0", "react-native-web": "~0.21.0", "react-native-worklets": "0.5.1" }, @@ -89,6 +91,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1470,6 +1473,7 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -3183,6 +3187,7 @@ "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.20.tgz", "integrity": "sha512-15luFq+35M2IOMHgbTJ0XDkPY7gm3YlR3yQKTuOTOHs+EeAUX71DlUuqcWMRqB0tt+OT6HimDQR7OboTB0N30g==", "license": "MIT", + "peer": true, "dependencies": { "@react-navigation/core": "^7.13.1", "escape-string-regexp": "^4.0.0", @@ -3254,6 +3259,106 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@supabase/auth-js": { + "version": "2.81.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.81.1.tgz", + "integrity": "sha512-K20GgiSm9XeRLypxYHa5UCnybWc2K0ok0HLbqCej/wRxDpJxToXNOwKt0l7nO8xI1CyQ+GrNfU6bcRzvdbeopQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.81.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.81.1.tgz", + "integrity": "sha512-sYgSO3mlgL0NvBFS3oRfCK4OgKGQwuOWJLzfPyWg0k8MSxSFSDeN/JtrDJD5GQrxskP6c58+vUzruBJQY78AqQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.81.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.81.1.tgz", + "integrity": "sha512-DePpUTAPXJyBurQ4IH2e42DWoA+/Qmr5mbgY4B6ZcxVc/ZUKfTVK31BYIFBATMApWraFc8Q/Sg+yxtfJ3E0wSg==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.81.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.81.1.tgz", + "integrity": "sha512-ViQ+Kxm8BuUP/TcYmH9tViqYKGSD1LBjdqx2p5J+47RES6c+0QHedM0PPAjthMdAHWyb2LGATE9PD2++2rO/tw==", + "license": "MIT", + "dependencies": { + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.81.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.81.1.tgz", + "integrity": "sha512-UNmYtjnZnhouqnbEMC1D5YJot7y0rIaZx7FG2Fv8S3hhNjcGVvO+h9We/tggi273BFkiahQPS/uRsapo1cSapw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.81.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.81.1.tgz", + "integrity": "sha512-KSdY7xb2L0DlLmlYzIOghdw/na4gsMcqJ8u4sD6tOQJr+x3hLujU9s4R8N3ob84/1bkvpvlU5PYKa1ae+OICnw==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.81.1", + "@supabase/functions-js": "2.81.1", + "@supabase/postgrest-js": "2.81.1", + "@supabase/realtime-js": "2.81.1", + "@supabase/storage-js": "2.81.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -3381,12 +3486,19 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/phoenix": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", + "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.1.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3397,6 +3509,15 @@ "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -3458,6 +3579,7 @@ "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.4", "@typescript-eslint/types": "8.46.4", @@ -4020,6 +4142,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4774,6 +4897,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -5812,6 +5936,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6008,6 +6133,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6246,6 +6372,7 @@ "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.23.tgz", "integrity": "sha512-b4uQoiRwQ6nwqsT2709RS15CWYNGF3eJtyr1KyLw9WuMAK7u4jjofkhRiO0+3o1C2NbV+WooyYTOZGubQQMBaQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.16", @@ -6324,6 +6451,7 @@ "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.10.tgz", "integrity": "sha512-Rhtv+X974k0Cahmvx6p7ER5+pNhBC0XbP1lRviL2J1Xl4sT2FBaIuIxF/0I0CbhOsySf0ksqc5caFweAy9Ewiw==", "license": "MIT", + "peer": true, "dependencies": { "@expo/config": "~12.0.10", "@expo/env": "~2.0.7" @@ -6348,6 +6476,7 @@ "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.9.tgz", "integrity": "sha512-xCoQbR/36qqB6tew/LQ6GWICpaBmHLhg/Loix5Rku/0ZtNaXMJv08M9o1AcrdiGTn/Xf/BnLu6DgS45cWQEHZg==", "license": "MIT", + "peer": true, "dependencies": { "fontfaceobserver": "^2.1.0" }, @@ -6409,6 +6538,7 @@ "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.8.tgz", "integrity": "sha512-MyeMcbFDKhXh4sDD1EHwd0uxFQNAc6VCrwBkNvvvufUsTYFq3glTA9Y8a+x78CPpjNqwNAamu74yIaIz7IEJyg==", "license": "MIT", + "peer": true, "dependencies": { "expo-constants": "~18.0.8", "invariant": "^2.2.4" @@ -10668,6 +10798,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10687,6 +10818,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -10723,6 +10855,7 @@ "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.5.tgz", "integrity": "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw==", "license": "MIT", + "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.5", @@ -10780,6 +10913,7 @@ "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz", "integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==", "license": "MIT", + "peer": true, "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", @@ -10827,6 +10961,7 @@ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.5.tgz", "integrity": "sha512-UA6VUbxwhRjEw2gSNrvhkusUq3upfD3Cv+AnB07V+kC8kpvwRVI+ivwY95ePbWNFkFpP+Y2Sdw1WHpHWEV+P2Q==", "license": "MIT", + "peer": true, "dependencies": { "react-native-is-edge-to-edge": "^1.2.1", "semver": "7.7.2" @@ -10855,6 +10990,7 @@ "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", "integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -10865,6 +11001,7 @@ "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.16.0.tgz", "integrity": "sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q==", "license": "MIT", + "peer": true, "dependencies": { "react-freeze": "^1.0.0", "react-native-is-edge-to-edge": "^1.2.1", @@ -10875,11 +11012,24 @@ "react-native": "*" } }, + "node_modules/react-native-url-polyfill": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/react-native-url-polyfill/-/react-native-url-polyfill-3.0.0.tgz", + "integrity": "sha512-aA5CiuUCUb/lbrliVCJ6lZ17/RpNJzvTO/C7gC/YmDQhTUoRD5q5HlJfwLWcxz4VgAhHwXKzhxH+wUN24tAdqg==", + "license": "MIT", + "dependencies": { + "whatwg-url-without-unicode": "8.0.0-3" + }, + "peerDependencies": { + "react-native": "*" + } + }, "node_modules/react-native-web": { "version": "0.21.2", "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", "integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", @@ -10912,6 +11062,7 @@ "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.5.1.tgz", "integrity": "sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w==", "license": "MIT", + "peer": true, "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.0.0-0", "@babel/plugin-transform-class-properties": "^7.0.0-0", @@ -11022,6 +11173,7 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12445,6 +12597,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12651,6 +12804,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13609,6 +13763,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 74b472c..4be37ad 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "android": "expo start --android", "ios": "expo start --ios", "web": "expo start --web", - "lint": "expo lint" + "lint": "expo lint", + "seed:places:bbox": "node scripts/seed-places.js --bbox 37.70,-122.50,37.88,-122.32", + "seed:places:center": "node scripts/seed-places.js --center 37.7858,-122.4064 --radiusKm 10" }, "dependencies": { "@expo/vector-icons": "^15.0.3", @@ -16,6 +18,7 @@ "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", + "@supabase/supabase-js": "^2.81.1", "expo": "~54.0.23", "expo-blur": "~15.0.7", "expo-constants": "~18.0.10", @@ -39,6 +42,7 @@ "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", + "react-native-url-polyfill": "^3.0.0", "react-native-web": "~0.21.0", "react-native-worklets": "0.5.1" }, diff --git a/s/supabase/places.sql b/s/supabase/places.sql new file mode 100644 index 0000000..31143a3 --- /dev/null +++ b/s/supabase/places.sql @@ -0,0 +1,80 @@ +-- Enable PostGIS once per database +create extension if not exists postgis; + +-- Table to store places aggregated from OSM (and optionally other sources) +create table if not exists public.places ( + id text primary key, + name text not null, + category text, + lat double precision not null, + lon double precision not null, + geom geography(Point, 4326) generated always as ( + ST_SetSRID(ST_MakePoint(lon, lat), 4326)::geography + ) stored, + address_line text, + house_number text, + road text, + city text, + state text, + postcode text, + opening_hours_lines text[], + tags jsonb, + updated_at timestamptz not null default now() +); + +-- Index for fast geo queries +create index if not exists places_gix on public.places using gist (geom); +create index if not exists places_name_idx on public.places using gin (to_tsvector('simple', coalesce(name,''))); +create index if not exists places_category_idx on public.places (category); + +-- RPC to fetch nearby places by radius (meters) +create or replace function public.nearby_places( + lat double precision, + lon double precision, + radius_m integer, + limit_count integer default 300, + offset_count integer default 0 +) +returns table ( + id text, + name text, + category text, + lat double precision, + lon double precision, + address_line text, + house_number text, + road text, + city text, + state text, + postcode text, + opening_hours_lines text[] +) language sql stable as $$ + select p.id, + p.name, + p.category, + p.lat, + p.lon, + p.address_line, + p.house_number, + p.road, + p.city, + p.state, + p.postcode, + p.opening_hours_lines + from public.places p + where ST_DWithin( + p.geom, + ST_SetSRID(ST_MakePoint(lon, lat), 4326)::geography, + greatest(100, radius_m) + ) + order by ST_Distance( + p.geom, + ST_SetSRID(ST_MakePoint(lon, lat), 4326)::geography + ) asc + -- Increase the hard ceiling to allow up to 3000 rows per request + limit least(3000, greatest(1, coalesce(limit_count, 300))) + offset greatest(0, coalesce(offset_count, 0)); +$$; + +-- Optional: allow anon to execute RPC (adjust for your security needs) +-- grant execute on function public.nearby_places(double precision,double precision,integer,integer,integer) to anon; diff --git a/scripts/seed-places.js b/scripts/seed-places.js new file mode 100644 index 0000000..6367adf --- /dev/null +++ b/scripts/seed-places.js @@ -0,0 +1,206 @@ +#!/usr/bin/env node +/* + Seed Supabase `places` from Overpass (OSM) for a bounding box or center+radius. + + Usage examples: + node scripts/seed-places.js --bbox 37.70,-122.50,37.88,-122.32 + node scripts/seed-places.js --center 37.7858,-122.4064 --radiusKm 10 + + Requires env: + EXPO_PUBLIC_SUPABASE_URL + EXPO_PUBLIC_SUPABASE_ANON_KEY or EXPO_PUBLIC_SUPABASE_KEY +*/ + +const { createClient } = require("@supabase/supabase-js"); +const fs = require("fs"); +const path = require("path"); + +// Lightweight .env loader so the script works outside Expo +function loadDotEnv() { + try { + const envPath = path.join(process.cwd(), ".env"); + if (!fs.existsSync(envPath)) return; + const content = fs.readFileSync(envPath, "utf8"); + for (const line of content.split(/\r?\n/)) { + if (!line || line.trim().startsWith("#")) continue; + const idx = line.indexOf("="); + if (idx === -1) continue; + const key = line.slice(0, idx).trim(); + const val = line.slice(idx + 1).trim(); + if (!(key in process.env)) { + process.env[key] = val; + } + } + console.log("env: loaded .env for seed script"); + } catch { + // ignore + } +} +loadDotEnv(); + +function parseArgs(argv) { + const args = {}; + for (let i = 2; i < argv.length; i++) { + const a = argv[i]; + if (a === "--bbox") { + const v = argv[++i]; + const [south, west, north, east] = (v || "") + .split(",") + .map((x) => parseFloat(x.trim())); + if ([south, west, north, east].some((n) => Number.isNaN(n))) { + throw new Error("Invalid --bbox. Expected: south,west,north,east"); + } + args.bbox = { south, west, north, east }; + } else if (a === "--center") { + const v = argv[++i]; + const [lat, lon] = (v || "").split(",").map((x) => parseFloat(x.trim())); + if ([lat, lon].some((n) => Number.isNaN(n))) { + throw new Error("Invalid --center. Expected: lat,lon"); + } + args.center = { lat, lon }; + } else if (a === "--radiusKm") { + const v = parseFloat(argv[++i]); + if (!Number.isFinite(v) || v <= 0) throw new Error("Invalid --radiusKm"); + args.radiusKm = v; + } else if (a === "--limit") { + const v = parseInt(argv[++i], 10); + if (!Number.isFinite(v) || v <= 0) throw new Error("Invalid --limit"); + args.limit = v; + } else if (a === "--dryRun") { + args.dryRun = true; + } else { + console.warn("Unknown arg:", a); + } + } + return args; +} + +function bboxFromCenter(lat, lon, radiusKm) { + const radiusDeg = (radiusKm * 1000) / 111000; // ~1 deg = 111km + return { + south: (lat - radiusDeg).toFixed(2), + west: (lon - radiusDeg).toFixed(2), + north: (lat + radiusDeg).toFixed(2), + east: (lon + radiusDeg).toFixed(2), + }; +} + +function buildOverpassQuery(bbox) { + const box = `(${bbox.south},${bbox.west},${bbox.north},${bbox.east})`; + return `[out:json];(node["shop"="supermarket"]${box};way["shop"="supermarket"]${box};node["shop"="greengrocer"]${box};way["shop"="greengrocer"]${box};node["amenity"="food_bank"]${box};way["amenity"="food_bank"]${box};node["amenity"="soup_kitchen"]${box};way["amenity"="soup_kitchen"]${box};node["shop"="bakery"]${box};way["shop"="bakery"]${box};node["shop"="convenience"]${box};way["shop"="convenience"]${box};);out body;>;out skel qt;`; +} + +async function fetchOverpass(query) { + const url = `https://overpass-api.de/api/interpreter?data=${encodeURIComponent( + query + )}`; + const res = await fetch(url, { + headers: { + "User-Agent": "FoodPantryApp/Seeder/1.0", + Accept: "application/json", + }, + }); + if (!res.ok) + throw new Error(`Overpass error ${res.status} ${res.statusText}`); + const json = await res.json(); + return json?.elements ?? []; +} + +function toRow(el) { + const lat = el.lat ?? el.center?.lat; + const lon = el.lon ?? el.center?.lon; + if (lat == null || lon == null) return null; + const tags = el.tags ?? {}; + const display = + tags.name || + tags["operator"] || + tags["brand"] || + `${tags.shop || tags.amenity || "Food resource"} (${el.type}/${el.id})`; + const row = { + id: `overpass_${el.type}_${el.id}`, + name: display, + category: tags.shop || tags.amenity || "food_resource", + lat, + lon, + address_line: undefined, + house_number: tags["addr:housenumber"], + road: tags["addr:street"], + city: tags["addr:city"] || tags["addr:town"] || tags["addr:village"], + state: tags["addr:state"], + postcode: tags["addr:postcode"], + opening_hours_lines: null, // optional: derive later + tags, + }; + const street = [row.house_number, row.road].filter(Boolean).join(" ").trim(); + const stateZip = [row.state, row.postcode].filter(Boolean).join(" ").trim(); + const parts = []; + if (street) parts.push(street); + if (row.city) parts.push(row.city); + if (stateZip) parts.push(stateZip); + row.address_line = parts.join(", "); + return row; +} + +async function upsertRows(supabase, rows, batchSize = 500) { + let total = 0; + for (let i = 0; i < rows.length; i += batchSize) { + const chunk = rows.slice(i, i + batchSize); + const { error, count } = await supabase.from("places").upsert(chunk, { + onConflict: "id", + ignoreDuplicates: false, + count: "exact", + }); + if (error) throw error; + total += chunk.length; + console.log( + `Upserted ${Math.min(i + chunk.length, rows.length)}/${rows.length}` + ); + } + return total; +} + +async function main() { + const args = parseArgs(process.argv); + if (!args.bbox && !args.center) { + console.log( + "Provide --bbox south,west,north,east OR --center lat,lon --radiusKm N" + ); + process.exit(1); + } + const bbox = + args.bbox || + bboxFromCenter(args.center.lat, args.center.lon, args.radiusKm || 10); + console.log("Using bbox:", bbox); + const query = buildOverpassQuery(bbox); + console.log("Overpass query:", query); + + const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL; + const supabaseKey = + process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY || + process.env.EXPO_PUBLIC_SUPABASE_KEY; + if (!supabaseUrl || !supabaseKey) { + throw new Error( + "Missing EXPO_PUBLIC_SUPABASE_URL or EXPO_PUBLIC_SUPABASE_ANON_KEY/KEY in env" + ); + } + const supabase = createClient(supabaseUrl, supabaseKey); + + // Fetch from Overpass + const elements = await fetchOverpass(query); + console.log("Overpass elements:", elements.length); + const rows = elements.map(toRow).filter(Boolean); + console.log("Rows to upsert:", rows.length); + + if (args.dryRun) { + console.log("Dry run; showing first 3 rows:\n", rows.slice(0, 3)); + return; + } + + const count = await upsertRows(supabase, rows); + console.log(`Done. Upserted ${count} rows into public.places`); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/services/scores.ts b/services/scores.ts new file mode 100644 index 0000000..a43e93d --- /dev/null +++ b/services/scores.ts @@ -0,0 +1,47 @@ +// src/services/scores.ts (moved out of app/ to avoid route scanning) +import { supabase } from "../lib/supabase"; + +export type ScoreRow = { + geoid: string; + score_0_100: number; + components: { + subscores?: { + A_lowIncomeLowAccess_flag?: number; + B_lowAccessShare?: number; + C_noVehicleLowAccessShare?: number; + D_vulnerableLowAccessShare?: number; + }; + raw?: { + lila_flag?: number; + lapop1?: number; + lahunv1?: number; + laseniors1?: number; + lakids1?: number; + lasnap1?: number; + pop2010?: number; + vulnerable_rate?: number; + }; + } | null; + vintage: string | null; + computed_at: string; +}; + +/** + * Fetch the food access score for a given census tract (geoid). + */ +export async function fetchScoreForTract( + geoid: string +): Promise { + const { data, error } = await supabase + .from("scores") + .select("geoid, score_0_100, components, vintage, computed_at") + .eq("geoid", geoid) + .maybeSingle(); + + if (error) { + console.error("Error fetching score for tract", geoid, error); + throw error; + } + + return data as ScoreRow | null; +} diff --git a/utils/osm-api.ts b/utils/osm-api.ts index f227c12..23ee065 100644 --- a/utils/osm-api.ts +++ b/utils/osm-api.ts @@ -1,6 +1,12 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { getCachedData, getCachedPlaceHours, setCachedData, setCachedPlaceHours } from './cache'; -import { getDistance } from './distance'; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { + getCachedData, + getCachedPlaceHours, + setCachedData, + setCachedPlaceHours, +} from "./cache"; +import { getDistance } from "./distance"; +import { fetchNearbyPlacesFromSupabase } from "./supabase-places"; export interface OSMPlace { place_id: string; @@ -22,6 +28,12 @@ export interface OSMPlace { // Simple cache to avoid re-fetching const cache = new Map(); const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes +const USE_SUPABASE_PLACES = + (process.env.EXPO_PUBLIC_USE_SUPABASE_PLACES || "").toLowerCase() === "true"; +const OSM_MAX_RESULTS = Math.max( + 50, + parseInt(process.env.EXPO_PUBLIC_OSM_MAX_RESULTS || "100", 10) || 100 +); // In-memory "stale-while-revalidate" cache and inflight deduper // Increased cache duration to 30 minutes to reduce API calls @@ -41,7 +53,7 @@ let lastOverpassRequestTime = 0; // const MAX_DISTANCE_MI = 2.5; // Overpass response types type OverpassElement = { - type: 'node' | 'way' | 'relation'; + type: "node" | "way" | "relation"; id: number; lat?: number; lon?: number; @@ -78,31 +90,35 @@ async function fetchOverpassFoodLocations( let attempt = 0; while (attempt <= OVERPASS_RETRY_LIMIT) { try { - console.log('Overpass query:', query); - const url = `https://overpass-api.de/api/interpreter?data=${encodeURIComponent(query)}`; + console.log("Overpass query:", query); + const url = `https://overpass-api.de/api/interpreter?data=${encodeURIComponent( + query + )}`; lastOverpassRequestTime = Date.now(); const res = await fetch(url, { headers: { - 'User-Agent': 'FoodPantryApp/1.0 (Educational Project)', - Accept: 'application/json', + "User-Agent": "FoodPantryApp/1.0 (Educational Project)", + Accept: "application/json", }, }); if (res.status === 429) { attempt += 1; if (attempt > OVERPASS_RETRY_LIMIT) { - console.error('Overpass rate limit exceeded (429) โ€“ giving up'); + console.error("Overpass rate limit exceeded (429) โ€“ giving up"); return []; } const wait = OVERPASS_RETRY_BASE_DELAY * attempt; - console.warn(`Overpass rate limit hit (429). Retrying in ${wait}ms (attempt ${attempt})`); + console.warn( + `Overpass rate limit hit (429). Retrying in ${wait}ms (attempt ${attempt})` + ); await new Promise((r) => setTimeout(r, wait)); continue; } if (!res.ok) { - console.error('Overpass API error:', res.status, res.statusText); + console.error("Overpass API error:", res.status, res.statusText); return []; } @@ -117,24 +133,29 @@ async function fetchOverpassFoodLocations( const tags = el.tags ?? {}; const display = tags.name || - tags['operator'] || - tags['brand'] || - `${tags.shop || tags.amenity || 'Food resource'} (${el.type}/${el.id})`; + tags["operator"] || + tags["brand"] || + `${tags.shop || tags.amenity || "Food resource"} (${el.type}/${ + el.id + })`; const openingRaw = tags.opening_hours as string | undefined; - const formatted = openingRaw ? formatOpeningHoursLines(openingRaw) : undefined; + const formatted = openingRaw + ? formatOpeningHoursLines(openingRaw) + : undefined; return { place_id: `overpass_${el.type}_${el.id}`, lat: String(lat), lon: String(lon), display_name: display, - type: tags.shop || tags.amenity || 'food_resource', + type: tags.shop || tags.amenity || "food_resource", address: { - house_number: tags['addr:housenumber'], - road: tags['addr:street'], - city: tags['addr:city'] || tags['addr:town'] || tags['addr:village'], - state: tags['addr:state'], - postcode: tags['addr:postcode'], + house_number: tags["addr:housenumber"], + road: tags["addr:street"], + city: + tags["addr:city"] || tags["addr:town"] || tags["addr:village"], + state: tags["addr:state"], + postcode: tags["addr:postcode"], }, openingHours: formatted, }; @@ -144,7 +165,7 @@ async function fetchOverpassFoodLocations( } catch (e) { attempt += 1; if (attempt > OVERPASS_RETRY_LIMIT) { - console.error('Overpass API error (giving up):', e); + console.error("Overpass API error (giving up):", e); return []; } const wait = OVERPASS_RETRY_BASE_DELAY * attempt; @@ -157,7 +178,9 @@ async function fetchOverpassFoodLocations( // Helper: hydrate opening hours for places that don't have them yet async function hydrateOpeningHours(places: OSMPlace[]): Promise { - const needHours = places.filter((p) => !p.openingHours || p.openingHours.length === 0); + const needHours = places.filter( + (p) => !p.openingHours || p.openingHours.length === 0 + ); // First try in-memory or persistent cache to avoid network calls for (const p of needHours) { @@ -174,7 +197,9 @@ async function hydrateOpeningHours(places: OSMPlace[]): Promise { } // Fetch remaining via APIs (small concurrency to respect usage policies) - const toFetch = needHours.filter((p) => !p.openingHours || p.openingHours.length === 0); + const toFetch = needHours.filter( + (p) => !p.openingHours || p.openingHours.length === 0 + ); if (!toFetch.length) return; const CONCURRENCY = 2; @@ -198,39 +223,63 @@ async function hydrateOpeningHours(places: OSMPlace[]): Promise { } } - const workers = Array.from({ length: Math.min(CONCURRENCY, toFetch.length) }, worker); + const workers = Array.from( + { length: Math.min(CONCURRENCY, toFetch.length) }, + worker + ); await Promise.all(workers); } export async function searchNearbyFoodLocations( latitude: number, longitude: number, - radiusKm: number = 5, + radiusKm: number = 10, opts?: { force?: boolean } ): Promise { + // Optional: prefer Supabase-backed places if enabled + if (USE_SUPABASE_PLACES) { + console.log( + `Using Supabase places (limit=${OSM_MAX_RESULTS}, radiusKm=${radiusKm})` + ); + const supa = await fetchNearbyPlacesFromSupabase({ + lat: latitude, + lon: longitude, + radiusKm, + limit: OSM_MAX_RESULTS, + }); + if (supa && supa.length) { + console.log(`Supabase returned: ${supa.length}`); + return supa; + } + // fall through to OSM if Supabase unavailable/empty + } const cacheKey = `locations_${latitude.toFixed(2)}_${longitude.toFixed(2)}`; - console.log(`searchNearbyFoodLocations called: ${cacheKey}, force=${opts?.force}`); + console.log( + `searchNearbyFoodLocations called: ${cacheKey}, force=${opts?.force}` + ); // If forcing, skip fast paths and drop in-memory entry for this key if (!opts?.force) { // 1) Fast path: valid in-memory cache const mem = memoryCache.get(cacheKey); if (mem && Date.now() - mem.ts < MEMORY_TTL_MS) { - console.log('Returning from memory cache'); + console.log("Returning from memory cache"); // seed hours memory cache from stored places mem.data.forEach((p) => { - if (p.openingHours?.length) hoursMemoryCache.set(p.place_id, p.openingHours); + if (p.openingHours?.length) + hoursMemoryCache.set(p.place_id, p.openingHours); }); return mem.data; } // 2) Fast path: persistent cache -> memory - console.log('Checking persistent cache...'); + console.log("Checking persistent cache..."); const cached = await getCachedData(cacheKey); if (cached && cached.length) { console.log(`Found ${cached.length} items in persistent cache`); cached.forEach((p) => { - if (p.openingHours?.length) hoursMemoryCache.set(p.place_id, p.openingHours); + if (p.openingHours?.length) + hoursMemoryCache.set(p.place_id, p.openingHours); }); memoryCache.set(cacheKey, { data: cached, ts: Date.now() }); return cached; @@ -238,22 +287,28 @@ export async function searchNearbyFoodLocations( // 3) Deduplicate concurrent fetches if (inflight.has(cacheKey)) { - console.log('Waiting for inflight request...'); + console.log("Waiting for inflight request..."); return inflight.get(cacheKey)!; } } else { // Drop only in-memory entry for this key; persistent cache will be replaced after fetch - console.log('Force flag set, clearing memory cache'); + console.log("Force flag set, clearing memory cache"); memoryCache.delete(cacheKey); } - console.log('Starting new fetch from Overpass...'); + console.log("Starting new fetch from Overpass..."); const fetchPromise = (async () => { try { // Use Overpass API with bounding box for food-related locations const radiusMeters = radiusKm * 1000; - console.log(`Calling fetchOverpassFoodLocations with radius ${radiusMeters}m`); - const overpassResults = await fetchOverpassFoodLocations(latitude, longitude, radiusMeters); + console.log( + `Calling fetchOverpassFoodLocations with radius ${radiusMeters}m` + ); + const overpassResults = await fetchOverpassFoodLocations( + latitude, + longitude, + radiusMeters + ); console.log(`Overpass returned: ${overpassResults.length}`); @@ -271,7 +326,7 @@ export async function searchNearbyFoodLocations( // Return all results sorted by distance, no filtering return resultsWithDistance .sort((a, b) => a.distance - b.distance) - .slice(0, 100) + .slice(0, OSM_MAX_RESULTS) .map((item) => item.place); }; @@ -279,23 +334,24 @@ export async function searchNearbyFoodLocations( console.log(`After sorting/capping: ${cappedResults.length} results`); // Hydrate opening hours (cached, then network where needed) - console.log('Hydrating opening hours...'); + console.log("Hydrating opening hours..."); await hydrateOpeningHours(cappedResults); - console.log('Opening hours hydrated'); + console.log("Opening hours hydrated"); // Save caches (persist + memory), including openingHours - console.log('Saving to cache...'); + console.log("Saving to cache..."); await setCachedData(cacheKey, cappedResults); memoryCache.set(cacheKey, { data: cappedResults, ts: Date.now() }); cappedResults.forEach((p) => { - if (p.openingHours?.length) hoursMemoryCache.set(p.place_id, p.openingHours); + if (p.openingHours?.length) + hoursMemoryCache.set(p.place_id, p.openingHours); }); - console.log('Cache saved, returning results'); + console.log("Cache saved, returning results"); return cappedResults; } finally { inflight.delete(cacheKey); - console.log('Removed from inflight'); + console.log("Removed from inflight"); } })(); @@ -303,7 +359,9 @@ export async function searchNearbyFoodLocations( return fetchPromise; } -export async function getOpeningHours(placeId: string): Promise { +export async function getOpeningHours( + placeId: string +): Promise { // 1) in-memory hours const mem = hoursMemoryCache.get(placeId); if (mem && mem.length) return mem; @@ -326,71 +384,96 @@ export async function getOpeningHours(placeId: string): Promise } export function formatOSMAddress(place: OSMPlace): string { - if (!place.address) return ''; - - const { road, house_number, city, state, postcode } = place.address; - return [ - house_number ? `${house_number} ` : '', - road, - city ? `, ${city}` : '', - supermarket: 'Supermarket', - greengrocer: 'Greengrocer', - convenience: 'Convenience Store', - bakery: 'Bakery', - market: 'Market', - marketplace: 'Market', - deli: 'Deli', + const a = place.address; + if (!a) return ""; + + const street = [a.house_number, a.road].filter(Boolean).join(" ").trim(); + const parts: string[] = []; + if (street) parts.push(street); + if (a.city) parts.push(a.city); + const stateZip = [a.state, a.postcode].filter(Boolean).join(" ").trim(); + if (stateZip) parts.push(stateZip); + return parts.join(", "); +} + +export function categorizePlace(place: OSMPlace): string { + const type = (place.type || "").toLowerCase(); + const categoryMap: Record = { + food_bank: "Food Bank", + soup_kitchen: "Soup Kitchen", + supermarket: "Supermarket", + greengrocer: "Greengrocer", + convenience: "Convenience Store", + bakery: "Bakery", + market: "Market", + marketplace: "Market", + deli: "Deli", }; if (categoryMap[type]) return categoryMap[type]; - - if (/market/.test(type)) return 'Market'; - if (/grocery|supermarket|store/.test(type)) return 'Grocery Store'; - if (/pantry|fridge/.test(type)) return 'Food Pantry'; - - return 'Other'; + if (/market/.test(type)) return "Market"; + if (/grocery|supermarket|store/.test(type)) return "Grocery Store"; + if (/pantry|fridge/.test(type)) return "Food Pantry"; + return "Other"; } // Format an OSM opening_hours string into readable lines function formatOpeningHoursLines(opening: string): string[] { - if (!opening) return []; - if (opening.trim() === '24/7') return ['Open 24/7']; + if (!opening) return []; + if (opening.trim() === "24/7") return ["Open 24/7"]; const dayMap: Record = { - Mo: 'Mon', Tu: 'Tue', We: 'Wed', Th: 'Thu', Fr: 'Fri', Sa: 'Sat', Su: 'Sun', + Mo: "Mon", + Tu: "Tue", + We: "Wed", + Th: "Thu", + Fr: "Fri", + Sa: "Sat", + Su: "Sun", }; const beautifyDays = (s: string) => - s.replace(/\b(Mo|Tu|We|Th|Fr|Sa|Su)\b/g, (m) => dayMap[m] || m) - .replace(/\b(Mon|Tue|Wed|Thu|Fri|Sat|Sun)-(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\b/g, '$1โ€“$2'); + s + .replace(/\b(Mo|Tu|We|Th|Fr|Sa|Su)\b/g, (m) => dayMap[m] || m) + .replace( + /\b(Mon|Tue|Wed|Thu|Fri|Sat|Sun)-(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\b/g, + "$1โ€“$2" + ); return opening - .split(';') + .split(";") .map((part) => part.trim()) .filter(Boolean) .map(beautifyDays); } // Fetch opening hours for Overpass id types: overpass_node_123, overpass_way_456, overpass_relation_789 -async function fetchOverpassOpeningHoursById(overpassId: string): Promise { +async function fetchOverpassOpeningHoursById( + overpassId: string +): Promise { try { - const [, type, rawId] = overpassId.split('_'); // ['overpass', 'node', '123'] - if (!type || !rawId) return null; - - const query = `[out:json][timeout:25]; ${type}(${rawId}); out tags;`; - const res = await fetch('https://overpass-api.de/api/interpreter', { - method: 'POST', + const parts = overpassId.split("_"); + if (parts.length < 3) return null; + const typeWord = parts[1]; // node | way | relation + const rawId = parts[2]; + if (!typeWord || !rawId) return null; + + const query = `[out:json][timeout:25]; ${typeWord}(${rawId}); out tags;`; + const res = await fetch("https://overpass-api.de/api/interpreter", { + method: "POST", headers: { - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', - 'User-Agent': 'FoodPantryApp/1.0 (Educational Project)', - Accept: 'application/json', + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "User-Agent": "FoodPantryApp/1.0 (Educational Project)", + Accept: "application/json", }, body: `data=${encodeURIComponent(query)}`, }); if (!res.ok) return null; const json = await res.json(); - const opening = json?.elements?.[0]?.tags?.opening_hours as string | undefined; + const opening = json?.elements?.[0]?.tags?.opening_hours as + | string + | undefined; if (!opening) return null; const lines = formatOpeningHoursLines(opening); return lines.length ? lines : null; @@ -418,16 +501,16 @@ export async function clearOSMMemoryCache() { const keys = await AsyncStorage.getAllKeys(); const toRemove = keys.filter( (k) => - k.startsWith('osm_cache_') || // places list cache - k.startsWith('osm_hours_') || // per-place hours - k.includes('locations_') // legacy/unprefixed keys + k.startsWith("osm_cache_") || // places list cache + k.startsWith("osm_hours_") || // per-place hours + k.includes("locations_") // legacy/unprefixed keys ); if (toRemove.length) { await AsyncStorage.multiRemove(toRemove); console.log(`OSM: Cleared ${toRemove.length} persisted cache keys`); } } catch (e) { - console.warn('OSM: Persistent cache clear failed', e); + console.warn("OSM: Persistent cache clear failed", e); } cacheClearListeners.forEach((cb) => { diff --git a/utils/supabase-places.ts b/utils/supabase-places.ts new file mode 100644 index 0000000..6eaf7e4 --- /dev/null +++ b/utils/supabase-places.ts @@ -0,0 +1,72 @@ +import { supabase } from "@/lib/supabase"; +import type { OSMPlace } from "./osm-api"; + +type NearbyParams = { + lat: number; + lon: number; + radiusKm?: number; + limit?: number; // total desired max to return +}; + +// Fetch nearby places from Supabase via an RPC that uses PostGIS for fast radius queries. +export async function fetchNearbyPlacesFromSupabase({ + lat, + lon, + radiusKm = 10, + limit = 300, +}: NearbyParams): Promise { + try { + const radius_m = Math.max(100, Math.floor(radiusKm * 1000)); + const PAGE = 1000; // Supabase/PostgREST typical row cap per request + const target = Math.max(1, limit); + let offset = 0; + const all: any[] = []; + + while (all.length < target) { + const pageLimit = Math.min(PAGE, target - all.length); + const { data, error } = await supabase.rpc("nearby_places", { + lat, + lon, + radius_m, + limit_count: pageLimit, + offset_count: offset, + }); + + if (error) { + console.error("Supabase nearby_places RPC error:", error); + break; + } + + const rows: any[] = Array.isArray(data) ? data : []; + all.push(...rows); + if (rows.length < pageLimit) break; // no more rows + offset += rows.length; + } + + // Map rows to OSMPlace-like structure used by the app + const mapped: OSMPlace[] = all.slice(0, target).map((row: any) => ({ + place_id: row.id + ? String(row.id) + : `sb_${row.name}_${row.lat}_${row.lon}`, + lat: String(row.lat), + lon: String(row.lon), + display_name: row.name || "Food resource", + type: row.category || row.type || "food_resource", + address: { + road: row.road || row.address_line || undefined, + house_number: row.house_number || undefined, + city: row.city || undefined, + state: row.state || undefined, + postcode: row.postcode || undefined, + }, + openingHours: Array.isArray(row.opening_hours_lines) + ? (row.opening_hours_lines as string[]) + : undefined, + })); + + return mapped; + } catch (e) { + console.error("Supabase nearby fetch failed:", e); + return null; + } +}