From b50cd5d8db59a49e62650a22fe30ac2401dbbe4e Mon Sep 17 00:00:00 2001 From: Jake Floch Date: Fri, 14 Nov 2025 22:07:45 -0500 Subject: [PATCH 1/5] Fixed styling and osm-api.ts problems preventing simulator from working on my end Refactored utils/osm-api.ts for improved readability and maintainability, including better address formatting and place categorization. Updated app/(tabs)/index.tsx to use consistent code style, fixed minor style issues, and improved UI code structure. No functional changes to package-lock.json except for dependency metadata. --- app/(tabs)/index.tsx | 389 ++++++++++++++++++++++++++----------------- package-lock.json | 26 +++ utils/osm-api.ts | 216 +++++++++++++++--------- 3 files changed, 397 insertions(+), 234 deletions(-) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 3f889e8..3ad42e5 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,21 @@ 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 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 +56,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 @@ -87,26 +99,30 @@ export default function HomeScreen() { try { const { status } = await Location.requestForegroundPermissionsAsync(); - if (status !== 'granted') { - console.log('Location permission not granted'); + if (status !== "granted") { + console.log("Location permission not granted"); setLoading(false); hasInitialLoad.current = true; isInitializing.current = false; return; } - console.log('Getting current position...'); + console.log("Getting current position..."); const locationData = await Location.getCurrentPositionAsync({}); - console.log('Current position:', locationData.coords.latitude, locationData.coords.longitude); + console.log( + "Current position:", + locationData.coords.latitude, + locationData.coords.longitude + ); // Only fetch OSM data with larger radius - console.log('Fetching OSM data...'); - + console.log("Fetching OSM data..."); + // Add timeout protection const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error('Request timeout after 30s')), 30000); + setTimeout(() => reject(new Error("Request timeout after 30s")), 30000); }); - + const osmPlaces = await Promise.race([ searchNearbyFoodLocations( locationData.coords.latitude, @@ -114,13 +130,13 @@ export default function HomeScreen() { 10, // Increased from default 5km to 10km force ? { force: true } : undefined // bypass caches on manual refresh ), - timeoutPromise + timeoutPromise, ]); console.log(`Fetched ${osmPlaces.length} places from OSM`); if (osmPlaces.length === 0) { - console.warn('No OSM places found in the area'); + console.warn("No OSM places found in the area"); setSortedLocations([]); setLastUpdated(Date.now()); hasInitialLoad.current = true; @@ -138,7 +154,7 @@ export default function HomeScreen() { ); return { id: place.place_id || `osm-${index}`, - name: place.display_name.split(',')[0], + name: place.display_name.split(",")[0], address: formatOSMAddress(place), type: categorizePlace(place), coordinate: { @@ -151,14 +167,14 @@ export default function HomeScreen() { console.log(`Mapped ${nextLocations.length} locations`); const sorted = sortByDistance(nextLocations); - console.log('Setting sorted locations...'); + console.log("Setting sorted locations..."); setSortedLocations(sorted); setLastUpdated(Date.now()); hasInitialLoad.current = true; isInitializing.current = false; - console.log('Location fetch complete'); + console.log("Location fetch complete"); } catch (error) { - console.error('Error getting location:', error); + console.error("Error getting location:", error); hasInitialLoad.current = true; isInitializing.current = false; // Don't set fallback data on error @@ -194,41 +210,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 +286,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 +319,22 @@ export default function HomeScreen() { {/* FOOD ACCESS SCORE CARD */} Food Access Score - + {score.label} - + {score.hint} @@ -288,8 +345,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 +363,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 +402,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 +452,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 +497,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 +521,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 +534,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 +563,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 +586,7 @@ const styles = StyleSheet.create({ }, resultsMeta: { fontSize: 12, - color: '#6b7280', + color: "#6b7280", marginBottom: 4, }, @@ -510,12 +595,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 +611,8 @@ const styles = StyleSheet.create({ opacity: 0.98, }, cardRow: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", }, leading: { marginRight: 12, @@ -536,15 +621,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 +641,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 +674,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/package-lock.json b/package-lock.json index 41378f7..8547994 100644 --- a/package-lock.json +++ b/package-lock.json @@ -89,6 +89,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 +1471,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 +3185,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", @@ -3387,6 +3390,7 @@ "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3458,6 +3462,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 +4025,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 +4780,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -5812,6 +5819,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 +6016,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 +6255,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 +6334,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 +6359,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 +6421,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 +10681,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 +10701,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 +10738,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 +10796,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 +10844,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 +10873,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 +10884,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", @@ -10880,6 +10900,7 @@ "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 +10933,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 +11044,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 +12468,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12651,6 +12675,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13609,6 +13634,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/utils/osm-api.ts b/utils/osm-api.ts index f227c12..3de14e5 100644 --- a/utils/osm-api.ts +++ b/utils/osm-api.ts @@ -1,6 +1,11 @@ -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"; export interface OSMPlace { place_id: string; @@ -41,7 +46,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 +83,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 +126,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 +158,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 +171,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 +190,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,7 +216,10 @@ 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); } @@ -209,28 +230,32 @@ export async function searchNearbyFoodLocations( opts?: { force?: boolean } ): Promise { 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 +263,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}`); @@ -279,23 +310,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 +335,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 +360,93 @@ 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'] + 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 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 +474,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) => { From a2fbf6a3ca17d0fd5ce8436acdee597fdb101daf Mon Sep 17 00:00:00 2001 From: Jake Floch Date: Sat, 15 Nov 2025 02:07:16 -0500 Subject: [PATCH 2/5] Add Supabase integration and improve location handling Introduces Supabase client setup and a new scores service for fetching food access scores. Updates .env with Supabase credentials and adds related dependencies. Refactors location fetching in explore and index tabs for improved accuracy and loading state management, and increases the OSM search radius to 10km. Cleans up Babel config formatting. --- .gitignore | 1 + app/(tabs)/explore.tsx | 16 ++--- app/(tabs)/index.tsx | 142 ++++++++++++++++++++--------------------- babel.config.js | 13 ++-- lib/supabase.ts | 27 ++++++++ package-lock.json | 129 +++++++++++++++++++++++++++++++++++++ package.json | 2 + services/scores.ts | 47 ++++++++++++++ utils/osm-api.ts | 2 +- 9 files changed, 290 insertions(+), 89 deletions(-) create mode 100644 lib/supabase.ts create mode 100644 services/scores.ts 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..9dd537b 100644 --- a/app/(tabs)/explore.tsx +++ b/app/(tabs)/explore.tsx @@ -49,7 +49,7 @@ export default function TabTwoScreen() { const osmPlaces = await searchNearbyFoodLocations( location.coords.latitude, location.coords.longitude, - undefined, + 10, opts?.force ? { force: true } : undefined ); @@ -123,9 +123,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 +135,7 @@ export default function TabTwoScreen() { (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 ""; @@ -92,94 +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 - ); - - // 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 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 + ); - 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 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 8547994..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" }, @@ -3257,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", @@ -3384,6 +3486,12 @@ "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", @@ -3401,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", @@ -10895,6 +11012,18 @@ "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", diff --git a/package.json b/package.json index 74b472c..10ea04a 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,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 +40,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/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 3de14e5..fdd80b5 100644 --- a/utils/osm-api.ts +++ b/utils/osm-api.ts @@ -226,7 +226,7 @@ async function hydrateOpeningHours(places: OSMPlace[]): Promise { export async function searchNearbyFoodLocations( latitude: number, longitude: number, - radiusKm: number = 5, + radiusKm: number = 10, opts?: { force?: boolean } ): Promise { const cacheKey = `locations_${latitude.toFixed(2)}_${longitude.toFixed(2)}`; From aa965b4cf2c6797d1eb8c8bb512ac6e23ce3c034 Mon Sep 17 00:00:00 2001 From: Jake Floch Date: Sat, 15 Nov 2025 02:27:26 -0500 Subject: [PATCH 3/5] Add Supabase-backed places and seeding script Introduces a Supabase SQL schema and PostGIS-powered RPC for storing and querying places. Adds a script to seed the Supabase 'places' table from OSM Overpass API, and integrates Supabase as an optional backend for fetching nearby places in the app. Updates the explore tab to use Supabase data if enabled, and adds related utility code and npm scripts. --- app/(tabs)/explore.tsx | 229 +++++++++++++++++++++------------------ package.json | 4 +- s/supabase/places.sql | 77 +++++++++++++ scripts/seed-places.js | 188 ++++++++++++++++++++++++++++++++ utils/osm-api.ts | 21 +++- utils/supabase-places.ts | 58 ++++++++++ 6 files changed, 471 insertions(+), 106 deletions(-) create mode 100644 s/supabase/places.sql create mode 100644 scripts/seed-places.js create mode 100644 utils/supabase-places.ts diff --git a/app/(tabs)/explore.tsx b/app/(tabs)/explore.tsx index 9dd537b..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 { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { FoodLocation, foodLocations } from '@/constants/locations'; -import { formatDistance, getDistance } from '@/utils/distance'; +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 { 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, + }); + + 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`); - 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, - 10, - opts?.force ? { force: true } : undefined - ); - - 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( + 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, - 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) => ({ - ...loc, - distance: formatDistance(loc.calculatedDistance), - })) - ); + 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(() => { @@ -124,8 +135,8 @@ export default function TabTwoScreen() { }, [loadLocations]); const mapRegion = { - latitude: (userLocation?.latitude ?? 33.7676), - longitude: (userLocation?.longitude ?? -84.3908), + latitude: userLocation?.latitude ?? 33.7676, + longitude: userLocation?.longitude ?? -84.3908, latitudeDelta: 0.15, longitudeDelta: 0.15, }; @@ -149,7 +160,7 @@ export default function TabTwoScreen() { { router.push({ - pathname: '/option/[id]', + pathname: "/option/[id]", params: { id: location.id, name: location.name, @@ -161,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 && } @@ -196,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, @@ -224,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, @@ -248,8 +269,8 @@ const styles = StyleSheet.create({ }, calloutTap: { fontSize: 12, - color: '#2563eb', - fontWeight: '700', + color: "#2563eb", + fontWeight: "700", marginTop: 6, }, }); diff --git a/package.json b/package.json index 10ea04a..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", diff --git a/s/supabase/places.sql b/s/supabase/places.sql new file mode 100644 index 0000000..b6c8277 --- /dev/null +++ b/s/supabase/places.sql @@ -0,0 +1,77 @@ +-- 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 +) +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 + limit least(1000, greatest(1, coalesce(limit_count, 300))); +$$; + +-- Optional: allow anon to execute RPC (adjust for your security needs) +-- grant execute on function public.nearby_places(double precision,double precision,integer,integer) to anon; diff --git a/scripts/seed-places.js b/scripts/seed-places.js new file mode 100644 index 0000000..9e888dd --- /dev/null +++ b/scripts/seed-places.js @@ -0,0 +1,188 @@ +#!/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/utils/osm-api.ts b/utils/osm-api.ts index fdd80b5..a4ed6d8 100644 --- a/utils/osm-api.ts +++ b/utils/osm-api.ts @@ -1,4 +1,5 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; +import { fetchNearbyPlacesFromSupabase } from "./supabase-places"; import { getCachedData, getCachedPlaceHours, @@ -27,6 +28,11 @@ 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 @@ -229,6 +235,19 @@ export async function searchNearbyFoodLocations( radiusKm: number = 10, opts?: { force?: boolean } ): Promise { + // Optional: prefer Supabase-backed places if enabled + if (USE_SUPABASE_PLACES) { + const supa = await fetchNearbyPlacesFromSupabase({ + lat: latitude, + lon: longitude, + radiusKm, + limit: OSM_MAX_RESULTS, + }); + if (supa && 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}` @@ -302,7 +321,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); }; diff --git a/utils/supabase-places.ts b/utils/supabase-places.ts new file mode 100644 index 0000000..a1b552d --- /dev/null +++ b/utils/supabase-places.ts @@ -0,0 +1,58 @@ +import { supabase } from '@/lib/supabase'; +import type { OSMPlace } from './osm-api'; + +type NearbyParams = { + lat: number; + lon: number; + radiusKm?: number; + limit?: number; +}; + +// 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 { data, error } = await supabase.rpc('nearby_places', { + lat, + lon, + radius_m, + limit_count: limit, + }); + + if (error) { + console.error('Supabase nearby_places RPC error:', error); + return null; + } + + if (!Array.isArray(data)) return []; + + // Map rows to OSMPlace-like structure used by the app + const mapped: OSMPlace[] = data.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; + } +} From 0ebf897f077f2cb50a596420c6091589dbd0d4bf Mon Sep 17 00:00:00 2001 From: Jake Floch Date: Sat, 15 Nov 2025 02:47:29 -0500 Subject: [PATCH 4/5] Add offset-based pagination and increase limit for Supabase places Extended the Supabase 'nearby_places' function and client to support offset-based pagination and increased the hard row limit from 1000 to 3000. Updated the seed script for consistent code style and improved error handling. Enhanced the OSM API integration to log Supabase usage and improved Overpass ID parsing. --- s/supabase/places.sql | 9 ++-- scripts/seed-places.js | 112 +++++++++++++++++++++++---------------- utils/osm-api.ts | 20 ++++--- utils/supabase-places.ts | 48 ++++++++++------- 4 files changed, 115 insertions(+), 74 deletions(-) diff --git a/s/supabase/places.sql b/s/supabase/places.sql index b6c8277..31143a3 100644 --- a/s/supabase/places.sql +++ b/s/supabase/places.sql @@ -32,7 +32,8 @@ create or replace function public.nearby_places( lat double precision, lon double precision, radius_m integer, - limit_count integer default 300 + limit_count integer default 300, + offset_count integer default 0 ) returns table ( id text, @@ -70,8 +71,10 @@ returns table ( p.geom, ST_SetSRID(ST_MakePoint(lon, lat), 4326)::geography ) asc - limit least(1000, greatest(1, coalesce(limit_count, 300))); + -- 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) to anon; +-- 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 index 9e888dd..6367adf 100644 --- a/scripts/seed-places.js +++ b/scripts/seed-places.js @@ -11,19 +11,19 @@ EXPO_PUBLIC_SUPABASE_ANON_KEY or EXPO_PUBLIC_SUPABASE_KEY */ -const { createClient } = require('@supabase/supabase-js'); -const fs = require('fs'); -const path = require('path'); +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'); + const envPath = path.join(process.cwd(), ".env"); if (!fs.existsSync(envPath)) return; - const content = fs.readFileSync(envPath, 'utf8'); + 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 (!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(); @@ -31,7 +31,7 @@ function loadDotEnv() { process.env[key] = val; } } - console.log('env: loaded .env for seed script'); + console.log("env: loaded .env for seed script"); } catch { // ignore } @@ -42,32 +42,34 @@ function parseArgs(argv) { const args = {}; for (let i = 2; i < argv.length; i++) { const a = argv[i]; - if (a === '--bbox') { + if (a === "--bbox") { const v = argv[++i]; - const [south, west, north, east] = (v || '').split(',').map((x) => parseFloat(x.trim())); + 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'); + throw new Error("Invalid --bbox. Expected: south,west,north,east"); } args.bbox = { south, west, north, east }; - } else if (a === '--center') { + } else if (a === "--center") { const v = argv[++i]; - const [lat, lon] = (v || '').split(',').map((x) => parseFloat(x.trim())); + 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'); + throw new Error("Invalid --center. Expected: lat,lon"); } args.center = { lat, lon }; - } else if (a === '--radiusKm') { + } else if (a === "--radiusKm") { const v = parseFloat(argv[++i]); - if (!Number.isFinite(v) || v <= 0) throw new Error('Invalid --radiusKm'); + if (!Number.isFinite(v) || v <= 0) throw new Error("Invalid --radiusKm"); args.radiusKm = v; - } else if (a === '--limit') { + } else if (a === "--limit") { const v = parseInt(argv[++i], 10); - if (!Number.isFinite(v) || v <= 0) throw new Error('Invalid --limit'); + if (!Number.isFinite(v) || v <= 0) throw new Error("Invalid --limit"); args.limit = v; - } else if (a === '--dryRun') { + } else if (a === "--dryRun") { args.dryRun = true; } else { - console.warn('Unknown arg:', a); + console.warn("Unknown arg:", a); } } return args; @@ -89,14 +91,17 @@ function buildOverpassQuery(bbox) { } async function fetchOverpass(query) { - const url = `https://overpass-api.de/api/interpreter?data=${encodeURIComponent(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', + "User-Agent": "FoodPantryApp/Seeder/1.0", + Accept: "application/json", }, }); - if (!res.ok) throw new Error(`Overpass error ${res.status} ${res.statusText}`); + if (!res.ok) + throw new Error(`Overpass error ${res.status} ${res.statusText}`); const json = await res.json(); return json?.elements ?? []; } @@ -107,29 +112,32 @@ function toRow(el) { 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})`; + 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', + 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'], + 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 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(', '); + row.address_line = parts.join(", "); return row; } @@ -137,14 +145,16 @@ 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', + const { error, count } = await supabase.from("places").upsert(chunk, { + onConflict: "id", ignoreDuplicates: false, - count: 'exact', + count: "exact", }); if (error) throw error; total += chunk.length; - console.log(`Upserted ${Math.min(i + chunk.length, rows.length)}/${rows.length}`); + console.log( + `Upserted ${Math.min(i + chunk.length, rows.length)}/${rows.length}` + ); } return total; } @@ -152,29 +162,37 @@ async function upsertRows(supabase, rows, batchSize = 500) { 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'); + 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 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); + 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; + 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'); + 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); + console.log("Overpass elements:", elements.length); const rows = elements.map(toRow).filter(Boolean); - console.log('Rows to upsert:', rows.length); + console.log("Rows to upsert:", rows.length); if (args.dryRun) { - console.log('Dry run; showing first 3 rows:\n', rows.slice(0, 3)); + console.log("Dry run; showing first 3 rows:\n", rows.slice(0, 3)); return; } diff --git a/utils/osm-api.ts b/utils/osm-api.ts index a4ed6d8..23ee065 100644 --- a/utils/osm-api.ts +++ b/utils/osm-api.ts @@ -1,5 +1,4 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; -import { fetchNearbyPlacesFromSupabase } from "./supabase-places"; import { getCachedData, getCachedPlaceHours, @@ -7,6 +6,7 @@ import { setCachedPlaceHours, } from "./cache"; import { getDistance } from "./distance"; +import { fetchNearbyPlacesFromSupabase } from "./supabase-places"; export interface OSMPlace { place_id: string; @@ -28,10 +28,11 @@ 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 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 + parseInt(process.env.EXPO_PUBLIC_OSM_MAX_RESULTS || "100", 10) || 100 ); // In-memory "stale-while-revalidate" cache and inflight deduper @@ -237,6 +238,9 @@ export async function searchNearbyFoodLocations( ): 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, @@ -244,6 +248,7 @@ export async function searchNearbyFoodLocations( limit: OSM_MAX_RESULTS, }); if (supa && supa.length) { + console.log(`Supabase returned: ${supa.length}`); return supa; } // fall through to OSM if Supabase unavailable/empty @@ -447,10 +452,13 @@ async function fetchOverpassOpeningHoursById( overpassId: string ): Promise { try { - const [, type, rawId] = overpassId.split("_"); // ['overpass', 'node', '123'] - if (!type || !rawId) return null; + 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]; ${type}(${rawId}); out tags;`; + const query = `[out:json][timeout:25]; ${typeWord}(${rawId}); out tags;`; const res = await fetch("https://overpass-api.de/api/interpreter", { method: "POST", headers: { diff --git a/utils/supabase-places.ts b/utils/supabase-places.ts index a1b552d..7395c42 100644 --- a/utils/supabase-places.ts +++ b/utils/supabase-places.ts @@ -1,11 +1,11 @@ -import { supabase } from '@/lib/supabase'; -import type { OSMPlace } from './osm-api'; +import { supabase } from "@/lib/supabase"; +import type { OSMPlace } from "./osm-api"; type NearbyParams = { lat: number; lon: number; radiusKm?: number; - limit?: number; + limit?: number; // total desired max to return }; // Fetch nearby places from Supabase via an RPC that uses PostGIS for fast radius queries. @@ -17,27 +17,39 @@ export async function fetchNearbyPlacesFromSupabase({ }: NearbyParams): Promise { try { const radius_m = Math.max(100, Math.floor(radiusKm * 1000)); - const { data, error } = await supabase.rpc('nearby_places', { - lat, - lon, - radius_m, - limit_count: limit, - }); + const PAGE = 1000; // Supabase/PostgREST typical row cap per request + const target = Math.max(1, limit); + let offset = 0; + const all: any[] = []; - if (error) { - console.error('Supabase nearby_places RPC error:', error); - return null; - } + 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; + } - if (!Array.isArray(data)) return []; + 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[] = data.map((row: any) => ({ + 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', + 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, @@ -52,7 +64,7 @@ export async function fetchNearbyPlacesFromSupabase({ return mapped; } catch (e) { - console.error('Supabase nearby fetch failed:', e); + console.error("Supabase nearby fetch failed:", e); return null; } } From 1e1ad0bd0dbc702608f696975204272bed010809 Mon Sep 17 00:00:00 2001 From: Jake Floch Date: Sat, 15 Nov 2025 02:48:38 -0500 Subject: [PATCH 5/5] Refactor place_id assignment for readability Improved formatting of the place_id assignment in fetchNearbyPlacesFromSupabase for better readability and maintainability. No functional changes were made. --- utils/supabase-places.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/utils/supabase-places.ts b/utils/supabase-places.ts index 7395c42..6eaf7e4 100644 --- a/utils/supabase-places.ts +++ b/utils/supabase-places.ts @@ -45,7 +45,9 @@ export async function fetchNearbyPlacesFromSupabase({ // 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}`, + 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",