Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| // Shared ZIP β real place resolution, used by the home money banner AND the | |
| // MoneyGameModal's "where's home?" gate. Every place is resolved from a real | |
| // /api/geocode lookup β we NEVER fabricate a location (CLAUDE.md: No Fabricated | |
| // Data). A ZIP can span cities and inside-vs-outside city limits, and city tax | |
| // rates STACK on the county's, so a single ZIP can yield several real choices. | |
| import api from '../lib/api' | |
| import type { LocationData } from '../contexts/LocationContext' | |
| import { nominatimUsStateCode } from './stateMapping' | |
| // Build a real LocationData from a Nominatim geocode result (same parsing as | |
| // AddressLookup.processResult). Returns null when no US state resolves. | |
| export function locationFromGeocode(result: any): LocationData | null { | |
| if (!result) return null | |
| const addr = result.address || {} | |
| const stateCode = nominatimUsStateCode(addr) || '' | |
| if (!stateCode) return null | |
| const county = (addr.county as string) || '' | |
| const city = | |
| (addr.city as string) || | |
| (addr.town as string) || | |
| (addr.village as string) || | |
| (addr.municipality as string) || | |
| (addr.hamlet as string) || | |
| (addr.suburb as string) || | |
| '' | |
| if (!city && !county) return null | |
| return { | |
| address: result.display_name, | |
| state: stateCode, | |
| county, | |
| city, | |
| granularity: !city ? 'county' : undefined, | |
| latitude: parseFloat(result.lat), | |
| longitude: parseFloat(result.lon), | |
| } | |
| } | |
| // Distinct real choices for a ZIP: one chip per city ("inside {city}") plus an | |
| // "outside city limits" (county-only) option per distinct county. Real geography | |
| // only β no invented ZIP table. | |
| export function buildZipChoices(results: any[]): { label: string; loc: LocationData }[] { | |
| const locs = (Array.isArray(results) ? results : [results]) | |
| .map(locationFromGeocode) | |
| .filter(Boolean) as LocationData[] | |
| const cities = new Map<string, LocationData>() | |
| const counties = new Map<string, LocationData>() | |
| for (const l of locs) { | |
| if (l.city) { | |
| const k = `${l.city}|${l.county}` | |
| if (!cities.has(k)) cities.set(k, l) | |
| } | |
| if (l.county) { | |
| if (!counties.has(l.county)) counties.set(l.county, { ...l, city: '', granularity: 'county' }) | |
| } | |
| } | |
| const multiCounty = counties.size > 1 | |
| const choices: { label: string; loc: LocationData }[] = [] | |
| for (const l of cities.values()) choices.push({ label: `π ${l.city}`, loc: l }) | |
| for (const l of counties.values()) { | |
| choices.push({ | |
| label: multiCounty ? `πΎ Outside city limits (${l.county})` : 'πΎ Outside city limits', | |
| loc: l, | |
| }) | |
| } | |
| return choices | |
| } | |
| // Resolve a 5-digit ZIP to its distinct real place choices. Nominatim's forward | |
| // ZIP lookup often omits the city, but reverse-geocoding the centroid recovers | |
| // it β so we merge both, then dedupe in buildZipChoices. | |
| export async function resolveZipToChoices(zip: string): Promise<{ label: string; loc: LocationData }[]> { | |
| const fwd = await api.get(`/geocode/search`, { params: { q: zip, limit: 10 } }) | |
| const fwdResults = Array.isArray(fwd.data) ? fwd.data : [fwd.data] | |
| let revResults: any[] = [] | |
| const first = fwdResults.find((r) => r?.lat && r?.lon) | |
| if (first) { | |
| try { | |
| const rev = await api.get(`/geocode/reverse`, { params: { lat: first.lat, lon: first.lon } }) | |
| revResults = Array.isArray(rev.data) ? rev.data : [rev.data] | |
| } catch { | |
| /* reverse is best-effort */ | |
| } | |
| } | |
| return buildZipChoices([...revResults, ...fwdResults]) | |
| } | |
| // Resolve a free-text query (a city/county name, or a full street address) to | |
| // its distinct real place choices via the same /geocode/search proxy. Used by | |
| // the modal's "search by city or county" / "search by full address" modes. Real | |
| // geocoder results only β never a fabricated place. | |
| export async function resolveQueryToChoices( | |
| query: string, | |
| ): Promise<{ label: string; loc: LocationData }[]> { | |
| const q = query.trim() | |
| if (!q) return [] | |
| const res = await api.get(`/geocode/search`, { params: { q, limit: 5 } }) | |
| const results = Array.isArray(res.data) ? res.data : [res.data] | |
| const choices: { label: string; loc: LocationData }[] = [] | |
| const seen = new Set<string>() | |
| for (const r of results) { | |
| const loc = locationFromGeocode(r) | |
| if (!loc) continue | |
| // One chip per distinct place β what matters for the tax bill is the | |
| // city/county/state, not the exact street address. | |
| const key = `${loc.city}|${loc.county}|${loc.state}` | |
| if (seen.has(key)) continue | |
| seen.add(key) | |
| const place = loc.city || (loc.county ? `${loc.county} County` : '') | |
| choices.push({ label: `π ${[place, loc.state].filter(Boolean).join(', ')}`, loc }) | |
| } | |
| return choices | |
| } | |
| // Reverse-geocode a lat/lon (e.g. the browser's geolocation) to a real place. | |
| // Returns null when no US state resolves β we never fabricate a location. | |
| export async function resolveCoordsToLocation( | |
| lat: number, | |
| lon: number, | |
| ): Promise<LocationData | null> { | |
| const res = await api.get(`/geocode/reverse`, { params: { lat, lon } }) | |
| const first = Array.isArray(res.data) ? res.data[0] : res.data | |
| return locationFromGeocode(first) | |
| } | |