From f215ea8e3a0b314c49bbb4860d7b7b92828b7386 Mon Sep 17 00:00:00 2001 From: Deeman Date: Sat, 7 Mar 2026 20:33:31 +0100 Subject: [PATCH] fix: supply gap inflation + inline map data + guard API endpoints A. location_profiles.sql: supply gap now uses GREATEST(catchment_padel_courts, COALESCE(city_padel_venue_count, 0)) so Playtomic venues prevent cities like Murcia/Cordoba/Gijon from receiving a full 30-pt supply gap bonus when their OSM catchment count is zero. Expected ~10-15 pt drop for affected ES cities. B. pseo_country_overview.sql: add population-weighted lat/lon centroid columns so the markets map can use accurate country positions from this table. C/D. content/routes.py + markets.html: query pseo_country_overview in the route and pass as map_countries to the template, replacing the fetch('/api/...') call with inline JSON. Map scores now match pseo_country_overview (pop-weighted), and the page loads without an extra round-trip. E. api.py: add @login_required to all 4 endpoints. Unauthenticated callers get a 302 redirect to login instead of data. Co-Authored-By: Claude Sonnet 4.6 --- .../models/serving/location_profiles.sql | 2 +- .../models/serving/pseo_country_overview.sql | 2 + web/src/padelnomics/api.py | 5 +++ web/src/padelnomics/content/routes.py | 10 +++++ .../content/templates/markets.html | 38 +++++++++---------- 5 files changed, 36 insertions(+), 21 deletions(-) diff --git a/transform/sqlmesh_padelnomics/models/serving/location_profiles.sql b/transform/sqlmesh_padelnomics/models/serving/location_profiles.sql index c35f767..68f2197 100644 --- a/transform/sqlmesh_padelnomics/models/serving/location_profiles.sql +++ b/transform/sqlmesh_padelnomics/models/serving/location_profiles.sql @@ -208,7 +208,7 @@ scored AS ( -- Supply gap (30 pts): inverted catchment venue density + 30.0 * GREATEST(0.0, 1.0 - COALESCE( CASE WHEN catchment_population > 0 - THEN catchment_padel_courts::DOUBLE / catchment_population * 100000 + THEN GREATEST(catchment_padel_courts, COALESCE(city_padel_venue_count, 0))::DOUBLE / catchment_population * 100000 ELSE 0.0 END, 0.0) / 8.0) -- Catchment gap (15 pts): distance to nearest court diff --git a/transform/sqlmesh_padelnomics/models/serving/pseo_country_overview.sql b/transform/sqlmesh_padelnomics/models/serving/pseo_country_overview.sql index a876295..a9afbc9 100644 --- a/transform/sqlmesh_padelnomics/models/serving/pseo_country_overview.sql +++ b/transform/sqlmesh_padelnomics/models/serving/pseo_country_overview.sql @@ -37,6 +37,8 @@ SELECT -- Use the most common currency in the country (MIN is deterministic for single-currency countries) MIN(price_currency) AS price_currency, SUM(population) AS total_population, + ROUND(SUM(lat * population) / NULLIF(SUM(population), 0), 4) AS lat, + ROUND(SUM(lon * population) / NULLIF(SUM(population), 0), 4) AS lon, CURRENT_DATE AS refreshed_date FROM serving.pseo_city_costs_de GROUP BY country_code, country_name_en, country_slug diff --git a/web/src/padelnomics/api.py b/web/src/padelnomics/api.py index 5ba4fb9..7de16d4 100644 --- a/web/src/padelnomics/api.py +++ b/web/src/padelnomics/api.py @@ -8,6 +8,7 @@ daily when the pipeline runs). from quart import Blueprint, abort, jsonify from .analytics import fetch_analytics +from .auth.routes import login_required from .core import fetch_all, is_flag_enabled bp = Blueprint("api", __name__) @@ -26,6 +27,7 @@ async def _require_maps_flag() -> None: @bp.route("/markets/countries.json") +@login_required async def countries(): """Country-level aggregates for the markets hub map.""" await _require_maps_flag() @@ -46,6 +48,7 @@ async def countries(): @bp.route("/markets//cities.json") +@login_required async def country_cities(country_slug: str): """City-level data for a country overview bubble map.""" await _require_maps_flag() @@ -80,6 +83,7 @@ async def country_cities(country_slug: str): @bp.route("/markets///venues.json") +@login_required async def city_venues(country_slug: str, city_slug: str): """Venue-level dots for the city detail map.""" await _require_maps_flag() @@ -98,6 +102,7 @@ async def city_venues(country_slug: str, city_slug: str): @bp.route("/opportunity/.json") +@login_required async def opportunity(country_slug: str): """Location-level opportunity scores for the opportunity map.""" await _require_maps_flag() diff --git a/web/src/padelnomics/content/routes.py b/web/src/padelnomics/content/routes.py index e82460e..a01892a 100644 --- a/web/src/padelnomics/content/routes.py +++ b/web/src/padelnomics/content/routes.py @@ -9,6 +9,7 @@ from jinja2 import Environment, FileSystemLoader from markupsafe import Markup from quart import Blueprint, abort, g, redirect, render_template, request +from ..analytics import fetch_analytics from ..core import ( REPO_ROOT, capture_waitlist_email, @@ -203,6 +204,14 @@ async def markets(): ) articles = await _filter_articles(q, country, region) + map_countries = await fetch_analytics(""" + SELECT country_code, country_name_en, country_slug, + city_count, total_venues, + avg_market_score, avg_opportunity_score, + lat, lon + FROM serving.pseo_country_overview + ORDER BY total_venues DESC + """) return await render_template( "markets.html", @@ -212,6 +221,7 @@ async def markets(): current_q=q, current_country=country, current_region=region, + map_countries=map_countries, ) diff --git a/web/src/padelnomics/content/templates/markets.html b/web/src/padelnomics/content/templates/markets.html index e0901a2..b273741 100644 --- a/web/src/padelnomics/content/templates/markets.html +++ b/web/src/padelnomics/content/templates/markets.html @@ -92,27 +92,25 @@ }); } - fetch('/api/markets/countries.json') - .then(function(r) { return r.json(); }) - .then(function(data) { - if (!data.length) return; - var maxV = Math.max.apply(null, data.map(function(d) { return d.total_venues; })); - var lang = document.documentElement.lang || 'en'; - data.forEach(function(c) { - if (!c.lat || !c.lon) return; - var size = 12 + 44 * Math.sqrt(c.total_venues / maxV); - var color = scoreColor(c.avg_market_score); - var oppColor = c.avg_opportunity_score >= 60 ? '#16A34A' : (c.avg_opportunity_score >= 30 ? '#D97706' : '#3B82F6'); - var tip = '' + c.country_name_en + '
' - + c.total_venues + ' venues · ' + c.city_count + ' cities
' - + 'Padelnomics Market Score: ' + c.avg_market_score + '/100
' - + 'Padelnomics Opportunity Score: ' + (c.avg_opportunity_score || 0) + '/100'; - L.marker([c.lat, c.lon], { icon: makeIcon(size, color) }) - .bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] }) - .on('click', function() { window.location = '/' + lang + '/markets/' + c.country_slug; }) - .addTo(map); - }); + var data = {{ map_countries | tojson }}; + if (data.length) { + var maxV = Math.max.apply(null, data.map(function(d) { return d.total_venues; })); + var lang = document.documentElement.lang || 'en'; + data.forEach(function(c) { + if (!c.lat || !c.lon) return; + var size = 12 + 44 * Math.sqrt(c.total_venues / maxV); + var color = scoreColor(c.avg_market_score); + var oppColor = c.avg_opportunity_score >= 60 ? '#16A34A' : (c.avg_opportunity_score >= 30 ? '#D97706' : '#3B82F6'); + var tip = '' + c.country_name_en + '
' + + c.total_venues + ' venues · ' + c.city_count + ' cities
' + + 'Padelnomics Market Score: ' + c.avg_market_score + '/100
' + + 'Padelnomics Opportunity Score: ' + (c.avg_opportunity_score || 0) + '/100'; + L.marker([c.lat, c.lon], { icon: makeIcon(size, color) }) + .bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] }) + .on('click', function() { window.location = '/' + lang + '/markets/' + c.country_slug; }) + .addTo(map); }); + } })(); {% endblock %}