diff --git a/transform/sqlmesh_padelnomics/models/foundation/dim_cities.sql b/transform/sqlmesh_padelnomics/models/foundation/dim_cities.sql index 01be95b..49ea369 100644 --- a/transform/sqlmesh_padelnomics/models/foundation/dim_cities.sql +++ b/transform/sqlmesh_padelnomics/models/foundation/dim_cities.sql @@ -75,7 +75,7 @@ uk_pop AS ( ), -- GeoNames global fallback (all cities ≥50K) geonames_pop AS ( - SELECT city_name, country_code, population, ref_year + SELECT geoname_id, city_name, country_code, population, ref_year FROM staging.stg_population_geonames QUALIFY ROW_NUMBER() OVER (PARTITION BY geoname_id ORDER BY ref_year DESC) = 1 ) @@ -153,7 +153,10 @@ SELECT )::INTEGER AS population_year, vc.padel_venue_count, ci.median_income_pps, - ci.income_year + ci.income_year, + -- GeoNames ID: FK to dim_locations / location_opportunity_profile. + -- NULL when city name doesn't match any GeoNames entry. + gn.geoname_id FROM venue_cities vc LEFT JOIN country_income ci ON vc.country_code = ci.country_code -- Eurostat EU population (via city code→name lookup) diff --git a/transform/sqlmesh_padelnomics/models/serving/city_market_profile.sql b/transform/sqlmesh_padelnomics/models/serving/city_market_profile.sql index 47ad2f7..3b14ec6 100644 --- a/transform/sqlmesh_padelnomics/models/serving/city_market_profile.sql +++ b/transform/sqlmesh_padelnomics/models/serving/city_market_profile.sql @@ -33,6 +33,7 @@ WITH base AS ( c.padel_venue_count, c.median_income_pps, c.income_year, + c.geoname_id, -- Venue density: padel venues per 100K residents CASE WHEN c.population > 0 THEN ROUND(c.padel_venue_count::DOUBLE / c.population * 100000, 2) @@ -107,6 +108,7 @@ SELECT s.median_occupancy_rate, s.median_daily_revenue_per_venue, s.price_currency, + s.geoname_id, CURRENT_DATE AS refreshed_date FROM scored s ORDER BY s.market_score DESC diff --git a/transform/sqlmesh_padelnomics/models/serving/pseo_city_costs_de.sql b/transform/sqlmesh_padelnomics/models/serving/pseo_city_costs_de.sql index 4176959..1544997 100644 --- a/transform/sqlmesh_padelnomics/models/serving/pseo_city_costs_de.sql +++ b/transform/sqlmesh_padelnomics/models/serving/pseo_city_costs_de.sql @@ -27,6 +27,7 @@ SELECT c.padel_venue_count, c.venues_per_100k, c.market_score, + lop.opportunity_score, c.data_confidence, -- Pricing (from Playtomic, NULL when no coverage) c.median_hourly_rate, @@ -48,6 +49,9 @@ FROM serving.city_market_profile c LEFT JOIN serving.planner_defaults p ON c.country_code = p.country_code AND c.city_slug = p.city_slug +LEFT JOIN serving.location_opportunity_profile lop + ON c.country_code = lop.country_code + AND c.geoname_id = lop.geoname_id -- Only cities with actual padel presence and at least some rate data WHERE c.padel_venue_count > 0 AND (p.rate_peak IS NOT NULL OR c.median_peak_rate IS NOT NULL) diff --git a/transform/sqlmesh_padelnomics/models/serving/pseo_country_overview.sql b/transform/sqlmesh_padelnomics/models/serving/pseo_country_overview.sql index 6ae7cdb..b895095 100644 --- a/transform/sqlmesh_padelnomics/models/serving/pseo_country_overview.sql +++ b/transform/sqlmesh_padelnomics/models/serving/pseo_country_overview.sql @@ -23,6 +23,12 @@ SELECT -- Top 5 cities by market score for internal linking (DuckDB list slice syntax) LIST(city_slug ORDER BY market_score DESC NULLS LAST)[1:5] AS top_city_slugs, LIST(city_name ORDER BY market_score DESC NULLS LAST)[1:5] AS top_city_names, + -- Opportunity score aggregates (NULL-safe: cities without geoname_id match excluded from AVG) + ROUND(AVG(opportunity_score), 1) AS avg_opportunity_score, + MAX(opportunity_score) AS top_opportunity_score, + -- Top 5 cities by opportunity score (may differ from top market score cities) + LIST(city_slug ORDER BY opportunity_score DESC NULLS LAST)[1:5] AS top_opportunity_slugs, + LIST(city_name ORDER BY opportunity_score DESC NULLS LAST)[1:5] AS top_opportunity_names, -- Pricing medians across cities (NULL when no Playtomic coverage in country) ROUND(MEDIAN(median_hourly_rate), 0) AS median_hourly_rate, ROUND(MEDIAN(median_peak_rate), 0) AS median_peak_rate,