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 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-03-07 20:33:31 +01:00
parent b2ffad055b
commit f215ea8e3a
5 changed files with 36 additions and 21 deletions

View File

@@ -208,7 +208,7 @@ scored AS (
-- Supply gap (30 pts): inverted catchment venue density -- Supply gap (30 pts): inverted catchment venue density
+ 30.0 * GREATEST(0.0, 1.0 - COALESCE( + 30.0 * GREATEST(0.0, 1.0 - COALESCE(
CASE WHEN catchment_population > 0 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 ELSE 0.0
END, 0.0) / 8.0) END, 0.0) / 8.0)
-- Catchment gap (15 pts): distance to nearest court -- Catchment gap (15 pts): distance to nearest court

View File

@@ -37,6 +37,8 @@ SELECT
-- Use the most common currency in the country (MIN is deterministic for single-currency countries) -- Use the most common currency in the country (MIN is deterministic for single-currency countries)
MIN(price_currency) AS price_currency, MIN(price_currency) AS price_currency,
SUM(population) AS total_population, 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 CURRENT_DATE AS refreshed_date
FROM serving.pseo_city_costs_de FROM serving.pseo_city_costs_de
GROUP BY country_code, country_name_en, country_slug GROUP BY country_code, country_name_en, country_slug

View File

@@ -8,6 +8,7 @@ daily when the pipeline runs).
from quart import Blueprint, abort, jsonify from quart import Blueprint, abort, jsonify
from .analytics import fetch_analytics from .analytics import fetch_analytics
from .auth.routes import login_required
from .core import fetch_all, is_flag_enabled from .core import fetch_all, is_flag_enabled
bp = Blueprint("api", __name__) bp = Blueprint("api", __name__)
@@ -26,6 +27,7 @@ async def _require_maps_flag() -> None:
@bp.route("/markets/countries.json") @bp.route("/markets/countries.json")
@login_required
async def countries(): async def countries():
"""Country-level aggregates for the markets hub map.""" """Country-level aggregates for the markets hub map."""
await _require_maps_flag() await _require_maps_flag()
@@ -46,6 +48,7 @@ async def countries():
@bp.route("/markets/<country_slug>/cities.json") @bp.route("/markets/<country_slug>/cities.json")
@login_required
async def country_cities(country_slug: str): async def country_cities(country_slug: str):
"""City-level data for a country overview bubble map.""" """City-level data for a country overview bubble map."""
await _require_maps_flag() await _require_maps_flag()
@@ -80,6 +83,7 @@ async def country_cities(country_slug: str):
@bp.route("/markets/<country_slug>/<city_slug>/venues.json") @bp.route("/markets/<country_slug>/<city_slug>/venues.json")
@login_required
async def city_venues(country_slug: str, city_slug: str): async def city_venues(country_slug: str, city_slug: str):
"""Venue-level dots for the city detail map.""" """Venue-level dots for the city detail map."""
await _require_maps_flag() await _require_maps_flag()
@@ -98,6 +102,7 @@ async def city_venues(country_slug: str, city_slug: str):
@bp.route("/opportunity/<country_slug>.json") @bp.route("/opportunity/<country_slug>.json")
@login_required
async def opportunity(country_slug: str): async def opportunity(country_slug: str):
"""Location-level opportunity scores for the opportunity map.""" """Location-level opportunity scores for the opportunity map."""
await _require_maps_flag() await _require_maps_flag()

View File

@@ -9,6 +9,7 @@ from jinja2 import Environment, FileSystemLoader
from markupsafe import Markup from markupsafe import Markup
from quart import Blueprint, abort, g, redirect, render_template, request from quart import Blueprint, abort, g, redirect, render_template, request
from ..analytics import fetch_analytics
from ..core import ( from ..core import (
REPO_ROOT, REPO_ROOT,
capture_waitlist_email, capture_waitlist_email,
@@ -203,6 +204,14 @@ async def markets():
) )
articles = await _filter_articles(q, country, region) 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( return await render_template(
"markets.html", "markets.html",
@@ -212,6 +221,7 @@ async def markets():
current_q=q, current_q=q,
current_country=country, current_country=country,
current_region=region, current_region=region,
map_countries=map_countries,
) )

View File

@@ -92,27 +92,25 @@
}); });
} }
fetch('/api/markets/countries.json') var data = {{ map_countries | tojson }};
.then(function(r) { return r.json(); }) if (data.length) {
.then(function(data) { var maxV = Math.max.apply(null, data.map(function(d) { return d.total_venues; }));
if (!data.length) return; var lang = document.documentElement.lang || 'en';
var maxV = Math.max.apply(null, data.map(function(d) { return d.total_venues; })); data.forEach(function(c) {
var lang = document.documentElement.lang || 'en'; if (!c.lat || !c.lon) return;
data.forEach(function(c) { var size = 12 + 44 * Math.sqrt(c.total_venues / maxV);
if (!c.lat || !c.lon) return; var color = scoreColor(c.avg_market_score);
var size = 12 + 44 * Math.sqrt(c.total_venues / maxV); var oppColor = c.avg_opportunity_score >= 60 ? '#16A34A' : (c.avg_opportunity_score >= 30 ? '#D97706' : '#3B82F6');
var color = scoreColor(c.avg_market_score); var tip = '<strong>' + c.country_name_en + '</strong><br>'
var oppColor = c.avg_opportunity_score >= 60 ? '#16A34A' : (c.avg_opportunity_score >= 30 ? '#D97706' : '#3B82F6'); + c.total_venues + ' venues · ' + c.city_count + ' cities<br>'
var tip = '<strong>' + c.country_name_en + '</strong><br>' + '<span style="color:' + color + ';font-weight:600;">Padelnomics Market Score: ' + c.avg_market_score + '/100</span><br>'
+ c.total_venues + ' venues · ' + c.city_count + ' cities<br>' + '<span style="color:' + oppColor + ';font-weight:600;">Padelnomics Opportunity Score: ' + (c.avg_opportunity_score || 0) + '/100</span>';
+ '<span style="color:' + color + ';font-weight:600;">Padelnomics Market Score: ' + c.avg_market_score + '/100</span><br>' L.marker([c.lat, c.lon], { icon: makeIcon(size, color) })
+ '<span style="color:' + oppColor + ';font-weight:600;">Padelnomics Opportunity Score: ' + (c.avg_opportunity_score || 0) + '/100</span>'; .bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] })
L.marker([c.lat, c.lon], { icon: makeIcon(size, color) }) .on('click', function() { window.location = '/' + lang + '/markets/' + c.country_slug; })
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] }) .addTo(map);
.on('click', function() { window.location = '/' + lang + '/markets/' + c.country_slug; })
.addTo(map);
});
}); });
}
})(); })();
</script> </script>
{% endblock %} {% endblock %}