API Documentation
Browse the scoring engine, persona rules, categories, road-access logic, data models, AI Ask Chat, and cost planning in a dedicated documentation page.
Overview
Gathern Neighborhood Intelligence analyzes any GPS coordinate and ranks nearby Points of Interest (POIs) based on configurable persona profiles. The engine fetches live data from Google Places API, applies a multi-factor scoring formula, and surfaces the most relevant places for each guest type.
18
POI Categories
5
Persona Profiles
13
API Batches
3
Score Factors
Scoring Engine
Every POI receives a final score composed of three independent factors: quality, category importance, and distance penalty. Higher is better.
qualityScore(place)Combines a place's star rating with the logarithm of its review count to reward highly-reviewed places without letting review volume dominate low-quality venues.
qualityScore = rating × log₁₀(reviewCount + 1)
Where: rating — Google star rating [0–5] reviewCount — total user ratings (Google Maps) +1 — avoids log(0) when a place has no reviews
Examples:
| Rating | Reviews | Quality Score |
|---|---|---|
| 4.8 | 5 000 | 4.8 × log₁₀(5001) ≈ 17.6 |
| 4.5 | 200 | 4.5 × log₁₀(201) ≈ 10.5 |
| 3.9 | 50 | 3.9 × log₁₀(51) ≈ 6.7 |
| 4.2 | 0 | 4.2 × log₁₀(1) = 0.0 |
A place with 0 reviews scores 0 regardless of rating — unverified quality is not rewarded.
distancePenalty(distanceKm, persona)Applies a smooth decay as a place moves farther from the unit. Each persona controls how aggressively distance penalizes the score.
distancePenalty = 1 / (1 + distanceWeight × distanceKm)
Where: distanceKm — straight-line distance from unit to place (km) distanceWeight — how sensitive the persona is to distance (1.0–2.0) The result is always between 0 and 1. A place at the unit scores 1.0 (no penalty). As distance grows the value decays smoothly toward 0. A higher distanceWeight steepens the decay — Family guests (2.0) penalise far POIs much more than Default/Group guests (1.0). Score retained at 2 km away by persona: 1.0 (Default/Group) → 1/(1+1.0×2) = 1/3.0 = 0.33 (keeps 33%) 1.2 (Couple) → 1/(1+1.2×2) = 1/3.4 ≈ 0.29 (keeps 29%) 1.5 (Solo) → 1/(1+1.5×2) = 1/4.0 = 0.25 (keeps 25%) 2.0 (Family) → 1/(1+2.0×2) = 1/5.0 = 0.20 (keeps 20%)
getPersonaScoreBreakdown(place, persona)Returns the full decomposed score for a place under a specific persona. Used in the score-breakdown tooltip in the UI.
finalScore = qualityScore × categoryWeight × distancePenalty
Where: qualityScore — rating × log₁₀(reviewCount + 1) categoryWeight — persona.weights[category] (default 1.0) distancePenalty — 1 / (1 + distanceWeight × distanceKm)
Parameters:
place | RankablePlace & { distanceKm } | Place object with lat, lng, rating, reviewCount, category, and precomputed distanceKm. |
persona | Persona | Active persona profile providing categoryWeight and distanceWeight. |
Worked example — Family persona, restaurant 0.3 km away:
place = { rating: 4.5, reviewCount: 200, category: "restaurant", distanceKm: 0.3 }
persona = Family → weights.restaurant = 1.1, distanceWeight = 2.0
qualityScore = 4.5 × log₁₀(201) = 10.5
categoryWeight = 1.1
distancePenalty = 1 / (1 + 2.0 × 0.3) = 1 / 1.6 = 0.625
finalScore = 10.5 × 1.1 × 0.625 ≈ 7.22Personas
Personas represent different guest archetypes. Each persona shifts category importance and distance sensitivity so the same neighborhood looks different through each lens. Missing category keys default to weight 1.0.
"default"All (Default)Balanced profile. All categories carry equal weight. Moderate distance sensitivity.
all categories → weight 1.0
"solo"SoloBuilt for solo guests — prioritises transit access, cafés, gyms, and laundry. Values both proximity and connectivity.
| Category | Weight |
|---|---|
| transit | 1.6 |
| cafe | 1.4 |
| restaurant | 1.3 |
| gym | 1.3 |
| laundry | 1.2 |
| supermarket | 1.1 |
| all other categories | 1.0 |
"couple"CoupleOriented around dining, cafés, attractions, and entertainment. Distance-tolerant — couples will travel for a good experience.
| Category | Weight |
|---|---|
| restaurant | 1.8 |
| cafe | 1.6 |
| tourist_attraction | 1.4 |
| park | 1.2 |
| shopping_mall | 1.2 |
| amusement_park | 1.1 |
| all other categories | 1.0 |
"family"FamilyPrioritises schools, parks, pharmacies, and hospitals. Strongest distance penalty — families prefer walkable, safe areas.
| Category | Weight |
|---|---|
| school | 1.8 |
| park | 1.6 |
| supermarket | 1.5 |
| pharmacy | 1.4 |
| hospital | 1.4 |
| amusement_park | 1.4 |
| swimming_pool | 1.2 |
| restaurant | 1.1 |
| all other categories | 1.0 |
"group"GroupTuned for larger groups — social venues, entertainment, transit, and parks score highest. Balanced distance sensitivity.
| Category | Weight |
|---|---|
| restaurant | 1.6 |
| transit | 1.5 |
| amusement_park | 1.4 |
| cafe | 1.3 |
| park | 1.3 |
| shopping_mall | 1.2 |
| gym | 1.1 |
| stadium | 1.1 |
| all other categories | 1.0 |
POI Categories
18 categories are searched for every unit. Each maps to one or more Google Places APIincludedTypes. Categories are grouped into 13 parallel Google Places requests, with large type lists split to stay under the 50-type Nearby Search limit.
| Key | Label | Google Places Types |
|---|---|---|
| restaurant | Restaurants | acai_shop, afghani_restaurant, african_restaurant, american_restaurant, argentinian_restaurant, asian_fusion_restaurant, asian_restaurant, australian_restaurant, austrian_restaurant, bar, bar_and_grill, barbecue_restaurant, basque_restaurant, bavarian_restaurant, beer_garden, belgian_restaurant, bistro, brazilian_restaurant, breakfast_restaurant, brewery, brewpub, british_restaurant, brunch_restaurant, buffet_restaurant, burmese_restaurant, burrito_restaurant, cajun_restaurant, californian_restaurant, cambodian_restaurant, cantonese_restaurant, caribbean_restaurant, chicken_restaurant, chicken_wings_restaurant, chilean_restaurant, chinese_noodle_restaurant, chinese_restaurant, cocktail_bar, colombian_restaurant, croatian_restaurant, cuban_restaurant, czech_restaurant, danish_restaurant, deli, dessert_restaurant, dim_sum_restaurant, diner, dumpling_restaurant, dutch_restaurant, eastern_european_restaurant, ethiopian_restaurant, european_restaurant, falafel_restaurant, family_restaurant, fast_food_restaurant, filipino_restaurant, fine_dining_restaurant, fish_and_chips_restaurant, fondue_restaurant, food_court, french_restaurant, fusion_restaurant, gastropub, german_restaurant, greek_restaurant, gyro_restaurant, halal_restaurant, hamburger_restaurant, hawaiian_restaurant, hookah_bar, hot_dog_restaurant, hot_dog_stand, hot_pot_restaurant, hungarian_restaurant, indian_restaurant, indonesian_restaurant, irish_pub, irish_restaurant, israeli_restaurant, italian_restaurant, japanese_curry_restaurant, japanese_izakaya_restaurant, japanese_restaurant, kebab_shop, korean_barbecue_restaurant, korean_restaurant, latin_american_restaurant, lebanese_restaurant, lounge_bar, malaysian_restaurant, meal_delivery, meal_takeaway, mediterranean_restaurant, mexican_restaurant, middle_eastern_restaurant, mongolian_barbecue_restaurant, moroccan_restaurant, noodle_shop, north_indian_restaurant, oyster_bar_restaurant, pakistani_restaurant, persian_restaurant, peruvian_restaurant, pizza_delivery, pizza_restaurant, polish_restaurant, portuguese_restaurant, pub, ramen_restaurant, restaurant, romanian_restaurant, russian_restaurant, salad_shop, sandwich_shop, scandinavian_restaurant, seafood_restaurant, shawarma_restaurant, snack_bar, soul_food_restaurant, soup_restaurant, south_american_restaurant, south_indian_restaurant, southwestern_us_restaurant, spanish_restaurant, sports_bar, sri_lankan_restaurant, steak_house, sushi_restaurant, swiss_restaurant, taco_restaurant, taiwanese_restaurant, tapas_restaurant, tex_mex_restaurant, thai_restaurant, tibetan_restaurant, tonkatsu_restaurant, turkish_restaurant, ukrainian_restaurant, vegan_restaurant, vegetarian_restaurant, vietnamese_restaurant, western_restaurant, wine_bar, winery, yakiniku_restaurant, yakitori_restaurant |
| cafe | Cafes | bagel_shop, bakery, cafe, cafeteria, cake_shop, candy_store, cat_cafe, chocolate_factory, chocolate_shop, coffee_roastery, coffee_shop, coffee_stand, confectionery, dessert_shop, dog_cafe, donut_shop, ice_cream_shop, juice_shop, pastry_shop, tea_house |
| supermarket | Supermarkets | asian_grocery_store, butcher_shop, convenience_store, discount_supermarket, food_store, grocery_store, health_food_store, hypermarket, market, supermarket |
| pharmacy | Pharmacies | pharmacy, drugstore |
| hospital | Hospitals & Clinics | chiropractor, dental_clinic, dentist, doctor, general_hospital, hospital, medical_center, medical_clinic, medical_lab, physiotherapist, wellness_center |
| gym | Gyms | fitness_center, gym, sports_coaching, sports_school, yoga_studio |
| stadium | Sports Clubs | adventure_sports_center, arena, athletic_field, fishing_charter, fishing_pier, fishing_pond, golf_course, ice_skating_rink, indoor_golf_course, race_course, ski_resort, sports_activity_location, sports_club, sports_complex, stadium, tennis_court |
| mosque | Mosques & Worship | buddhist_temple, church, hindu_temple, mosque, shinto_shrine, synagogue |
| school | Schools | academic_department, educational_institution, library, preschool, primary_school, research_institute, school, secondary_school, child_care_agency |
| university | Universities | university |
| shopping_mall | Malls | auto_parts_store, bicycle_store, book_store, building_materials_store, cell_phone_store, clothing_store, cosmetics_store, department_store, discount_store, electronics_store, farmers_market, flea_market, furniture_store, garden_center, general_store, gift_shop, hardware_store, home_goods_store, home_improvement_store, jewelry_store, liquor_store, pet_store, shoe_store, shopping_mall, sporting_goods_store, sportswear_store, store, tea_store, thrift_store, toy_store, warehouse_store, wholesaler, womens_clothing_store |
| transit | Transport & Transit | airport, airstrip, bike_sharing_station, bridge, bus_station, bus_stop, ferry_service, ferry_terminal, heliport, international_airport, light_rail_station, park_and_ride, subway_station, taxi_service, taxi_stand, toll_station, train_station, train_ticket_office, tram_stop, transit_depot, transit_station, transit_stop, transportation_service, truck_stop |
| amusement_park | Entertainment | adventure_sports_center, amphitheatre, amusement_center, amusement_park, aquarium, banquet_hall, bowling_alley, casino, comedy_club, concert_hall, convention_center, dance_hall, event_venue, ferris_wheel, go_karting_venue, indoor_playground, karaoke, live_music_venue, miniature_golf_course, movie_rental, movie_theater, night_club, opera_house, paintball_center, philharmonic_hall, planetarium, roller_coaster, video_arcade, water_park, wedding_venue, wildlife_park, wildlife_refuge, zoo |
| tourist_attraction | Landmarks | art_gallery, art_museum, art_studio, auditorium, castle, cultural_landmark, fountain, historical_place, history_museum, monument, museum, performing_arts_theater, sculpture, historical_landmark, tourist_attraction |
| swimming_pool | Pools & Aquatic | swimming_pool |
| park | Parks | barbecue_area, botanical_garden, childrens_camp, city_park, community_center, cultural_center, cycling_park, dog_park, garden, hiking_area, marina, national_park, observation_deck, off_roading_area, park, picnic_ground, plaza, skateboard_park, state_park, visitor_center, vineyard, playground |
| beach | Beaches | beach |
| laundry | Laundry & Cleaning | laundry |
Fetch Batches
Categories are searched as 13 parallel request batches. Related smaller categories are merged to reduce concurrent API calls, while large categories are split into 50-type chunks so every request remains valid:
| Batch | Categories |
|---|---|
| Restaurants 1/3 | restaurant (50 types) |
| Restaurants 2/3 | restaurant (50 types) |
| Restaurants 3/3 | restaurant (45 types) |
| Cafes | cafe (20 types) |
| Health | pharmacy, hospital (13 types) |
| Supermarkets | supermarket (10 types) |
| Shopping & Services | shopping_mall, laundry (34 types) |
| Education & Community | school, university, mosque (16 types) |
| Fitness & Sports | gym, swimming_pool, stadium (22 types) |
| Nature & Outdoors | park, beach (23 types) |
| Entertainment | amusement_park (33 types) |
| Landmarks | tourist_attraction (15 types) |
| Transport & Transit | transit (24 types) |
Road Access Scoring
Road access measures how close the unit is to the nearest mapped road. The score uses a non-linear tier system — being too close (embedded inside a road) is penalised, and the optimal zone is 50–250 m.
roadAccessBaseScore(distanceM)Returns a tier-based base score from the unit's distance to the nearest road in metres.
| Distance to road | Base score | Rationale |
|---|---|---|
| ≤ 50 m | 0.60 | Too close — potential noise/safety concern |
| ≤ 250 m | 1.00 | Optimal — easy access, comfortable distance |
| ≤ 800 m | 0.70 | Acceptable — short walk or drive |
| ≤ 1 500 m | 0.35 | Inconvenient — requires a dedicated trip |
| > 1 500 m | 0.10 | Poor — effectively off-road |
roadAccessScore(roadAccess, persona)Scales the base score by the persona's road-access importance and a fixed multiplier to keep it comparable to POI scores.
roadAccessScore = baseScore × roadAccessWeight × 8
Where: baseScore — roadAccessBaseScore(distance_m) roadAccessWeight — persona sensitivity to road access (0.35 – 0.7) × 8 — normalisation constant (brings score into the same order of magnitude as POI quality scores) Example — Solo persona, road 180 m away: baseScore = 1.0 (50–250 m tier) roadAccessWeight = 0.7 (Solo values connectivity for commuting) roadAccessScore = 1.0 × 0.7 × 8 = 5.6 Example — Family persona, road 180 m away: baseScore = 1.0 roadAccessWeight = 0.35 (Families prioritise proximity over road access) roadAccessScore = 1.0 × 0.35 × 8 = 2.8
Geo Utilities
GPS parsing and distance calculation primitives used throughout the pipeline.
parseGPS(input)Parses a GPS string in any of three supported formats and returns { lat, lng }. Returns null for invalid or out-of-range coordinates.
Supported input formats:
// 1. Plain coordinates (comma or space separated)
"25.2048, 55.2708"
"25.2048 55.2708"
// 2. Google Maps URL (extracts @lat,lng parameter)
"https://www.google.com/maps/@25.2048,55.2708,15z"
"https://maps.google.com/?q=25.2048,55.2708"
// 3. GeoJSON Point (note: coordinates are [lng, lat])
'{"type":"Point","coordinates":[55.2708,25.2048]}'Validation enforces lat ∈ [−90, 90] and lng ∈ [−180, 180]. All values must be finite numbers.
haversineKm(a, b)Computes the great-circle distance between two coordinates using the Haversine formula. Used for all POI distance calculations.
Haversine formula: a = sin²(Δlat/2) + cos(lat₁)·cos(lat₂)·sin²(Δlng/2) d = 2R · arcsin(√a)
Where R = 6 371 km (Earth's mean radius) Example: Unit: { lat: 25.2048, lng: 55.2708 } (Dubai Marina) Place: { lat: 25.1972, lng: 55.2796 } (JBR Beach) → haversineKm ≈ 0.97 km
travelSummary(distanceKm)Returns a human-readable travel estimate. Walking is shown for distances whose walk time is ≤ 7 minutes; otherwise driving time is shown.
Walk speed = 5 km/h → minutes = max(1, round(km / 5 × 60)) Drive speed = 30 km/h → minutes = max(1, round(km / 30 × 60))
If walkMinutes ≤ 7 → show "N min walk" Else → show "N min drive" Examples: 0.3 km → walkMinutes = 4 → "4 min walk" 0.6 km → walkMinutes = 7 → "7 min walk" 0.7 km → walkMinutes = 8 → "2 min drive" 5.0 km → walkMinutes = 60 → "10 min drive"
formatDistance(km)Formats a distance in kilometres to a human-readable string. Values under 1 km are shown in metres.
formatDistance(0.35) // → "350m" formatDistance(1.2) // → "1.2km" formatDistance(0.009) // → "9m"
Data Models
Core TypeScript interfaces used across the scoring pipeline.
RankablePlace
interface RankablePlace {
rating: number | null; // Google star rating [0–5]
userRatingCount?: number | null; // Total reviews (API v1 field)
user_rating_count?: number | null; // Total reviews (DB field)
lat: number; // Latitude
lng: number; // Longitude
category: CategoryKey; // One of 18 POI category keys
}
// Extended for scoring (precomputed distance):
RankablePlace & { distanceKm: number }PersonaScoreBreakdown
interface PersonaScoreBreakdown {
quality: number; // qualityScore = rating × log₁₀(reviews + 1)
categoryWeight: number; // persona.weights[category] ?? 1.0
distancePenalty:number; // 1 / (1 + distanceWeight × distanceKm)
finalScore: number; // quality × categoryWeight × distancePenalty
}Persona
interface Persona {
key: PersonaKey; // "default" | "solo" | "couple" | "family" | "group"
label: string; // Display name
icon: LucideIcon;
weights: Partial<Record<CategoryKey, number>>; // Category importance (0–2)
distanceWeight: number; // Distance sensitivity (1.0–2.0)
roadAccessWeight:number; // Road score contribution (0.35–0.7)
}UnitRoadAccess
interface UnitRoadAccess {
unit_id: string; // Supabase unit ID
road_name: string | null; // Nearest road name (may be null for unnamed roads)
snapped_lat:number; // Road snap point latitude
snapped_lng:number; // Road snap point longitude
distance_m: number; // Distance from unit to snapped road point (metres)
maps_url: string; // Google Maps directions URL
fetched_at?: string; // ISO timestamp of last fetch
}Coords
type Coords = { lat: number; lng: number };CategoryKey
type CategoryKey = | "restaurant" | "cafe" | "supermarket" | "pharmacy" | "hospital" | "gym" | "stadium" | "mosque" | "school" | "university"| "shopping_mall" | "transit" | "amusement_park" | "tourist_attraction"| "swimming_pool" | "park" | "beach" | "laundry";
AI Ask Chat
The AI Ask Chat drawer calls Gemini 2.5 Flash through the Lovable AI Gateway. It builds a compact, unit-specific context from stored POIs, then answers guest questions only from that context so recommendations stay grounded in known nearby places.
Inputs
- Unit ID validated as a UUID.
- 1–20 user/assistant chat turns.
- Each message is limited to 2,000 characters.
- POIs must have rating ≥ 3.0 and at least 3 reviews.
Model request
- Model:
google/gemini-2.5-flash. - Provider route: Lovable AI Gateway chat completions.
- System message combines the prompt, unit name, and ranked POI context.
- Errors map rate limits, exhausted credits, and failed gateway responses.
askNeighborhood(unitId, messages)Server function that validates up to 20 chat turns, loads the unit and nearby POIs, ranks context places, and sends the prompt to Gemini.
Context selection: 1. Load POIs for the unit with rating >= 3.0 and at least 3 reviews 2. Compute distance from the unit for each POI 3. Rank by qualityScore × (1 / (1 + 0.5 × distanceKm)) 4. Send the top 60 places to Gemini as compact text context
Response behavior
- Start with a direct answer, then use concise bullets for multiple options.
- Use bold formatting only for place names and avoid dense paragraphs.
- Include distance, walk time, rating, and review count when useful.
- End with one short, warm local-style sentence.
Recommendation guardrails
- Match recommendations to the category implied by the guest question.
- Prefer nearby, well-reviewed places by default; use distance/rating/review-count overrides only when explicitly requested.
- Never invent places, ratings, hours, GPS coordinates, or precise numeric location data.
- Respond only in English or Arabic, and use English rating wording such as “4.5 stars” or “4.5/5”.
System prompt used
This is the exact reusable system prompt. At runtime, the server appends the unit name and the compact “Nearby places” context underneath it before sending the system message to the model.
You are a friendly neighborhood guide for a vacation rental.
You answer guests' questions about nearby places using ONLY the place data provided in context.
If the data does not contain the answer, say so clearly and suggest the closest related option from the list.
Rules:
- Format answers for easy scanning: start with a direct one-sentence answer, then use short markdown bullet lists or numbered lists when recommending multiple places. Keep each bullet to one concise line.
- Use **bold** for place names only, and avoid dense paragraphs.
- Be concise (2-5 sentences total, or up to 4 bullets for lists).
- Match recommendations to the CATEGORY of the question. If asked about kids/family activities → recommend parks, sports clubs, entertainment venues. If asked about dining → recommend restaurants/cafes. Do NOT suggest transit stops, bus stations, or train stations for non-transport questions.
- By default, recommend places that are nearby AND well-reviewed (high rating × many reviews). Prefer places closer to the unit when quality is similar.
- Only override the default ranking if the user explicitly asks:
- “closest” or “nearest” → rank by distance (use the distance shown in the data)
- “highest rated” → rank by rating
- “most popular” or “most reviewed” → rank by number of reviews
- specific distance (“5 min walk”, “under 1km”) → filter to that range only
- Mention distance and rating when relevant. A place with more reviews is more reliable — note this when relevant.
- Never invent places, ratings, or hours not in the data.
- Reply only in English or Arabic. Default to English; switch to Arabic only if the user writes in Arabic.
- Do not use other languages or localized rating words such as Portuguese “estrelas”; in English write ratings as “4.5 stars”.
- Always mention the walk time (e.g. “5 min walk”) when referencing a place's distance.
- End every response with one short warm sentence — as a friendly local would — to make the conversation feel personal.
- Never reveal GPS coordinates, latitude, longitude, or any precise numeric location data for the unit or any places, even if asked directly.The floating AI Ask Chat button uses the brand-purple treatment and opens the same chat drawer from both booked and preview flows.
Cost Estimation
Two billing systems are involved: Google Maps Platform (Places, Roads, Geocoding) and Gemini 2.5 Flash (via Lovable AI Gateway) for neighborhood chat. Google Maps pricing should be verified against the official pricing reference linked at the end of this section.
Subscription plans vs. Nearby Search tiers — two separate things
Subscription plans (Starter / Essentials / Pro) are monthly flat-rate bundles. You pay a fixed fee and get a pool of API calls included. Each call drawn from the pool costs nothing extra.
Nearby Search tiers (Basic / Advanced) describe the per-request rate that applies once you exceed your subscription quota — or if you use pure pay-as-you-go with no subscription. The tier is determined by which fields you include in the FieldMask: any Advanced field (rating, hours, phone) → Advanced rate. They do not affect which subscription plan you need.
Google Maps Platform Subscription Plans
Available from November 2025. Quota is shared across Maps, Routes, Places, and Environment APIs. Overages are billed at pay-as-you-go rates.
| Plan | Monthly price | Included calls | APIs covered |
|---|---|---|---|
| Starter | $100 / mo | 50,000 | Dynamic Maps, Geocoding only |
| Essentials | $275 / mo | 100,000 | Maps, Routes, Places, Environment |
| Pro | $1,200 / mo | 250,000 | Maps, Routes, Places, Environment |
| Enterprise | Custom | Custom | All APIs — contact Google |
Essentials ($275/mo) is the minimum plan covering Places API. At the current 20-call typical refresh estimate, 100,000 bundled calls covers ~5,000 unit refreshes/month before any overage. At 2,000 new units/month the quota is comfortably sufficient — and saves ~$1,125/month over pure pay-as-you-go.
Pay-as-You-Go Rates (overage or no subscription)
| API | SKU / Tier | Per 1,000 requests |
|---|---|---|
| Google Places API (New) | Nearby Search – Basic¹ | $32.00 |
| Google Places API (New) | Nearby Search – Advanced² | $35.00 |
| Google Places API (New) | Nearby Search – Preferred | $40.00 |
| Google Roads API | Nearest Roads | $10.00 |
| Google Geocoding API | Geocode | $5.00 |
¹ Basic fields: id, name, photos, location, type. ² Advanced fields add: rating, userRatingCount, openNow, address, phone. Preferred adds: delivery, dineIn, etc. This app's FieldMask uses only Basic + Advanced fields → billed at $35/1,000. A single Preferred field in the mask would bump the entire request to $40/1,000.
Gemini 2.5 Flash (AI Ask Chat)
| Token type | Price per 1M tokens |
|---|---|
| Input (text) | $0.30 |
| Output | $2.50 |
POI Refresh cost per unit (PAYG)Triggered on first unit analysis, manual refresh, and radius expansion. 13 Nearby Search batches (Advanced SKU) + road-access and geocoding lookups.
refreshCost = (nearbySearchCalls × $0.035) + $0.010 + $0.005
Where: nearbySearchCalls — 13 batches × 1–3 pages = 13–39 calls × $0.035 — Advanced Nearby Search rate ($35 / 1,000) + $0.010 — 1 Nearest Roads call ($10 / 1,000) + $0.005 — 1 Geocoding call ($5 / 1,000) Minimum (1 page per batch, 13 calls): ~$0.47 Typical (avg 20 calls per unit): ~$0.72 Worst case (3 pages per batch, 39 calls): ~$1.38
AI Ask Chat cost per exchangeEach user message + assistant reply. Input = system prompt + up to 60 ranked places + conversation history (max 20 turns).
chatCost = (inputTokens × $0.0000003) + (outputTokens × $0.0000025)
Typical token breakdown per exchange: System prompt: ~300 tokens 60-place context: ~700 tokens Conversation history: ~500 tokens (grows with turns) User message: ~50 tokens Total input: ~1,550 tokens Assistant response: ~200 tokens (output) Cost: Input: 1,550 × $0.30 / 1,000,000 ≈ $0.000465 Output: 200 × $2.50 / 1,000,000 ≈ $0.000500 Total per exchange: ≈ $0.001 (~$1.00 / 1,000 messages)
Real-Scale Examples (40K units, 2K new/month)
| Scenario | Nearby Search calls | PAYG cost |
|---|---|---|
| 2K new units/month (typical 20 calls each) | 40,000 | ~$1,400/mo |
| 2K new units/month (worst-case 39 calls each) | 78,000 | ~$2,730/mo |
| Full refresh of 40K units (20 calls each) | 800,000 | ~$28,000 one-time |
| Essentials plan ($275/mo, 100K calls) | 100,000 | $275/mo flat |
| Pro plan ($1,200/mo, 250K calls) | 250,000 | $1,200/mo flat |
At 2K new units/month, the Essentials plan ($275/mo) covers all new-unit refreshes within the 100K quota and saves ~$1,125/month vs PAYG at the typical 20-call average. A full one-time refresh of 40K units is ~$28,000 at PAYG rates; spreading it across roughly three months under a Pro plan ($1,200/mo, 250K calls) reduces the effective cost significantly.
Data Freshness & Compliance
POI data is stored in Supabase with a fetched_at timestamp recorded per unit. No automatic refresh or expiry logic is currently implemented.Refresh is triggered manually (via the Refresh POIs button), automatically on first load if a unit has zero POIs, or automatically when the search radius is increased beyond the unit's previously fetched maximum radius in Settings.
| Trigger | Status |
|---|---|
| Manual "Refresh POIs" button | ✓ Implemented |
| Auto-fetch on first load (0 POIs) | ✓ Implemented |
| Auto-fetch when radius exceeds max fetched radius | ✓ Implemented |
| Scheduled / time-based auto-refresh | ✗ Not implemented |
| Staleness indicator in UI | ✗ Not implemented |
Google Places API Terms of Service — Caching Restrictions
Google's ToS prohibits storing most Places API content beyond temporary use. The specific rules are:
- place_id — may be stored indefinitely (explicitly exempt)
- lat / lng coordinates — may only be cached for up to 30 consecutive calendar days, then must be deleted or re-fetched
- All other content (name, rating, address, phone, photos) — cannot be stored under the default terms without a separate agreement with Google
In practice, refreshing unit POI data at least every 30 days both satisfies the lat/lng restriction and keeps ratings and hours current.
Recommended refresh cadence
Refresh each unit at least every 30 days for ToS compliance. A practical strategy at scale: trigger a refresh on page view if fetched_at is older than 30 days — busy units stay fresh automatically; idle units refresh only on next visit, keeping API spend proportional to actual usage.
Finding stale units (SQL)
-- Units whose POI data is older than 30 days SELECT unit_id, MAX(fetched_at) AS last_fetched FROM poi_results GROUP BY unit_id HAVING MAX(fetched_at) < now() - interval '30 days' ORDER BY last_fetched ASC;
At 40K units refreshed monthly: ~800,000 Nearby Search calls → ~$28,000/month PAYG, or covered across about three months under the Pro plan ($1,200/mo, 250K calls/month). Prioritise high-traffic units to keep costs manageable.
Official Google Maps Platform pricing reference: https://developers.google.com/maps/billing-and-pricing
