From d379dc75510a10759e219f96657da8a7f55f0205 Mon Sep 17 00:00:00 2001 From: Deeman Date: Sun, 8 Mar 2026 20:25:43 +0100 Subject: [PATCH] feat(markets): geo-localize article + map sorting via CF-IPCountry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- web/src/padelnomics/app.py | 5 +++ web/src/padelnomics/content/routes.py | 56 ++++++++++++++++++++++++--- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/web/src/padelnomics/app.py b/web/src/padelnomics/app.py index 1149d71..708e32f 100644 --- a/web/src/padelnomics/app.py +++ b/web/src/padelnomics/app.py @@ -148,6 +148,11 @@ def create_app() -> Quart: # Per-request hooks # ------------------------------------------------------------------------- + @app.before_request + async def set_user_country(): + """Stash Cloudflare CF-IPCountry header (ISO alpha-2) in g for geo sorting.""" + g.user_country = request.headers.get("CF-IPCountry", "").upper() or "" + @app.before_request async def validate_lang(): """404 unsupported language prefixes (e.g. /fr/terms).""" diff --git a/web/src/padelnomics/content/routes.py b/web/src/padelnomics/content/routes.py index a01892a..80ef604 100644 --- a/web/src/padelnomics/content/routes.py +++ b/web/src/padelnomics/content/routes.py @@ -212,6 +212,13 @@ async def markets(): FROM serving.pseo_country_overview ORDER BY total_venues DESC """) + # Sort so user's country renders last (on top in Leaflet z-order) + user_country = g.get("user_country", "") + if user_country and map_countries: + map_countries = sorted( + map_countries, + key=lambda c: 1 if c["country_code"] == user_country else 0, + ) return await render_template( "markets.html", @@ -237,9 +244,46 @@ async def market_results(): return await render_template("partials/market_results.html", articles=articles) +_NEARBY_COUNTRIES: dict[str, tuple[str, ...]] = { + "DE": ("AT", "CH"), "AT": ("DE", "CH"), "CH": ("DE", "AT"), + "ES": ("PT",), "PT": ("ES",), + "GB": ("IE",), "IE": ("GB",), + "US": ("CA",), "CA": ("US",), + "IT": ("CH",), "FR": ("BE", "CH"), "BE": ("FR", "NL", "DE"), + "NL": ("BE", "DE"), "SE": ("NO", "DK", "FI"), "NO": ("SE", "DK"), + "DK": ("SE", "NO", "DE"), "FI": ("SE",), + "MX": ("US",), "BR": ("AR",), "AR": ("BR",), +} + + +def _geo_order_clause(user_country: str) -> tuple[str, list]: + """Build ORDER BY clause that sorts user's country first, nearby second. + + Returns (order_sql, params) where order_sql starts with the geo CASE + followed by published_at DESC. Caller prepends 'ORDER BY'. + """ + if not user_country: + return "published_at DESC", [] + + nearby = _NEARBY_COUNTRIES.get(user_country, ()) + if nearby: + placeholders = ",".join("?" * len(nearby)) + geo_case = f"""CASE WHEN country = ? THEN 0 + WHEN country IN ({placeholders}) THEN 1 + ELSE 2 END, + published_at DESC""" + return geo_case, [user_country, *nearby] + + return """CASE WHEN country = ? THEN 0 ELSE 1 END, + published_at DESC""", [user_country] + + async def _filter_articles(q: str, country: str, region: str) -> list[dict]: - """Query published articles for the current language.""" + """Query published articles for the current language, geo-sorted.""" lang = g.get("lang", "en") + user_country = g.get("user_country", "") + geo_order, geo_params = _geo_order_clause(user_country) + if q: # FTS query wheres = ["articles_fts MATCH ?"] @@ -253,14 +297,16 @@ async def _filter_articles(q: str, country: str, region: str) -> list[dict]: wheres.append("a.region = ?") params.append(region) where = " AND ".join(wheres) + # Geo-sort references a.country + order = geo_order.replace("country", "a.country") return await fetch_all( f"""SELECT a.* FROM articles a JOIN articles_fts ON articles_fts.rowid = a.id WHERE {where} AND a.status = 'published' AND a.published_at <= datetime('now') - ORDER BY a.published_at DESC + ORDER BY {order} LIMIT 100""", - tuple(params), + tuple(params + geo_params), ) else: wheres = ["status = 'published'", "published_at <= datetime('now')", "language = ?"] @@ -274,8 +320,8 @@ async def _filter_articles(q: str, country: str, region: str) -> list[dict]: where = " AND ".join(wheres) return await fetch_all( f"""SELECT * FROM articles WHERE {where} - ORDER BY published_at DESC LIMIT 100""", - tuple(params), + ORDER BY {geo_order} LIMIT 100""", + tuple(params + geo_params), )