Gathern
Documentation
v1.0

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)
number

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:

RatingReviewsQuality Score
4.85 0004.8 × log₁₀(5001) ≈ 17.6
4.52004.5 × log₁₀(201) ≈ 10.5
3.9503.9 × log₁₀(51) ≈ 6.7
4.204.2 × log₁₀(1) = 0.0

A place with 0 reviews scores 0 regardless of rating — unverified quality is not rewarded.

distancePenalty(distanceKm, persona)
number [0–1]

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)
PersonaScoreBreakdown

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.22

Personas

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)
distanceWeight: 1
roadAccessWeight: 0.6

Balanced profile. All categories carry equal weight. Moderate distance sensitivity.

all categories → weight 1.0

"solo"Solo
distanceWeight: 1.5
roadAccessWeight: 0.7

Built for solo guests — prioritises transit access, cafés, gyms, and laundry. Values both proximity and connectivity.

CategoryWeight
transit1.6
cafe1.4
restaurant1.3
gym1.3
laundry1.2
supermarket1.1
all other categories1.0
"couple"Couple
distanceWeight: 1.2
roadAccessWeight: 0.45

Oriented around dining, cafés, attractions, and entertainment. Distance-tolerant — couples will travel for a good experience.

CategoryWeight
restaurant1.8
cafe1.6
tourist_attraction1.4
park1.2
shopping_mall1.2
amusement_park1.1
all other categories1.0
"family"Family
distanceWeight: 2
roadAccessWeight: 0.35

Prioritises schools, parks, pharmacies, and hospitals. Strongest distance penalty — families prefer walkable, safe areas.

CategoryWeight
school1.8
park1.6
supermarket1.5
pharmacy1.4
hospital1.4
amusement_park1.4
swimming_pool1.2
restaurant1.1
all other categories1.0
"group"Group
distanceWeight: 1
roadAccessWeight: 0.7

Tuned for larger groups — social venues, entertainment, transit, and parks score highest. Balanced distance sensitivity.

CategoryWeight
restaurant1.6
transit1.5
amusement_park1.4
cafe1.3
park1.3
shopping_mall1.2
gym1.1
stadium1.1
all other categories1.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.

KeyLabelGoogle Places Types
restaurantRestaurantsacai_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
cafeCafesbagel_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
supermarketSupermarketsasian_grocery_store, butcher_shop, convenience_store, discount_supermarket, food_store, grocery_store, health_food_store, hypermarket, market, supermarket
pharmacyPharmaciespharmacy, drugstore
hospitalHospitals & Clinicschiropractor, dental_clinic, dentist, doctor, general_hospital, hospital, medical_center, medical_clinic, medical_lab, physiotherapist, wellness_center
gymGymsfitness_center, gym, sports_coaching, sports_school, yoga_studio
stadiumSports Clubsadventure_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
mosqueMosques & Worshipbuddhist_temple, church, hindu_temple, mosque, shinto_shrine, synagogue
schoolSchoolsacademic_department, educational_institution, library, preschool, primary_school, research_institute, school, secondary_school, child_care_agency
universityUniversitiesuniversity
shopping_mallMallsauto_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
transitTransport & Transitairport, 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_parkEntertainmentadventure_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_attractionLandmarksart_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_poolPools & Aquaticswimming_pool
parkParksbarbecue_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
beachBeachesbeach
laundryLaundry & Cleaninglaundry

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:

BatchCategories
Restaurants 1/3restaurant (50 types)
Restaurants 2/3restaurant (50 types)
Restaurants 3/3restaurant (45 types)
Cafescafe (20 types)
Healthpharmacy, hospital (13 types)
Supermarketssupermarket (10 types)
Shopping & Servicesshopping_mall, laundry (34 types)
Education & Communityschool, university, mosque (16 types)
Fitness & Sportsgym, swimming_pool, stadium (22 types)
Nature & Outdoorspark, beach (23 types)
Entertainmentamusement_park (33 types)
Landmarkstourist_attraction (15 types)
Transport & Transittransit (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)
number

Returns a tier-based base score from the unit's distance to the nearest road in metres.

Distance to roadBase scoreRationale
≤ 50 m0.60Too close — potential noise/safety concern
≤ 250 m1.00Optimal — easy access, comfortable distance
≤ 800 m0.70Acceptable — short walk or drive
≤ 1 500 m0.35Inconvenient — requires a dedicated trip
> 1 500 m0.10Poor — effectively off-road
roadAccessScore(roadAccess, persona)
number

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)
Promise<Coords | null>

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)
number (km)

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)
string

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)
string

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)
{ reply }

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.

PlanMonthly priceIncluded callsAPIs covered
Starter$100 / mo50,000Dynamic Maps, Geocoding only
Essentials$275 / mo100,000Maps, Routes, Places, Environment
Pro$1,200 / mo250,000Maps, Routes, Places, Environment
EnterpriseCustomCustomAll 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)

APISKU / TierPer 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 APINearest Roads$10.00
Google Geocoding APIGeocode$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 typePrice per 1M tokens
Input (text)$0.30
Output$2.50
POI Refresh cost per unit (PAYG)
$0.47 – $1.38

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 exchange
~$0.001

Each 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)

ScenarioNearby Search callsPAYG 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.

TriggerStatus
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