- Expand dim_countries.sql CASE to cover 22 missing countries (PL, RO,
CO, HU, ZA, KE, BR, CZ, QA, NZ, HR, LV, MT, CR, CY, PA, SV, DO,
PE, VE, EE, ID) that fell through to bare ISO codes
- Add 19 missing entries to COUNTRY_LABELS (i18n.py) + both locale files
(EN + DE dir_country_* keys) including IE which was in SQL but not i18n
- Localise map tooltips: routes.py injects country_name via
get_country_name(), JS uses c.country_name instead of c.country_name_en
- Localise dropdown: apply country_name filter to option labels
- Show avg + top score in map tooltip with separate color dots and new
map_score_avg / map_score_top i18n keys (EN: "Avg. Score" / "Top City",
DE: "Ø Score" / "Top-Stadt")
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Runs read-only SQL against analytics.duckdb (default) or lakehouse.duckdb
on the prod server over SSH. SQL is base64-encoded to avoid shell escaping.
Supports TSV (default) and JSON output. Blocks mutation keywords.
For lakehouse, works around the DuckDB catalog naming issue (SQLMesh views
reference "local" but the file creates catalog "lakehouse") by attaching
the file as the "local" catalog.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Push-notify search engines (Bing, Yandex, Seznam, Naver) when content
changes instead of waiting for sitemap crawls. Especially valuable for
batch article publishing and supplier directory updates.
- Add INDEXNOW_KEY config var and key verification route
- New seo/_indexnow.py: async fire-and-forget POST to IndexNow API
- notify_indexnow() wrapper in sitemap.py expands paths to all lang variants
- Integrated at all article publish/unpublish/edit and supplier create points
- Bulk operations batch all URLs into a single IndexNow request
- Skips silently when key is empty (dev environments)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The @start_ds in the glob pattern only matched files for the first day
of the batch, so incremental restates only loaded 1 day of data.
Changed to wildcard glob with explicit BETWEEN @start_ds AND @end_ds
filter on the date column.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The previous approach diffed HEAD~1 vs HEAD to detect web/ changes,
but this missed changes inside merge commits (HEAD~1 IS the merge,
so the diff only saw the follow-up CHANGELOG commit). Result: web
containers never got rebuilt after merge-based pushes.
Simpler and deterministic: always run deploy.sh on every new tag.
Blue/green swap is zero-downtime and Docker layer caching makes
no-op builds fast (~10s). Removes web_code_changed() entirely.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Addressable Market 20→15, Economic Power 15→10, Supply Deficit 40→50.
Update scaling description (LN/500K → SQRT/1M) and add existence
dampener explanation to supply deficit description.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two fixes:
1. dim_locations now sources venues from dim_venues (deduplicated OSM + Playtomic)
instead of stg_padel_courts (OSM only). Playtomic-only venues are no longer
invisible to spatial lookups.
2. Country-level supply saturation dampener on supply deficit component.
Saturated countries (Spain 7.4/100k) get dampened supply deficit (x0.30 → 12 pts max).
Emerging markets (Germany 0.24/100k) nearly unaffected (x0.98 → ~39 pts).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- stg_population_geonames: reject CJK/Cyrillic/Arabic city names via regex
(fixes "Seelow" showing Japanese characters on map)
- dim_locations: filter empty location names after trim
- location_profiles: defensive LEAST/GREATEST clamp on both scores (0-100)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add 12 map_* keys to EN and DE locale files
- Inject window.__MAP_T from templates that load map scripts
- article-maps.js already used __MAP_T with fallbacks (no change needed)
- markets.html and opportunity_map.html inline scripts now use T.*
- Admin preview templates get EN fallback __MAP_T
- Fix mkt_legend_color: "Market Score" → "Padelnomics Score"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Rename /market-score → /padelnomics-score with 301 redirect
- Rewrite methodology page as single Padelnomics Score (pnscore_* keys)
- Replace all mscore_* i18n keys with pnscore_* in both EN and DE
- Business plan: query opportunity_score from location_profiles
- Footer link updated to padelnomics-score route
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Phase B: Remove dual-ring marker design (ring/core), replace with
single-color markers colored by opportunity_score ("Padelnomics Score").
Simplify CSS, update tooltips to show one score line on all maps.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace flat circle markers with nested dual-ring structure encoding
two scores per marker (core = primary, ring = secondary). Upgrade
from 3-tier to 5-tier colorblind-safe scale. Add pulse animation for
high-opportunity markers (>=75). Extract shared PNMarkers module from
3 duplicated implementations.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Pre-select user's country on opportunity map dropdown (CF-IPCountry),
auto-load the map on page load. Highlight user's city on country
overview maps with a blue ring (CF-IPCity best-effort match). Unify
opportunity score color scale to red/amber/green (was using blue for
low scores). Inject window.__GEO global for client-side geo access.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
All three Cloudflare geo headers now available:
- g.user_country (CF-IPCountry) — used by geo-sorted article listing
- g.user_region (CF-RegionCode) — available for within-country sorting
- g.user_city (CF-IPCity) — available for city-level proximity
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Read Cloudflare CF-IPCountry header into g.user_country (before_request)
- _filter_articles() sorts user's country first, nearby countries second,
then rest — falls back to published_at DESC when header is absent
- map_countries sorted so user's country bubble renders on top (Leaflet z-order)
- Nearby-country mapping covers DACH, Iberia, Nordics, Benelux, UK/IE, Americas
Prerequisite: Nginx must forward CF-IPCountry header to Quart (same as Umami).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add legend below map: bubble size = venue count, color = Market Score
- Unify opportunity score color to use same green/orange/red scale
(was using blue for low scores, inconsistent with market score)
- Add mkt_legend_size / mkt_legend_color i18n keys (EN + DE)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Non-EU countries (AR, MX, AE, AU, etc.) previously got NULL for
median_income_pps and pli_construction, falling back to EU-calibrated
defaults (15K PPS, PLI=100) that produced wrong scores.
New World Bank WDI extractor fetches GNI per capita PPP and price level
ratio for 215 countries. dim_countries uses Germany as calibration anchor
to scale WB values into the Eurostat range (dynamic ratio, self-corrects
as both sources update). EU countries keep exact Eurostat values.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- CHANGELOG.md: document Market Score v4 and Opportunity Score v5 changes
- pipeline_routes.py: add dim_countries to location_profiles dependency list
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Merge supply gap (30pts) + catchment gap (15pts) → supply deficit (35pts, GREATEST)
Eliminates ~80% correlated double-count on a single signal.
- Add sports culture signal (10pts): tennis court density as racquet-sport adoption proxy.
Ceiling 50 courts/25km. Harmless when tennis data is zero (contributes 0).
- Add construction affordability (5pts): income relative to PLI construction costs.
Joins dim_countries.pli_construction. High income + low build cost = high score.
- Reduce economic power from 20 → 15pts to make room.
New weights: addressable market 25, economic power 15, supply deficit 35,
sports culture 10, construction affordability 5, market validation 10.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Delete opportunity() JSON endpoint from api.py (dead after this refactor)
- Add GET /opportunity-map/data route returning HTML partial with two JSON
data islands (opp_points + ref_points from serving.location_profiles)
- Create partials/opportunity_map_data.html (2-line data island partial)
- Rewrite opportunity_map.html: HTMX attrs on <select>, invisible #map-data
swap target, htmx:afterSwap listener replaces fetch()-based loadCountry()
city_venues endpoint stays public (article-maps.js calls it on public pages).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
opportunity_map.html (public page) still fetches these. Only countries.json
and city_venues.json are no longer called from any public page, so those two
keep @login_required.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
-m padelnomics.export_serving resolves to web package, not src/padelnomics.
src/padelnomics is not a uv workspace member so it's not importable by name.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two targeted fixes for inflated country scores (ES 83, SE 77):
1. pseo_country_overview: replace AVG() with population-weighted averages
for avg_opportunity_score and avg_market_score. Madrid/Barcelona now
dominate Spain's average instead of hundreds of 30K-town white-space
towns. Expected ES drop from ~83 to ~55-65.
2. location_profiles: replace dead sports culture component (10 pts,
tennis data all zeros) with market validation signal.
Split scored CTE into: market_scored → country_market → scored.
country_market aggregates AVG(market_score) per country from cities
with padel courts (market_score > 0), so zero-court locations don't
dilute the signal. ES (~60/100) → ~6 pts. SE (~35/100) → ~3.5 pts.
NULL → 0.5 neutral → 5 pts (untested market, not penalised).
Score budget unchanged: 25+20+30+15+10 = 100 pts.
No new models, no new data sources, no cycles.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>