Compare commits
48 Commits
v202603051
...
v202603061
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d29ecf1d6 | ||
|
|
a3b4e1fab6 | ||
|
|
8b794d24a6 | ||
|
|
688f2dd1ee | ||
|
|
81b556b205 | ||
|
|
cda94c9ee4 | ||
|
|
4fbd91b59b | ||
|
|
159d1b5b9a | ||
|
|
fcd0c9b007 | ||
|
|
f841ae105a | ||
|
|
dec4f07fbb | ||
|
|
4e4ff61699 | ||
|
|
f907f2cd60 | ||
|
|
3ad2885c84 | ||
|
|
e2f54552b0 | ||
|
|
07ca1ce15b | ||
|
|
be9b10c13f | ||
|
|
82d6333517 | ||
|
|
ed48936dad | ||
|
|
e3bda5b816 | ||
|
|
831233cb29 | ||
|
|
c5327c4012 | ||
|
|
4426ab2cb6 | ||
|
|
93c9408f6b | ||
|
|
84128a3a64 | ||
|
|
e9b4faa05c | ||
|
|
a834bb481d | ||
|
|
9515ec8ae9 | ||
|
|
fb99d6e0db | ||
|
|
4ee80603ef | ||
|
|
2e42245ad5 | ||
|
|
2f47d1e589 | ||
|
|
ead12c4552 | ||
|
|
c54eb50004 | ||
|
|
5d7fcec17a | ||
|
|
f7faf7ab57 | ||
|
|
add5f8ddfa | ||
|
|
15ca316682 | ||
|
|
103ef73cf5 | ||
|
|
aa27f14f3c | ||
|
|
8205744444 | ||
|
|
1cbefe349c | ||
|
|
003f19e071 | ||
|
|
c3f15535b8 | ||
|
|
fcb8ec4227 | ||
|
|
6b7fa45bce | ||
|
|
0d8687859d | ||
|
|
b064e18aa1 |
@@ -3,6 +3,7 @@ APP_NAME=ENC[AES256_GCM,data:ldJf4P0iD9ziMVg=,iv:hiVl2whhd02yZCafzBfbxX5/EU/suvz
|
||||
SECRET_KEY=ENC[AES256_GCM,data:hmlXm7NKVVFmeea4DnlrH/oSnsoaMAkUz42oWwFXOXL1XwAh3iemIKHUQOV2G4SPlmjfmEVQD64xbxaJW0OcPQ/8KqhrRYDsy0F/u0h7nmNQdwJrcvzcmbvjgcwU5IITPIr23d/W5PeSJzxhB93uaJ0+zFN2CyHfeewrJKafPfw=,iv:e+ZSLUO+dlt+ET8r/0/pf74UtGIBMkaVoJMWlJn1W5U=,tag:LdDCCrHcJnKLkKL/cY/R/Q==,type:str]
|
||||
BASE_URL=ENC[AES256_GCM,data:50k/RqlZ1EHqGM4UkSmTaCsuJgyU4w==,iv:f8zKr2jkts4RsawA97hzICHwj9Quzgp+Dw8AhQ7GSWA=,tag:9KhNvwmoOtDyuIql7okeew==,type:str]
|
||||
DEBUG=ENC[AES256_GCM,data:O0/uRF4=,iv:cZ+vyUuXjQOYYRf4l8lWS3JIWqL/w3pnlCTDPAZpB1E=,tag:OmJE9oJpzYzth0xwaMqADQ==,type:str]
|
||||
LANDING_DIR=ENC[AES256_GCM,data:rn8u+tGob0vU7kSAtxmrpYQlneesvyO10A==,iv:PuGtdcQBdRbnybulzd6L7JVQClcK3/QjMeYFXZSxGW0=,tag:K2PJPMCWXdqTlQpwP9+DOQ==,type:str]
|
||||
#ENC[AES256_GCM,data:xmJc6WTb3yumHzvLeA==,iv:9jKuYaDgm4zR/DTswIMwsajV0s5UTe+AOX4Sue0GPCs=,tag:b/7H9js1HmFYjuQE4zJz8w==,type:comment]
|
||||
ADMIN_EMAILS=ENC[AES256_GCM,data:R/2YTk8KDEpNQ71RN8Fm6miLZvXNJQ==,iv:kzmiaBK7KvnSjR5gx6lp7zEMzs5xRul6LBhmLf48bCU=,tag:csVZ0W1TxBAoJacQurW9VQ==,type:str]
|
||||
#ENC[AES256_GCM,data:S7Pdg9tcom3N,iv:OjmYk3pqbZHKPS1Y06w1y8BE7CU0y6Vx2wnio9tEhus=,tag:YAOGbrHQ+UOcdSQFWdiCDA==,type:comment]
|
||||
@@ -63,7 +64,7 @@ sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb2
|
||||
sops_age__list_1__map_recipient=age1wjepykv3glvsrtegu25tevg7vyn3ngpl607u3yjc9ucay04s045s796msw
|
||||
sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFeHhaOURNZnRVMEwxNThu\nUjF4Q0kwUXhTUE1QSzZJbmpubnh3RnpQTmdvCjRmWWxpNkxFUmVGb3NRbnlydW5O\nWEg3ZXJQTU4vcndzS2pUQXY3Q0ttYjAKLS0tIE9IRFJ1c2ZxbGVHa2xTL0swbGN1\nTzgwMThPUDRFTWhuZHJjZUYxOTZrU00KY62qrNBCUQYxwcLMXFEnLkwncxq3BPJB\nKm4NzeHBU87XmPWVrgrKuf+PH1mxJlBsl7Hev8xBTy7l6feiZjLIvQ==\n-----END AGE ENCRYPTED FILE-----\n
|
||||
sops_age__list_2__map_recipient=age1c783ym2q5x9tv7py5d28uc4k44aguudjn03g97l9nzs00dd9tsrqum8h4d
|
||||
sops_lastmodified=2026-03-01T20:26:09Z
|
||||
sops_mac=ENC[AES256_GCM,data:IxzU6VehA0iHgpIEqDSoMywKyKONI6jSr/6Amo+g3JI72awJtk6ft0ppfDWZjeHhL0ixfnvgqMNwai+1e0V/U8hSP8/FqYKEVpAO0UGJfBPKP3pbw+tx3WJQMF5dIh2/UVNrKvoACZq0IDJfXlVqalCnRMQEHGtKVTIT3fn8m6c=,iv:0w0ohOBsqTzuoQdtt6AI5ZdHEKw9+hI73tycBjDSS0o=,tag:Guw7LweA4m4Nw+3kSuZKWA==,type:str]
|
||||
sops_lastmodified=2026-03-05T15:55:19Z
|
||||
sops_mac=ENC[AES256_GCM,data:orLypjurBTYmk3um0bDQV3wFxj1pjCsjOf2D+AZyoIYY88MeY8BjK8mg8BWhmJYlGWqHH1FCpoJS+2SECv2Bvgejqvx/C/HSysA8et5CArM/p/MBbcupLAKOD8bTXorKMRDYPkWpK/snkPToxIZZd7dNj/zSU+OhRp5qLGCHkvM=,iv:eBn93z4DSk8UPHgP/Jf/Kz+3KwoKIQ9Et72pbLFcLP8=,tag:79kzPIKp0rtHGhH1CkXqwg==,type:str]
|
||||
sops_unencrypted_suffix=_unencrypted
|
||||
sops_version=3.12.1
|
||||
|
||||
23
CHANGELOG.md
23
CHANGELOG.md
@@ -6,7 +6,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- **Unified `location_profiles` serving model** — merged `city_market_profile` and `location_opportunity_profile` into a single `serving.location_profiles` table at `(country_code, geoname_id)` grain. Both Marktreife-Score (Market Score) and Marktpotenzial-Score (Opportunity Score) are now computed per location. City data enriched via LEFT JOIN `dim_cities` on `geoname_id`. Downstream models (`planner_defaults`, `pseo_city_costs_de`, `pseo_city_pricing`) updated to query `location_profiles` directly. `city_padel_venue_count` (exact from dim_cities) distinguished from `padel_venue_count` (spatial 5km from dim_locations).
|
||||
- **Both scores on all map tooltips** — country map shows avg Market Score + avg Opportunity Score; city map shows Market Score + Opportunity Score per city; opportunity map shows Opportunity Score + Market Score per location. All score labels use the trademarked "Padelnomics Market Score" / "Padelnomics Opportunity Score" names.
|
||||
- **API endpoints** — `/api/markets/countries.json` adds `avg_opportunity_score`; `/api/markets/<country>/cities.json` adds `opportunity_score`; `/api/opportunity/<country>.json` adds `market_score`.
|
||||
- **Marktpotenzial-Score v3: H3 catchment lens** — addressable market (25pts) and supply gap (30pts) now use a regional H3 catchment (~15-18km radius, res-4 cell + 6 neighbours, ~462km²) instead of local city population and 5km court count. Mid-size cities surrounded by dense Gemeinden (e.g. Oldenburg) now score correctly. New output columns: `catchment_population`, `catchment_padel_courts`, `catchment_venues_per_100k`. Requires one-time `INSTALL h3 FROM community` in DuckDB on each machine.
|
||||
|
||||
### Added
|
||||
- **Custom 404/500 error pages** — styled error pages extending `base.html` with i18n support (EN/DE). The 404 page is context-aware: when the URL matches `/markets/{country}/{city}`, it shows a city-specific message with a link back to the country overview instead of a generic "page not found".
|
||||
- **Map: city article indicators** — country overview map bubbles now differentiate cities with/without published articles. All cities retain score-based colors (green/amber/red); non-article cities are visually receded with lower opacity, dashed borders, desaturated color, and default cursor (no click). Tooltips show scores for all cities — article cities get "Click to explore →", non-article cities get "Coming soon". The `/api/markets/<country>/cities.json` endpoint includes a `has_article` boolean per city.
|
||||
|
||||
### Fixed
|
||||
- **Admin template preview maps** — Leaflet maps rendered blank because `article-maps.js` called `L.divIcon()` at the IIFE top level before Leaflet was dynamically loaded, crashing the script. Moved `VENUE_ICON` creation into the `script.onload` callback so it runs after Leaflet is available. Previous commit's `.card` `overflow: visible` fix remains (clips tile layers otherwise).
|
||||
- **Admin articles page 500** — `/admin/articles` crashed with `BuildError` when an article generation task was running because `article_stats.html` partial referenced `url_for('admin.article_stats')` but the route didn't exist. Added the missing HTMX partial endpoint.
|
||||
- **Silent 500 errors in dev** — `dev_run.sh` used Granian which swallowed Quart's debug error pages, showing generic "Internal Server Error" with no traceback. Switched to `uv run python -m padelnomics.app` for proper debug mode with browser tracebacks. Added `@app.errorhandler(500)` to log exceptions even when running under Granian in production.
|
||||
- **Pipeline diagnostic script** (`scripts/check_pipeline.py`) — handle DuckDB catalog naming quirk where `lakehouse.duckdb` uses catalog `lakehouse` instead of `local`, causing SQLMesh logical views to break. Script now auto-detects the catalog via `USE`, and falls back to querying physical tables (`sqlmesh__<schema>.<table>__<hash>`) when views fail.
|
||||
- **Eurostat gas prices extractor** — `nrg_pc_203` filter missing `unit` dimension (API returns both KWH and GJ_GCV); now filters to `KWH`.
|
||||
- **Eurostat labour costs extractor** — `lc_lci_lev` used non-existent `currency` filter dimension; corrected to `unit: EUR`.
|
||||
- **Supervisor transform step** — changed `sqlmesh run` to `sqlmesh plan prod --auto-apply` so new/modified models are detected and applied automatically.
|
||||
|
||||
### Added
|
||||
- **Pipeline diagnostic script** (`scripts/check_pipeline.py`) — read-only script that reports row counts at every layer of the pricing pipeline (staging → foundation → serving), date range analysis, HAVING filter impact, and join coverage. Run on prod to diagnose empty serving tables.
|
||||
- **Extraction card descriptions** — each workflow card on the admin pipeline page now shows a one-line description explaining what the data source is (e.g. "EU geographic boundaries (NUTS2 polygons) from Eurostat GISCO"). Descriptions defined in `workflows.toml`.
|
||||
- **Running state indicator** — extraction cards show a spinner + "Running" label with a blue-tinted border when an extraction is actively running, replacing the plain Run button. Cards also display the start time with "running..." text.
|
||||
|
||||
- **Interactive Leaflet maps** — geographic visualization across 4 key placements using self-hosted Leaflet 1.9.4 (GDPR-safe, no CDN):
|
||||
- **Markets hub** (`/markets`): country bubble map with circles sized by total venues, colored by avg market score (green ≥ 60, amber 30-60, red < 30). Click navigates to country overview.
|
||||
- **Country overview articles**: city bubble map loads after article render, auto-fits bounds, click navigates to city page. Bubbles colored by market score.
|
||||
|
||||
@@ -25,6 +25,7 @@ WORKDIR /app
|
||||
RUN mkdir -p /app/data && chown -R appuser:appuser /app
|
||||
COPY --from=build --chown=appuser:appuser /app .
|
||||
COPY --from=css-build /app/web/src/padelnomics/static/css/output.css ./web/src/padelnomics/static/css/output.css
|
||||
COPY --chown=appuser:appuser infra/supervisor/workflows.toml ./infra/supervisor/workflows.toml
|
||||
USER appuser
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV DATABASE_PATH=/app/data/app.db
|
||||
|
||||
@@ -63,15 +63,15 @@ DATASETS: dict[str, dict] = {
|
||||
"time_dim": "time",
|
||||
},
|
||||
"nrg_pc_203": {
|
||||
# Gas prices for non-household consumers, EUR/GJ, excl. taxes
|
||||
"filters": {"freq": "S", "nrg_cons": "GJ1000-9999", "currency": "EUR", "tax": "I_TAX"},
|
||||
# Gas prices for non-household consumers, EUR/kWh, excl. taxes
|
||||
"filters": {"freq": "S", "nrg_cons": "GJ1000-9999", "unit": "KWH", "currency": "EUR", "tax": "I_TAX"},
|
||||
"geo_dim": "geo",
|
||||
"time_dim": "time",
|
||||
},
|
||||
"lc_lci_lev": {
|
||||
# Labour cost levels EUR/hour — NACE N (administrative/support services)
|
||||
# Stored in dim_countries for future staffed-scenario calculations.
|
||||
"filters": {"lcstruct": "D1_D2_A_HW", "nace_r2": "N", "currency": "EUR"},
|
||||
# D1_D4_MD5 = compensation of employees + taxes - subsidies (total labour cost)
|
||||
"filters": {"lcstruct": "D1_D4_MD5", "nace_r2": "N", "unit": "EUR"},
|
||||
"geo_dim": "geo",
|
||||
"time_dim": "time",
|
||||
},
|
||||
|
||||
@@ -33,10 +33,10 @@ do
|
||||
DUCKDB_PATH="${DUCKDB_PATH:-/data/padelnomics/lakehouse.duckdb}" \
|
||||
uv run --package padelnomics_extract extract
|
||||
|
||||
# Transform
|
||||
# Transform — run evaluates missing daily intervals for incremental models.
|
||||
LANDING_DIR="${LANDING_DIR:-/data/padelnomics/landing}" \
|
||||
DUCKDB_PATH="${DUCKDB_PATH:-/data/padelnomics/lakehouse.duckdb}" \
|
||||
uv run --package sqlmesh_padelnomics sqlmesh run --select-model "serving.*"
|
||||
uv run sqlmesh -p transform/sqlmesh_padelnomics run prod
|
||||
|
||||
# Export serving tables to analytics.duckdb (atomic swap).
|
||||
# The web app detects the inode change on next query — no restart needed.
|
||||
|
||||
@@ -8,54 +8,67 @@
|
||||
# entry — optional: function name if not "main" (default: "main")
|
||||
# depends_on — optional: list of workflow names that must run first
|
||||
# proxy_mode — optional: "round-robin" (default) or "sticky"
|
||||
# description — optional: human-readable one-liner shown in the admin UI
|
||||
|
||||
[overpass]
|
||||
module = "padelnomics_extract.overpass"
|
||||
schedule = "monthly"
|
||||
description = "Padel court locations from OpenStreetMap via Overpass API"
|
||||
|
||||
[overpass_tennis]
|
||||
module = "padelnomics_extract.overpass_tennis"
|
||||
schedule = "monthly"
|
||||
description = "Tennis court locations from OpenStreetMap via Overpass API"
|
||||
|
||||
[eurostat]
|
||||
module = "padelnomics_extract.eurostat"
|
||||
schedule = "monthly"
|
||||
description = "City population data from Eurostat Urban Audit"
|
||||
|
||||
[geonames]
|
||||
module = "padelnomics_extract.geonames"
|
||||
schedule = "monthly"
|
||||
description = "Global city/town gazetteer from GeoNames (pop >= 1K)"
|
||||
|
||||
[playtomic_tenants]
|
||||
module = "padelnomics_extract.playtomic_tenants"
|
||||
schedule = "daily"
|
||||
description = "Padel venue directory from Playtomic (names, locations, courts)"
|
||||
|
||||
[playtomic_availability]
|
||||
module = "padelnomics_extract.playtomic_availability"
|
||||
schedule = "daily"
|
||||
depends_on = ["playtomic_tenants"]
|
||||
description = "Morning availability snapshots — slot-level pricing per venue"
|
||||
|
||||
[playtomic_recheck]
|
||||
module = "padelnomics_extract.playtomic_availability"
|
||||
entry = "main_recheck"
|
||||
schedule = "0,30 6-23 * * *"
|
||||
depends_on = ["playtomic_availability"]
|
||||
description = "Intraday availability rechecks for occupancy tracking"
|
||||
|
||||
[census_usa]
|
||||
module = "padelnomics_extract.census_usa"
|
||||
schedule = "monthly"
|
||||
description = "US city/place population from Census Bureau ACS"
|
||||
|
||||
[census_usa_income]
|
||||
module = "padelnomics_extract.census_usa_income"
|
||||
schedule = "monthly"
|
||||
description = "US county median household income from Census Bureau ACS"
|
||||
|
||||
[eurostat_city_labels]
|
||||
module = "padelnomics_extract.eurostat_city_labels"
|
||||
schedule = "monthly"
|
||||
description = "City code-to-name mapping for Eurostat Urban Audit cities"
|
||||
|
||||
[ons_uk]
|
||||
module = "padelnomics_extract.ons_uk"
|
||||
schedule = "monthly"
|
||||
description = "UK local authority population estimates from ONS"
|
||||
|
||||
[gisco]
|
||||
module = "padelnomics_extract.gisco"
|
||||
schedule = "monthly"
|
||||
schedule = "0 0 1 1 *"
|
||||
description = "EU geographic boundaries (NUTS2 polygons) from Eurostat GISCO"
|
||||
|
||||
290
scripts/check_pipeline.py
Normal file
290
scripts/check_pipeline.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""
|
||||
Diagnostic script: check row counts at every layer of the pricing pipeline.
|
||||
|
||||
Run on prod via SSH:
|
||||
DUCKDB_PATH=/opt/padelnomics/data/lakehouse.duckdb uv run python scripts/check_pipeline.py
|
||||
|
||||
Or locally:
|
||||
DUCKDB_PATH=data/lakehouse.duckdb uv run python scripts/check_pipeline.py
|
||||
|
||||
Read-only — never writes to the database.
|
||||
|
||||
Handles the DuckDB catalog naming quirk: when the file is named lakehouse.duckdb,
|
||||
the catalog is "lakehouse" not "local". SQLMesh views may reference the wrong catalog,
|
||||
so we fall back to querying physical tables (sqlmesh__<schema>.<table>__<hash>).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import duckdb
|
||||
|
||||
DUCKDB_PATH = os.environ.get("DUCKDB_PATH", "data/lakehouse.duckdb")
|
||||
|
||||
PIPELINE_TABLES = [
|
||||
("staging", "stg_playtomic_availability"),
|
||||
("foundation", "fct_availability_slot"),
|
||||
("foundation", "dim_venue_capacity"),
|
||||
("foundation", "fct_daily_availability"),
|
||||
("serving", "venue_pricing_benchmarks"),
|
||||
("serving", "pseo_city_pricing"),
|
||||
]
|
||||
|
||||
|
||||
def _use_catalog(con):
|
||||
"""Detect and USE the database catalog so schema-qualified queries work."""
|
||||
catalogs = [
|
||||
row[0]
|
||||
for row in con.execute(
|
||||
"SELECT catalog_name FROM information_schema.schemata"
|
||||
).fetchall()
|
||||
]
|
||||
# Pick the non-system catalog (not 'system', 'temp', 'memory')
|
||||
user_catalogs = [c for c in set(catalogs) if c not in ("system", "temp", "memory")]
|
||||
if user_catalogs:
|
||||
catalog = user_catalogs[0]
|
||||
con.execute(f"USE {catalog}")
|
||||
return catalog
|
||||
return None
|
||||
|
||||
|
||||
def _find_physical_table(con, schema, table):
|
||||
"""Find the SQLMesh physical table name for a logical table.
|
||||
|
||||
SQLMesh stores physical tables as:
|
||||
sqlmesh__<schema>.<schema>__<table>__<hash>
|
||||
"""
|
||||
sqlmesh_schema = f"sqlmesh__{schema}"
|
||||
try:
|
||||
rows = con.execute(
|
||||
"SELECT table_schema, table_name "
|
||||
"FROM information_schema.tables "
|
||||
f"WHERE table_schema = '{sqlmesh_schema}' "
|
||||
f"AND table_name LIKE '{schema}__{table}%' "
|
||||
"ORDER BY table_name "
|
||||
"LIMIT 1"
|
||||
).fetchall()
|
||||
if rows:
|
||||
return f"{rows[0][0]}.{rows[0][1]}"
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _query_table(con, schema, table):
|
||||
"""Try logical view first, fall back to physical table. Returns (fqn, count) or (fqn, error_str)."""
|
||||
logical = f"{schema}.{table}"
|
||||
try:
|
||||
(count,) = con.execute(f"SELECT COUNT(*) FROM {logical}").fetchone()
|
||||
return logical, count
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
physical = _find_physical_table(con, schema, table)
|
||||
if physical:
|
||||
try:
|
||||
(count,) = con.execute(f"SELECT COUNT(*) FROM {physical}").fetchone()
|
||||
return f"{physical} (physical)", count
|
||||
except Exception as e:
|
||||
return f"{physical} (physical)", f"ERROR: {e}"
|
||||
|
||||
return logical, "ERROR: view broken, no physical table found"
|
||||
|
||||
|
||||
def _query_sql(con, sql, schema_tables):
|
||||
"""Execute SQL, falling back to rewritten SQL using physical table names if views fail.
|
||||
|
||||
schema_tables: list of (schema, table) tuples used in the SQL, in order of appearance.
|
||||
The SQL must use {schema}.{table} format for these references.
|
||||
"""
|
||||
try:
|
||||
return con.execute(sql)
|
||||
except Exception:
|
||||
# Rewrite SQL to use physical table names
|
||||
rewritten = sql
|
||||
for schema, table in schema_tables:
|
||||
physical = _find_physical_table(con, schema, table)
|
||||
if physical:
|
||||
rewritten = rewritten.replace(f"{schema}.{table}", physical)
|
||||
else:
|
||||
raise
|
||||
return con.execute(rewritten)
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.exists(DUCKDB_PATH):
|
||||
print(f"ERROR: {DUCKDB_PATH} not found")
|
||||
sys.exit(1)
|
||||
|
||||
con = duckdb.connect(DUCKDB_PATH, read_only=True)
|
||||
|
||||
print(f"Database: {DUCKDB_PATH}")
|
||||
print(f"DuckDB version: {con.execute('SELECT version()').fetchone()[0]}")
|
||||
|
||||
catalog = _use_catalog(con)
|
||||
if catalog:
|
||||
print(f"Catalog: {catalog}")
|
||||
print()
|
||||
|
||||
# ── Row counts at each layer ──────────────────────────────────────────
|
||||
print("=" * 60)
|
||||
print("PIPELINE ROW COUNTS")
|
||||
print("=" * 60)
|
||||
|
||||
for schema, table in PIPELINE_TABLES:
|
||||
fqn, result = _query_table(con, schema, table)
|
||||
if isinstance(result, int):
|
||||
print(f" {fqn:55s} {result:>10,} rows")
|
||||
else:
|
||||
print(f" {fqn:55s} {result}")
|
||||
|
||||
# ── Date range in fct_daily_availability ──────────────────────────────
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("DATE RANGE: fct_daily_availability")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
row = _query_sql(
|
||||
con,
|
||||
"""
|
||||
SELECT
|
||||
MIN(snapshot_date) AS min_date,
|
||||
MAX(snapshot_date) AS max_date,
|
||||
COUNT(DISTINCT snapshot_date) AS distinct_days,
|
||||
CURRENT_DATE AS today,
|
||||
CURRENT_DATE - INTERVAL '30 days' AS window_start
|
||||
FROM foundation.fct_daily_availability
|
||||
""",
|
||||
[("foundation", "fct_daily_availability")],
|
||||
).fetchone()
|
||||
if row:
|
||||
min_date, max_date, days, today, window_start = row
|
||||
print(f" Min snapshot_date: {min_date}")
|
||||
print(f" Max snapshot_date: {max_date}")
|
||||
print(f" Distinct days: {days}")
|
||||
print(f" Today: {today}")
|
||||
print(f" 30-day window start: {window_start}")
|
||||
if max_date and str(max_date) < str(window_start):
|
||||
print()
|
||||
print(" *** ALL DATA IS OUTSIDE THE 30-DAY WINDOW ***")
|
||||
print(" This is why venue_pricing_benchmarks is empty.")
|
||||
except Exception as e:
|
||||
print(f" ERROR: {e}")
|
||||
|
||||
# ── HAVING filter impact in venue_pricing_benchmarks ──────────────────
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("HAVING FILTER IMPACT (venue_pricing_benchmarks)")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
row = _query_sql(
|
||||
con,
|
||||
"""
|
||||
WITH venue_stats AS (
|
||||
SELECT
|
||||
da.tenant_id,
|
||||
da.country_code,
|
||||
da.city,
|
||||
COUNT(DISTINCT da.snapshot_date) AS days_observed
|
||||
FROM foundation.fct_daily_availability da
|
||||
WHERE TRY_CAST(da.snapshot_date AS DATE) >= CURRENT_DATE - INTERVAL '30 days'
|
||||
AND da.occupancy_rate IS NOT NULL
|
||||
AND da.occupancy_rate BETWEEN 0 AND 1.5
|
||||
GROUP BY da.tenant_id, da.country_code, da.city
|
||||
)
|
||||
SELECT
|
||||
COUNT(*) AS total_venues,
|
||||
COUNT(*) FILTER (WHERE days_observed >= 3) AS venues_passing_having,
|
||||
COUNT(*) FILTER (WHERE days_observed < 3) AS venues_failing_having,
|
||||
MAX(days_observed) AS max_days,
|
||||
MIN(days_observed) AS min_days
|
||||
FROM venue_stats
|
||||
""",
|
||||
[("foundation", "fct_daily_availability")],
|
||||
).fetchone()
|
||||
if row:
|
||||
total, passing, failing, max_d, min_d = row
|
||||
print(f" Venues in 30-day window: {total}")
|
||||
print(f" Venues with >= 3 days (PASSING): {passing}")
|
||||
print(f" Venues with < 3 days (FILTERED): {failing}")
|
||||
print(f" Max days observed: {max_d}")
|
||||
print(f" Min days observed: {min_d}")
|
||||
if total == 0:
|
||||
print()
|
||||
print(" *** NO VENUES IN 30-DAY WINDOW — check fct_daily_availability dates ***")
|
||||
except Exception as e:
|
||||
print(f" ERROR: {e}")
|
||||
|
||||
# ── Occupancy rate distribution ───────────────────────────────────────
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("OCCUPANCY RATE DISTRIBUTION (fct_daily_availability)")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
rows = _query_sql(
|
||||
con,
|
||||
"""
|
||||
SELECT
|
||||
CASE
|
||||
WHEN occupancy_rate IS NULL THEN 'NULL'
|
||||
WHEN occupancy_rate < 0 THEN '< 0 (invalid)'
|
||||
WHEN occupancy_rate > 1.5 THEN '> 1.5 (filtered)'
|
||||
WHEN occupancy_rate <= 0.25 THEN '0 – 0.25'
|
||||
WHEN occupancy_rate <= 0.50 THEN '0.25 – 0.50'
|
||||
WHEN occupancy_rate <= 0.75 THEN '0.50 – 0.75'
|
||||
ELSE '0.75 – 1.0+'
|
||||
END AS bucket,
|
||||
COUNT(*) AS cnt
|
||||
FROM foundation.fct_daily_availability
|
||||
GROUP BY 1
|
||||
ORDER BY 1
|
||||
""",
|
||||
[("foundation", "fct_daily_availability")],
|
||||
).fetchall()
|
||||
for bucket, cnt in rows:
|
||||
print(f" {bucket:25s} {cnt:>10,}")
|
||||
except Exception as e:
|
||||
print(f" ERROR: {e}")
|
||||
|
||||
# ── dim_venue_capacity join coverage ──────────────────────────────────
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("JOIN COVERAGE: fct_availability_slot → dim_venue_capacity")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
row = _query_sql(
|
||||
con,
|
||||
"""
|
||||
SELECT
|
||||
COUNT(DISTINCT a.tenant_id) AS slot_tenants,
|
||||
COUNT(DISTINCT c.tenant_id) AS capacity_tenants,
|
||||
COUNT(DISTINCT a.tenant_id) - COUNT(DISTINCT c.tenant_id) AS missing_capacity
|
||||
FROM foundation.fct_availability_slot a
|
||||
LEFT JOIN foundation.dim_venue_capacity c ON a.tenant_id = c.tenant_id
|
||||
""",
|
||||
[
|
||||
("foundation", "fct_availability_slot"),
|
||||
("foundation", "dim_venue_capacity"),
|
||||
],
|
||||
).fetchone()
|
||||
if row:
|
||||
slot_t, cap_t, missing = row
|
||||
print(f" Tenants in fct_availability_slot: {slot_t}")
|
||||
print(f" Tenants with capacity match: {cap_t}")
|
||||
print(f" Tenants missing capacity: {missing}")
|
||||
if missing and missing > 0:
|
||||
print(f" *** {missing} tenants dropped by INNER JOIN to dim_venue_capacity ***")
|
||||
except Exception as e:
|
||||
print(f" ERROR: {e}")
|
||||
|
||||
con.close()
|
||||
print()
|
||||
print("Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -247,10 +247,10 @@ def run_shell(cmd: str, timeout_seconds: int = SUBPROCESS_TIMEOUT_SECONDS) -> tu
|
||||
|
||||
|
||||
def run_transform() -> None:
|
||||
"""Run SQLMesh — it evaluates model staleness internally."""
|
||||
"""Run SQLMesh — evaluates missing daily intervals."""
|
||||
logger.info("Running SQLMesh transform")
|
||||
ok, err = run_shell(
|
||||
"uv run sqlmesh -p transform/sqlmesh_padelnomics plan prod --auto-apply",
|
||||
"uv run sqlmesh -p transform/sqlmesh_padelnomics run prod",
|
||||
)
|
||||
if not ok:
|
||||
send_alert(f"[transform] {err}")
|
||||
@@ -358,6 +358,8 @@ def git_pull_and_sync() -> None:
|
||||
run_shell(f"git checkout --detach {latest}")
|
||||
run_shell("sops --input-type dotenv --output-type dotenv -d .env.prod.sops > .env")
|
||||
run_shell("uv sync --all-packages")
|
||||
# Apply any model changes (FULL→INCREMENTAL, new models, etc.) before re-exec
|
||||
run_shell("uv run sqlmesh -p transform/sqlmesh_padelnomics plan prod --auto-apply")
|
||||
# Re-exec so the new code is loaded. os.execv replaces this process in-place;
|
||||
# systemd sees it as the same PID and does not restart the unit.
|
||||
logger.info("Deploy complete — re-execing to load new code")
|
||||
|
||||
@@ -56,27 +56,27 @@ Grain must match reality — use `QUALIFY ROW_NUMBER()` to enforce it.
|
||||
|-----------|-------|---------|
|
||||
| `foundation.dim_countries` | `country_code` | `dim_cities`, `dim_locations`, `pseo_city_costs_de`, `planner_defaults` — single source for country names, income, PLI/cost overrides |
|
||||
| `foundation.dim_venues` | `venue_id` | `dim_cities`, `dim_venue_capacity`, `fct_daily_availability` (via capacity join) |
|
||||
| `foundation.dim_cities` | `(country_code, city_slug)` | `serving.city_market_profile` → all pSEO serving models |
|
||||
| `foundation.dim_locations` | `(country_code, geoname_id)` | `serving.location_opportunity_profile` — all GeoNames locations (pop ≥1K), incl. zero-court locations |
|
||||
| `foundation.dim_cities` | `(country_code, city_slug)` | `serving.location_profiles` (city_slug + city_padel_venue_count) → all pSEO serving models |
|
||||
| `foundation.dim_locations` | `(country_code, geoname_id)` | `serving.location_profiles` — all GeoNames locations (pop ≥1K), incl. zero-court locations |
|
||||
| `foundation.dim_venue_capacity` | `tenant_id` | `foundation.fct_daily_availability` |
|
||||
|
||||
## Source integration map
|
||||
|
||||
```
|
||||
stg_playtomic_venues ─┐
|
||||
stg_playtomic_resources─┤→ dim_venues ─┬→ dim_cities ──────────────→ city_market_profile
|
||||
stg_padel_courts ─┘ └→ dim_venue_capacity (Marktreife-Score)
|
||||
↓
|
||||
stg_playtomic_resources─┤→ dim_venues ─┬→ dim_cities ──┐
|
||||
stg_padel_courts ─┘ └→ dim_venue_capacity
|
||||
│
|
||||
stg_playtomic_availability ──→ fct_availability_slot ──→ fct_daily_availability
|
||||
↓
|
||||
venue_pricing_benchmarks
|
||||
↓
|
||||
stg_population ──→ dim_cities ─────────────────────────────┘
|
||||
stg_income ──→ dim_cities
|
||||
|
||||
stg_population_geonames ─┐
|
||||
stg_padel_courts ─┤→ dim_locations ──→ location_opportunity_profile
|
||||
stg_tennis_courts ─┤ (Marktpotenzial-Score)
|
||||
stg_income ──→ dim_cities │
|
||||
↓
|
||||
stg_population_geonames ─┐ location_profiles
|
||||
stg_padel_courts ─┤→ dim_locations ────────→ (both scores:
|
||||
stg_tennis_courts ─┤ Marktreife + Marktpotenzial)
|
||||
stg_income ─┘
|
||||
```
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ gateways:
|
||||
local: "{{ env_var('DUCKDB_PATH', 'data/lakehouse.duckdb') }}"
|
||||
extensions:
|
||||
- spatial
|
||||
- name: h3
|
||||
repository: community
|
||||
|
||||
default_gateway: duckdb
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
-- Built from venue locations (dim_venues) as the primary source — padelnomics
|
||||
-- tracks cities where padel venues actually exist, not an administrative city list.
|
||||
--
|
||||
-- Conformed dimension: used by city_market_profile and all pSEO serving models.
|
||||
-- Conformed dimension: used by location_profiles and all pSEO serving models.
|
||||
-- Integrates four sources:
|
||||
-- dim_venues → city list, venue count, coordinates (Playtomic + OSM)
|
||||
-- foundation.dim_countries → country_name_en, country_slug, median_income_pps
|
||||
@@ -128,7 +128,7 @@ SELECT
|
||||
vc.padel_venue_count,
|
||||
c.median_income_pps,
|
||||
c.income_year,
|
||||
-- GeoNames ID: FK to dim_locations / location_opportunity_profile.
|
||||
-- GeoNames ID: FK to dim_locations / location_profiles.
|
||||
-- String match preferred; spatial fallback used when name doesn't match (Milano→Milan, etc.)
|
||||
COALESCE(gn.geoname_id, gs.spatial_geoname_id) AS geoname_id
|
||||
FROM venue_cities vc
|
||||
|
||||
@@ -215,6 +215,7 @@ SELECT
|
||||
l.location_slug,
|
||||
l.lat,
|
||||
l.lon,
|
||||
h3_latlng_to_cell(l.lat, l.lon, 4) AS h3_cell_res4,
|
||||
l.admin1_code,
|
||||
l.admin2_code,
|
||||
l.population,
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
|
||||
MODEL (
|
||||
name foundation.fct_availability_slot,
|
||||
kind FULL,
|
||||
kind INCREMENTAL_BY_TIME_RANGE (
|
||||
time_column snapshot_date
|
||||
),
|
||||
start '2026-03-01',
|
||||
cron '@daily',
|
||||
grain (snapshot_date, tenant_id, resource_id, slot_start_time)
|
||||
);
|
||||
@@ -37,7 +40,8 @@ WITH deduped AS (
|
||||
captured_at_utc DESC
|
||||
) AS rn
|
||||
FROM staging.stg_playtomic_availability
|
||||
WHERE price_amount IS NOT NULL
|
||||
WHERE snapshot_date BETWEEN @start_ds AND @end_ds
|
||||
AND price_amount IS NOT NULL
|
||||
AND price_amount > 0
|
||||
)
|
||||
SELECT
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
|
||||
MODEL (
|
||||
name foundation.fct_daily_availability,
|
||||
kind FULL,
|
||||
kind INCREMENTAL_BY_TIME_RANGE (
|
||||
time_column snapshot_date
|
||||
),
|
||||
start '2026-03-01',
|
||||
cron '@daily',
|
||||
grain (snapshot_date, tenant_id)
|
||||
);
|
||||
@@ -37,6 +40,7 @@ WITH slot_agg AS (
|
||||
MAX(a.price_currency) AS price_currency,
|
||||
MAX(a.captured_at_utc) AS captured_at_utc
|
||||
FROM foundation.fct_availability_slot a
|
||||
WHERE a.snapshot_date BETWEEN @start_ds AND @end_ds
|
||||
GROUP BY a.snapshot_date, a.tenant_id
|
||||
)
|
||||
SELECT
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
Analytics-ready views consumed by the web app and programmatic SEO.
|
||||
Query these from `analytics.py` via DuckDB read-only connection.
|
||||
|
||||
Naming convention: `serving.<purpose>` (e.g. `serving.city_market_profile`)
|
||||
Naming convention: `serving.<purpose>` (e.g. `serving.location_profiles`)
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
-- One Big Table: per-city padel market intelligence.
|
||||
-- Consumed by: SEO article generation, planner city-select pre-fill, API endpoints.
|
||||
--
|
||||
-- Padelnomics Marktreife-Score v3 (0–100):
|
||||
-- Answers "How mature/established is this padel market?"
|
||||
-- Only computed for cities with ≥1 padel venue (padel_venue_count > 0).
|
||||
-- For white-space opportunity scoring, see serving.location_opportunity_profile.
|
||||
--
|
||||
-- 40 pts supply development — log-scaled density (LN ceiling 20/100k) × count gate
|
||||
-- (min(1, count/5) kills small-town inflation)
|
||||
-- 25 pts demand evidence — occupancy when available; 40% density proxy otherwise
|
||||
-- 15 pts addressable market — log-scaled population, ceiling 1M (context only)
|
||||
-- 10 pts economic context — income PPS normalised to 200 ceiling
|
||||
-- 10 pts data quality — completeness discount
|
||||
-- No saturation discount: high density = maturity, not a penalty
|
||||
|
||||
MODEL (
|
||||
name serving.city_market_profile,
|
||||
kind FULL,
|
||||
cron '@daily',
|
||||
grain (country_code, city_slug)
|
||||
);
|
||||
|
||||
WITH base AS (
|
||||
SELECT
|
||||
c.country_code,
|
||||
c.country_name_en,
|
||||
c.country_slug,
|
||||
c.city_name,
|
||||
c.city_slug,
|
||||
c.lat,
|
||||
c.lon,
|
||||
c.population,
|
||||
c.population_year,
|
||||
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)
|
||||
ELSE NULL
|
||||
END AS venues_per_100k,
|
||||
-- Data confidence: 1.0 if both population and venues are present
|
||||
CASE
|
||||
WHEN c.population > 0 AND c.padel_venue_count > 0 THEN 1.0
|
||||
WHEN c.population > 0 OR c.padel_venue_count > 0 THEN 0.5
|
||||
ELSE 0.0
|
||||
END AS data_confidence,
|
||||
-- Pricing / occupancy from Playtomic (NULL when no availability data)
|
||||
vpb.median_hourly_rate,
|
||||
vpb.median_peak_rate,
|
||||
vpb.median_offpeak_rate,
|
||||
vpb.median_occupancy_rate,
|
||||
vpb.median_daily_revenue_per_venue,
|
||||
vpb.price_currency
|
||||
FROM foundation.dim_cities c
|
||||
LEFT JOIN serving.venue_pricing_benchmarks vpb
|
||||
ON c.country_code = vpb.country_code
|
||||
AND c.city_slug = vpb.city_slug
|
||||
WHERE c.padel_venue_count > 0
|
||||
),
|
||||
scored AS (
|
||||
SELECT *,
|
||||
ROUND(
|
||||
-- Supply development (40 pts): THE maturity signal.
|
||||
-- Log-scaled density: LN(density+1)/LN(21) → 20/100k ≈ full marks.
|
||||
-- Count gate: min(1, count/5) — 1 venue=20%, 5+ venues=100%.
|
||||
-- Kills small-town inflation (1 court / 5k pop = 20/100k) without hard cutoffs.
|
||||
40.0 * LEAST(1.0, LN(COALESCE(venues_per_100k, 0) + 1) / LN(21))
|
||||
* LEAST(1.0, padel_venue_count / 5.0)
|
||||
-- Demand evidence (25 pts): occupancy when Playtomic data available.
|
||||
-- Fallback: 40% of density score (avoids double-counting with supply component).
|
||||
+ 25.0 * CASE
|
||||
WHEN median_occupancy_rate IS NOT NULL
|
||||
THEN LEAST(1.0, median_occupancy_rate / 0.65)
|
||||
ELSE 0.4 * LEAST(1.0, LN(COALESCE(venues_per_100k, 0) + 1) / LN(21))
|
||||
* LEAST(1.0, padel_venue_count / 5.0)
|
||||
END
|
||||
-- Addressable market (15 pts): population as context, not maturity signal.
|
||||
-- LN(1) = 0 so zero-pop cities score 0 here.
|
||||
+ 15.0 * LEAST(1.0, LN(GREATEST(population, 1)) / LN(1000000))
|
||||
-- Economic context (10 pts): country-level income PPS.
|
||||
-- Flat per country — kept as context modifier, not primary signal.
|
||||
+ 10.0 * LEAST(1.0, COALESCE(median_income_pps, 100) / 200.0)
|
||||
-- Data quality (10 pts): completeness discount.
|
||||
+ 10.0 * data_confidence
|
||||
, 1)
|
||||
AS market_score
|
||||
FROM base
|
||||
)
|
||||
SELECT
|
||||
s.country_code,
|
||||
s.country_name_en,
|
||||
s.country_slug,
|
||||
s.city_name,
|
||||
s.city_slug,
|
||||
s.lat,
|
||||
s.lon,
|
||||
s.population,
|
||||
s.population_year,
|
||||
s.padel_venue_count,
|
||||
s.venues_per_100k,
|
||||
s.data_confidence,
|
||||
s.market_score,
|
||||
s.median_income_pps,
|
||||
s.income_year,
|
||||
s.median_hourly_rate,
|
||||
s.median_peak_rate,
|
||||
s.median_offpeak_rate,
|
||||
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
|
||||
@@ -1,86 +0,0 @@
|
||||
-- Per-location padel investment opportunity intelligence.
|
||||
-- Consumed by: Gemeinde-level pSEO pages, opportunity map, "top markets" lists.
|
||||
--
|
||||
-- Padelnomics Marktpotenzial-Score v2 (0–100):
|
||||
-- Answers "Where should I build a padel court?"
|
||||
-- Covers ALL GeoNames locations (pop ≥ 1K) — NOT filtered to existing padel markets.
|
||||
-- Zero-court locations score highest on supply gap component (white space = opportunity).
|
||||
--
|
||||
-- 25 pts addressable market — log-scaled population, ceiling 500K
|
||||
-- (opportunity peaks in mid-size cities; megacities already served)
|
||||
-- 20 pts economic power — country income PPS, normalised to 35,000
|
||||
-- EU PPS values range 18k-37k; /35k gives real spread.
|
||||
-- DE ≈ 13.2pts, ES ≈ 10.7pts, SE ≈ 14.3pts.
|
||||
-- Previously /200 caused all countries to saturate at 20/20.
|
||||
-- 30 pts supply gap — INVERTED venue density; 0 courts/100K = full marks.
|
||||
-- Ceiling raised to 8/100K (was 4) for a gentler gradient
|
||||
-- and to account for ~87% data undercount vs FIP totals.
|
||||
-- Linear: GREATEST(0, 1 - density/8)
|
||||
-- 15 pts catchment gap — distance to nearest padel court.
|
||||
-- DuckDB LEAST ignores NULLs: LEAST(1.0, NULL/30) = 1.0,
|
||||
-- so NULL nearest_km = full marks (no court in bounding box
|
||||
-- = high opportunity). COALESCE fallback is dead code.
|
||||
-- 10 pts sports culture — tennis courts within 25km (≥10 = full marks).
|
||||
-- NOTE: dim_locations tennis data is empty (all 0 rows).
|
||||
-- Component contributes 0 pts everywhere until data lands.
|
||||
|
||||
MODEL (
|
||||
name serving.location_opportunity_profile,
|
||||
kind FULL,
|
||||
cron '@daily',
|
||||
grain (country_code, geoname_id)
|
||||
);
|
||||
|
||||
SELECT
|
||||
l.geoname_id,
|
||||
l.country_code,
|
||||
l.country_name_en,
|
||||
l.country_slug,
|
||||
l.location_name,
|
||||
l.location_slug,
|
||||
l.lat,
|
||||
l.lon,
|
||||
l.admin1_code,
|
||||
l.admin2_code,
|
||||
l.population,
|
||||
l.population_year,
|
||||
l.median_income_pps,
|
||||
l.income_year,
|
||||
l.padel_venue_count,
|
||||
l.padel_venues_per_100k,
|
||||
l.nearest_padel_court_km,
|
||||
l.tennis_courts_within_25km,
|
||||
ROUND(
|
||||
-- Addressable market (25 pts): log-scaled to 500K ceiling.
|
||||
-- Lower ceiling than Marktreife (1M) — opportunity peaks in mid-size cities
|
||||
-- that can support a court but aren't already saturated by large-city operators.
|
||||
25.0 * LEAST(1.0, LN(GREATEST(l.population, 1)) / LN(500000))
|
||||
|
||||
-- Economic power (20 pts): country-level income PPS normalised to 35,000.
|
||||
-- Drives willingness-to-pay for court fees (€20-35/hr target range).
|
||||
-- EU PPS values range 18k-37k; ceiling 35k gives meaningful spread.
|
||||
-- v1 used /200 which caused LEAST(1.0, 115) = 1.0 for ALL countries (flat, no differentiation).
|
||||
-- v2: /35000 → DE 0.66×20=13.2pts, ES 0.53×20=10.7pts, SE 0.71×20=14.3pts.
|
||||
-- Default 15000 for missing data = reasonable developing-market assumption (~0.43).
|
||||
+ 20.0 * LEAST(1.0, COALESCE(l.median_income_pps, 15000) / 35000.0)
|
||||
|
||||
-- Supply gap (30 pts): INVERTED venue density.
|
||||
-- 0 courts/100K = full 30 pts (white space); ≥8/100K = 0 pts (served market).
|
||||
-- Ceiling raised from 4→8/100K for a gentler gradient and to account for data
|
||||
-- undercount (~87% of real courts not in our data).
|
||||
-- This is the key signal that separates Marktpotenzial from Marktreife.
|
||||
+ 30.0 * GREATEST(0.0, 1.0 - COALESCE(l.padel_venues_per_100k, 0) / 8.0)
|
||||
|
||||
-- Catchment gap (15 pts): distance to nearest existing padel court.
|
||||
-- >30km = full 15 pts (underserved catchment area).
|
||||
-- NULL = no courts found anywhere (rare edge case) → neutral 0.5.
|
||||
+ 15.0 * COALESCE(LEAST(1.0, l.nearest_padel_court_km / 30.0), 0.5)
|
||||
|
||||
-- Sports culture proxy (10 pts): tennis courts within 25km.
|
||||
-- ≥10 courts = full 10 pts (proven racket sport market = faster padel adoption).
|
||||
-- 0 courts = 0 pts. Many new padel courts open inside existing tennis clubs.
|
||||
+ 10.0 * LEAST(1.0, l.tennis_courts_within_25km / 10.0)
|
||||
, 1) AS opportunity_score,
|
||||
CURRENT_DATE AS refreshed_date
|
||||
FROM foundation.dim_locations l
|
||||
ORDER BY opportunity_score DESC
|
||||
@@ -0,0 +1,243 @@
|
||||
-- Unified location profile: both scores at (country_code, geoname_id) grain.
|
||||
-- Base: dim_locations (ALL GeoNames locations, pop ≥ 1K, ~140K rows).
|
||||
-- Enriched with dim_cities (city_slug, city_name, exact venue count) and
|
||||
-- venue_pricing_benchmarks (Playtomic pricing/occupancy).
|
||||
--
|
||||
-- Two scores per location:
|
||||
--
|
||||
-- Padelnomics Market Score (Marktreife-Score v3, 0–100):
|
||||
-- "How mature/established is this padel market?"
|
||||
-- Only meaningful for locations matched to a dim_cities row (city_slug IS NOT NULL)
|
||||
-- with padel venues. 0 for all other locations.
|
||||
--
|
||||
-- 40 pts supply development — log-scaled density (LN ceiling 20/100k) × count gate
|
||||
-- 25 pts demand evidence — occupancy when available; 40% density proxy otherwise
|
||||
-- 15 pts addressable market — log-scaled population, ceiling 1M
|
||||
-- 10 pts economic context — income PPS normalised to 200 ceiling
|
||||
-- 10 pts data quality — completeness discount
|
||||
--
|
||||
-- Padelnomics Opportunity Score (Marktpotenzial-Score v3, 0–100):
|
||||
-- "Where should I build a padel court?"
|
||||
-- Computed for ALL locations — zero-court locations score highest on supply gap.
|
||||
-- H3 catchment methodology: addressable market and supply gap use a regional
|
||||
-- H3 catchment (res-4 cell + 6 neighbours, ~462km², ~15-18km radius).
|
||||
--
|
||||
-- 25 pts addressable market — log-scaled catchment population, ceiling 500K
|
||||
-- 20 pts economic power — income PPS, normalised to 35,000
|
||||
-- 30 pts supply gap — inverted catchment venue density; 0 courts = full marks
|
||||
-- 15 pts catchment gap — distance to nearest padel court
|
||||
-- 10 pts sports culture — tennis courts within 25km
|
||||
--
|
||||
-- Consumers query directly with WHERE filters:
|
||||
-- cities API: WHERE country_slug = ? AND city_slug IS NOT NULL
|
||||
-- opportunity API: WHERE country_slug = ? AND opportunity_score > 0
|
||||
-- planner_defaults: WHERE city_slug IS NOT NULL
|
||||
-- pseo_*: WHERE city_slug IS NOT NULL AND city_padel_venue_count > 0
|
||||
|
||||
MODEL (
|
||||
name serving.location_profiles,
|
||||
kind FULL,
|
||||
cron '@daily',
|
||||
grain (country_code, geoname_id)
|
||||
);
|
||||
|
||||
WITH
|
||||
-- All locations from dim_locations (superset)
|
||||
base AS (
|
||||
SELECT
|
||||
l.geoname_id,
|
||||
l.country_code,
|
||||
l.country_name_en,
|
||||
l.country_slug,
|
||||
l.location_name,
|
||||
l.location_slug,
|
||||
l.lat,
|
||||
l.lon,
|
||||
l.admin1_code,
|
||||
l.admin2_code,
|
||||
l.population,
|
||||
l.population_year,
|
||||
l.median_income_pps,
|
||||
l.income_year,
|
||||
l.padel_venue_count,
|
||||
l.padel_venues_per_100k,
|
||||
l.nearest_padel_court_km,
|
||||
l.tennis_courts_within_25km,
|
||||
l.h3_cell_res4
|
||||
FROM foundation.dim_locations l
|
||||
),
|
||||
-- Aggregate population and court counts per H3 cell (res 4, ~10km edge).
|
||||
-- Grouping by cell first (~30-50K distinct cells vs 140K locations) keeps the
|
||||
-- subsequent lateral join small.
|
||||
hex_stats AS (
|
||||
SELECT
|
||||
h3_cell_res4,
|
||||
SUM(population) AS hex_population,
|
||||
SUM(padel_venue_count) AS hex_padel_courts
|
||||
FROM foundation.dim_locations
|
||||
GROUP BY h3_cell_res4
|
||||
),
|
||||
-- For each location, sum hex_stats across the cell + 6 neighbours (k_ring=1).
|
||||
-- Effective catchment: ~462km², ~15-18km radius — realistic driving distance.
|
||||
catchment AS (
|
||||
SELECT
|
||||
l.geoname_id,
|
||||
SUM(hs.hex_population) AS catchment_population,
|
||||
SUM(hs.hex_padel_courts) AS catchment_padel_courts
|
||||
FROM base l,
|
||||
LATERAL (SELECT UNNEST(h3_grid_disk(l.h3_cell_res4, 1)) AS cell) ring
|
||||
JOIN hex_stats hs ON hs.h3_cell_res4 = ring.cell
|
||||
GROUP BY l.geoname_id
|
||||
),
|
||||
-- Match dim_cities via (country_code, geoname_id) to get city_slug + exact venue count.
|
||||
-- QUALIFY handles rare multi-city-per-geoname collisions (keep highest venue count).
|
||||
city_match AS (
|
||||
SELECT
|
||||
c.country_code,
|
||||
c.geoname_id,
|
||||
c.city_slug,
|
||||
c.city_name,
|
||||
c.padel_venue_count AS city_padel_venue_count
|
||||
FROM foundation.dim_cities c
|
||||
WHERE c.geoname_id IS NOT NULL
|
||||
QUALIFY ROW_NUMBER() OVER (
|
||||
PARTITION BY c.country_code, c.geoname_id
|
||||
ORDER BY c.padel_venue_count DESC
|
||||
) = 1
|
||||
),
|
||||
-- Pricing / occupancy from Playtomic (via city_slug) + H3 catchment
|
||||
with_pricing AS (
|
||||
SELECT
|
||||
b.*,
|
||||
cm.city_slug,
|
||||
cm.city_name,
|
||||
cm.city_padel_venue_count,
|
||||
vpb.median_hourly_rate,
|
||||
vpb.median_peak_rate,
|
||||
vpb.median_offpeak_rate,
|
||||
vpb.median_occupancy_rate,
|
||||
vpb.median_daily_revenue_per_venue,
|
||||
vpb.price_currency,
|
||||
COALESCE(ct.catchment_population, b.population)::BIGINT AS catchment_population,
|
||||
COALESCE(ct.catchment_padel_courts, b.padel_venue_count)::INTEGER AS catchment_padel_courts
|
||||
FROM base b
|
||||
LEFT JOIN city_match cm
|
||||
ON b.country_code = cm.country_code
|
||||
AND b.geoname_id = cm.geoname_id
|
||||
LEFT JOIN serving.venue_pricing_benchmarks vpb
|
||||
ON cm.country_code = vpb.country_code
|
||||
AND cm.city_slug = vpb.city_slug
|
||||
LEFT JOIN catchment ct
|
||||
ON b.geoname_id = ct.geoname_id
|
||||
),
|
||||
-- Both scores computed from the enriched base
|
||||
scored AS (
|
||||
SELECT *,
|
||||
-- City-level venue density (from dim_cities exact count, not dim_locations spatial 5km)
|
||||
CASE WHEN population > 0
|
||||
THEN ROUND(COALESCE(city_padel_venue_count, 0)::DOUBLE / population * 100000, 2)
|
||||
ELSE NULL
|
||||
END AS city_venues_per_100k,
|
||||
-- Data confidence (for market_score)
|
||||
CASE
|
||||
WHEN population > 0 AND COALESCE(city_padel_venue_count, 0) > 0 THEN 1.0
|
||||
WHEN population > 0 OR COALESCE(city_padel_venue_count, 0) > 0 THEN 0.5
|
||||
ELSE 0.0
|
||||
END AS data_confidence,
|
||||
-- ── Market Score (Marktreife-Score v3) ──────────────────────────────────
|
||||
-- 0 when no city match or no venues (city_padel_venue_count NULL or 0)
|
||||
CASE WHEN COALESCE(city_padel_venue_count, 0) > 0 THEN
|
||||
ROUND(
|
||||
-- Supply development (40 pts)
|
||||
40.0 * LEAST(1.0, LN(
|
||||
COALESCE(
|
||||
CASE WHEN population > 0
|
||||
THEN COALESCE(city_padel_venue_count, 0)::DOUBLE / population * 100000
|
||||
ELSE 0 END
|
||||
, 0) + 1) / LN(21))
|
||||
* LEAST(1.0, COALESCE(city_padel_venue_count, 0) / 5.0)
|
||||
-- Demand evidence (25 pts)
|
||||
+ 25.0 * CASE
|
||||
WHEN median_occupancy_rate IS NOT NULL
|
||||
THEN LEAST(1.0, median_occupancy_rate / 0.65)
|
||||
ELSE 0.4 * LEAST(1.0, LN(
|
||||
COALESCE(
|
||||
CASE WHEN population > 0
|
||||
THEN COALESCE(city_padel_venue_count, 0)::DOUBLE / population * 100000
|
||||
ELSE 0 END
|
||||
, 0) + 1) / LN(21))
|
||||
* LEAST(1.0, COALESCE(city_padel_venue_count, 0) / 5.0)
|
||||
END
|
||||
-- Addressable market (15 pts)
|
||||
+ 15.0 * LEAST(1.0, LN(GREATEST(population, 1)) / LN(1000000))
|
||||
-- Economic context (10 pts)
|
||||
+ 10.0 * LEAST(1.0, COALESCE(median_income_pps, 100) / 200.0)
|
||||
-- Data quality (10 pts)
|
||||
+ 10.0 * CASE
|
||||
WHEN population > 0 AND COALESCE(city_padel_venue_count, 0) > 0 THEN 1.0
|
||||
WHEN population > 0 OR COALESCE(city_padel_venue_count, 0) > 0 THEN 0.5
|
||||
ELSE 0.0
|
||||
END
|
||||
, 1)
|
||||
ELSE 0
|
||||
END AS market_score,
|
||||
-- ── Opportunity Score (Marktpotenzial-Score v3, H3 catchment) ──────────
|
||||
ROUND(
|
||||
-- Addressable market (25 pts): log-scaled catchment population, ceiling 500K
|
||||
25.0 * LEAST(1.0, LN(GREATEST(catchment_population, 1)) / LN(500000))
|
||||
-- Economic power (20 pts): income PPS normalised to 35,000
|
||||
+ 20.0 * LEAST(1.0, COALESCE(median_income_pps, 15000) / 35000.0)
|
||||
-- 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
|
||||
ELSE 0.0
|
||||
END, 0.0) / 8.0)
|
||||
-- Catchment gap (15 pts): distance to nearest court
|
||||
+ 15.0 * COALESCE(LEAST(1.0, nearest_padel_court_km / 30.0), 0.5)
|
||||
-- Sports culture (10 pts): tennis courts within 25km
|
||||
+ 10.0 * LEAST(1.0, tennis_courts_within_25km / 10.0)
|
||||
, 1) AS opportunity_score
|
||||
FROM with_pricing
|
||||
)
|
||||
SELECT
|
||||
s.geoname_id,
|
||||
s.country_code,
|
||||
s.country_name_en,
|
||||
s.country_slug,
|
||||
s.location_name,
|
||||
s.location_slug,
|
||||
s.city_slug,
|
||||
s.city_name,
|
||||
s.lat,
|
||||
s.lon,
|
||||
s.admin1_code,
|
||||
s.admin2_code,
|
||||
s.population,
|
||||
s.population_year,
|
||||
s.median_income_pps,
|
||||
s.income_year,
|
||||
s.padel_venue_count,
|
||||
s.padel_venues_per_100k,
|
||||
s.nearest_padel_court_km,
|
||||
s.tennis_courts_within_25km,
|
||||
s.city_padel_venue_count,
|
||||
s.city_venues_per_100k,
|
||||
s.data_confidence,
|
||||
s.catchment_population,
|
||||
s.catchment_padel_courts,
|
||||
CASE WHEN s.catchment_population > 0
|
||||
THEN ROUND(s.catchment_padel_courts::DOUBLE / s.catchment_population * 100000, 2)
|
||||
ELSE NULL
|
||||
END AS catchment_venues_per_100k,
|
||||
s.market_score,
|
||||
s.opportunity_score,
|
||||
s.median_hourly_rate,
|
||||
s.median_peak_rate,
|
||||
s.median_offpeak_rate,
|
||||
s.median_occupancy_rate,
|
||||
s.median_daily_revenue_per_venue,
|
||||
s.price_currency,
|
||||
CURRENT_DATE AS refreshed_date
|
||||
FROM scored s
|
||||
ORDER BY s.market_score DESC, s.opportunity_score DESC
|
||||
@@ -76,11 +76,12 @@ city_profiles AS (
|
||||
city_slug,
|
||||
country_code,
|
||||
city_name,
|
||||
padel_venue_count,
|
||||
city_padel_venue_count AS padel_venue_count,
|
||||
population,
|
||||
market_score,
|
||||
venues_per_100k
|
||||
FROM serving.city_market_profile
|
||||
city_venues_per_100k AS venues_per_100k
|
||||
FROM serving.location_profiles
|
||||
WHERE city_slug IS NOT NULL
|
||||
)
|
||||
SELECT
|
||||
cp.city_slug,
|
||||
|
||||
@@ -31,10 +31,10 @@ SELECT
|
||||
c.lon,
|
||||
-- Market metrics
|
||||
c.population,
|
||||
c.padel_venue_count,
|
||||
c.venues_per_100k,
|
||||
c.city_padel_venue_count AS padel_venue_count,
|
||||
c.city_venues_per_100k AS venues_per_100k,
|
||||
c.market_score,
|
||||
lop.opportunity_score,
|
||||
c.opportunity_score,
|
||||
c.data_confidence,
|
||||
-- Pricing (from Playtomic, NULL when no coverage)
|
||||
c.median_hourly_rate,
|
||||
@@ -85,15 +85,13 @@ SELECT
|
||||
cc.working_capital AS "workingCapital",
|
||||
cc.permits_compliance AS "permitsCompliance",
|
||||
CURRENT_DATE AS refreshed_date
|
||||
FROM serving.city_market_profile c
|
||||
FROM serving.location_profiles 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
|
||||
LEFT JOIN foundation.dim_countries cc
|
||||
ON c.country_code = cc.country_code
|
||||
-- Only cities with actual padel presence and at least some rate data
|
||||
WHERE c.padel_venue_count > 0
|
||||
WHERE c.city_slug IS NOT NULL
|
||||
AND c.city_padel_venue_count > 0
|
||||
AND (p.rate_peak IS NOT NULL OR c.median_peak_rate IS NOT NULL)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
-- pSEO article data: per-city padel court pricing.
|
||||
-- One row per city — consumed by the city-pricing.md.jinja template.
|
||||
-- Joins venue_pricing_benchmarks (real Playtomic data) with city_market_profile
|
||||
-- Joins venue_pricing_benchmarks (real Playtomic data) with location_profiles
|
||||
-- (population, venue count, country metadata).
|
||||
--
|
||||
-- Stricter filter than pseo_city_costs_de: requires >= 2 venues with real
|
||||
@@ -16,7 +16,7 @@ MODEL (
|
||||
SELECT
|
||||
-- Composite natural key: country_slug + city_slug ensures uniqueness across countries
|
||||
c.country_slug || '-' || c.city_slug AS city_key,
|
||||
-- City identity (from city_market_profile, which has the canonical city_slug)
|
||||
-- City identity (from location_profiles, which has the canonical city_slug)
|
||||
c.city_slug,
|
||||
c.city_name,
|
||||
c.country_code,
|
||||
@@ -24,8 +24,8 @@ SELECT
|
||||
c.country_slug,
|
||||
-- Market context
|
||||
c.population,
|
||||
c.padel_venue_count,
|
||||
c.venues_per_100k,
|
||||
c.city_padel_venue_count AS padel_venue_count,
|
||||
c.city_venues_per_100k AS venues_per_100k,
|
||||
c.market_score,
|
||||
-- Pricing benchmarks (from Playtomic availability data)
|
||||
vpb.median_hourly_rate,
|
||||
@@ -38,9 +38,10 @@ SELECT
|
||||
vpb.price_currency,
|
||||
CURRENT_DATE AS refreshed_date
|
||||
FROM serving.venue_pricing_benchmarks vpb
|
||||
-- Join city_market_profile to get the canonical city_slug and country metadata
|
||||
INNER JOIN serving.city_market_profile c
|
||||
-- Join location_profiles to get canonical city metadata
|
||||
INNER JOIN serving.location_profiles c
|
||||
ON vpb.country_code = c.country_code
|
||||
AND vpb.city_slug = c.city_slug
|
||||
AND c.city_slug IS NOT NULL
|
||||
-- Only cities with enough venues for meaningful pricing statistics
|
||||
WHERE vpb.venue_count >= 2
|
||||
|
||||
@@ -27,7 +27,7 @@ WITH venue_stats AS (
|
||||
MAX(da.active_court_count) AS court_count,
|
||||
COUNT(DISTINCT da.snapshot_date) AS days_observed
|
||||
FROM foundation.fct_daily_availability da
|
||||
WHERE TRY_CAST(da.snapshot_date AS DATE) >= CURRENT_DATE - INTERVAL '30 days'
|
||||
WHERE da.snapshot_date >= CURRENT_DATE - INTERVAL '30 days'
|
||||
AND da.occupancy_rate IS NOT NULL
|
||||
AND da.occupancy_rate BETWEEN 0 AND 1.5
|
||||
GROUP BY da.tenant_id, da.country_code, da.city, da.city_slug, da.price_currency
|
||||
|
||||
@@ -13,44 +13,28 @@
|
||||
|
||||
MODEL (
|
||||
name staging.stg_playtomic_availability,
|
||||
kind FULL,
|
||||
kind INCREMENTAL_BY_TIME_RANGE (
|
||||
time_column snapshot_date
|
||||
),
|
||||
start '2026-03-01',
|
||||
cron '@daily',
|
||||
grain (snapshot_date, tenant_id, resource_id, slot_start_time, snapshot_type, captured_at_utc)
|
||||
);
|
||||
|
||||
WITH
|
||||
morning_jsonl AS (
|
||||
all_jsonl AS (
|
||||
SELECT
|
||||
date AS snapshot_date,
|
||||
CAST(date AS DATE) AS snapshot_date,
|
||||
captured_at_utc,
|
||||
'morning' AS snapshot_type,
|
||||
NULL::INTEGER AS recheck_hour,
|
||||
tenant_id,
|
||||
slots AS slots_json
|
||||
FROM read_json(
|
||||
@LANDING_DIR || '/playtomic/*/*/availability_*.jsonl.gz',
|
||||
format = 'newline_delimited',
|
||||
columns = {
|
||||
date: 'VARCHAR',
|
||||
captured_at_utc: 'VARCHAR',
|
||||
tenant_id: 'VARCHAR',
|
||||
slots: 'JSON'
|
||||
},
|
||||
filename = true
|
||||
)
|
||||
WHERE filename NOT LIKE '%_recheck_%'
|
||||
AND tenant_id IS NOT NULL
|
||||
),
|
||||
recheck_jsonl AS (
|
||||
SELECT
|
||||
date AS snapshot_date,
|
||||
captured_at_utc,
|
||||
'recheck' AS snapshot_type,
|
||||
CASE
|
||||
WHEN filename LIKE '%_recheck_%' THEN 'recheck'
|
||||
ELSE 'morning'
|
||||
END AS snapshot_type,
|
||||
TRY_CAST(recheck_hour AS INTEGER) AS recheck_hour,
|
||||
tenant_id,
|
||||
slots AS slots_json
|
||||
FROM read_json(
|
||||
@LANDING_DIR || '/playtomic/*/*/availability_*_recheck_*.jsonl.gz',
|
||||
@LANDING_DIR || '/playtomic/*/*/availability_' || @start_ds || '*.jsonl.gz',
|
||||
format = 'newline_delimited',
|
||||
columns = {
|
||||
date: 'VARCHAR',
|
||||
@@ -63,11 +47,6 @@ recheck_jsonl AS (
|
||||
)
|
||||
WHERE tenant_id IS NOT NULL
|
||||
),
|
||||
all_venues AS (
|
||||
SELECT * FROM morning_jsonl
|
||||
UNION ALL
|
||||
SELECT * FROM recheck_jsonl
|
||||
),
|
||||
raw_resources AS (
|
||||
SELECT
|
||||
av.snapshot_date,
|
||||
@@ -76,7 +55,7 @@ raw_resources AS (
|
||||
av.recheck_hour,
|
||||
av.tenant_id,
|
||||
resource_json
|
||||
FROM all_venues av,
|
||||
FROM all_jsonl av,
|
||||
LATERAL UNNEST(
|
||||
from_json(av.slots_json, '["JSON"]')
|
||||
) AS t(resource_json)
|
||||
|
||||
@@ -165,7 +165,7 @@ echo ""
|
||||
echo "Press Ctrl-C to stop all processes."
|
||||
echo ""
|
||||
|
||||
run_with_label "$COLOR_APP" "app " uv run granian --interface asgi --host 127.0.0.1 --port 5000 --reload --reload-paths web/src padelnomics.app:app
|
||||
run_with_label "$COLOR_APP" "app " uv run python -m padelnomics.app
|
||||
run_with_label "$COLOR_WORKER" "worker" uv run python -u -m padelnomics.worker
|
||||
run_with_label "$COLOR_CSS" "css " make css-watch
|
||||
|
||||
|
||||
@@ -51,8 +51,10 @@ bp = Blueprint(
|
||||
_LANDING_DIR = os.environ.get("LANDING_DIR", "data/landing")
|
||||
_SERVING_DUCKDB_PATH = os.environ.get("SERVING_DUCKDB_PATH", "data/analytics.duckdb")
|
||||
|
||||
# Repo root: web/src/padelnomics/admin/ → up 4 levels
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
# In prod the package is installed in a venv so __file__.parents[4] won't
|
||||
# reach the repo checkout. WorkingDirectory in the systemd unit is /opt/padelnomics,
|
||||
# so CWD is reliable; REPO_ROOT env var overrides for non-standard setups.
|
||||
_REPO_ROOT = Path(os.environ.get("REPO_ROOT", ".")).resolve()
|
||||
_WORKFLOWS_TOML = _REPO_ROOT / "infra" / "supervisor" / "workflows.toml"
|
||||
|
||||
# A "running" row older than this is considered stale/crashed.
|
||||
@@ -109,13 +111,12 @@ _DAG: dict[str, list[str]] = {
|
||||
"fct_daily_availability": ["fct_availability_slot", "dim_venue_capacity"],
|
||||
# Serving
|
||||
"venue_pricing_benchmarks": ["fct_daily_availability"],
|
||||
"city_market_profile": ["dim_cities", "venue_pricing_benchmarks"],
|
||||
"planner_defaults": ["venue_pricing_benchmarks", "city_market_profile"],
|
||||
"location_opportunity_profile": ["dim_locations"],
|
||||
"location_profiles": ["dim_locations", "dim_cities", "venue_pricing_benchmarks"],
|
||||
"planner_defaults": ["venue_pricing_benchmarks", "location_profiles"],
|
||||
"pseo_city_costs_de": [
|
||||
"city_market_profile", "planner_defaults", "location_opportunity_profile",
|
||||
"location_profiles", "planner_defaults",
|
||||
],
|
||||
"pseo_city_pricing": ["venue_pricing_benchmarks", "city_market_profile"],
|
||||
"pseo_city_pricing": ["venue_pricing_benchmarks", "location_profiles"],
|
||||
"pseo_country_overview": ["pseo_city_costs_de"],
|
||||
}
|
||||
|
||||
@@ -538,6 +539,7 @@ def _load_workflows() -> list[dict]:
|
||||
"schedule": schedule,
|
||||
"schedule_label": schedule_label,
|
||||
"depends_on": config.get("depends_on", []),
|
||||
"description": config.get("description", ""),
|
||||
})
|
||||
return workflows
|
||||
|
||||
|
||||
@@ -2465,6 +2465,18 @@ async def articles():
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/articles/stats")
|
||||
@role_required("admin")
|
||||
async def article_stats():
|
||||
"""HTMX partial: article stats bar (polled while generating)."""
|
||||
stats = await _get_article_stats()
|
||||
return await render_template(
|
||||
"admin/partials/article_stats.html",
|
||||
stats=stats,
|
||||
is_generating=await _is_generating(),
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/articles/results")
|
||||
@role_required("admin")
|
||||
async def article_results():
|
||||
@@ -2736,13 +2748,13 @@ async def article_edit(article_id: int):
|
||||
body = raw[m.end():].lstrip("\n") if m else raw
|
||||
|
||||
body_html = mistune.html(body) if body else ""
|
||||
css_url = url_for("static", filename="css/output.css")
|
||||
preview_doc = (
|
||||
f"<!doctype html><html><head>"
|
||||
f"<link rel='stylesheet' href='{css_url}'>"
|
||||
f"<style>html,body{{margin:0;padding:0}}body{{padding:2rem 2.5rem}}</style>"
|
||||
f"</head><body><div class='article-body'>{body_html}</div></body></html>"
|
||||
) if body_html else ""
|
||||
await render_template(
|
||||
"admin/partials/article_preview_doc.html", body_html=body_html
|
||||
)
|
||||
if body_html
|
||||
else ""
|
||||
)
|
||||
|
||||
data = {**dict(article), "body": body}
|
||||
return await render_template(
|
||||
@@ -2764,13 +2776,13 @@ async def article_preview():
|
||||
m = _FRONTMATTER_RE.match(body)
|
||||
body = body[m.end():].lstrip("\n") if m else body
|
||||
body_html = mistune.html(body) if body else ""
|
||||
css_url = url_for("static", filename="css/output.css")
|
||||
preview_doc = (
|
||||
f"<!doctype html><html><head>"
|
||||
f"<link rel='stylesheet' href='{css_url}'>"
|
||||
f"<style>html,body{{margin:0;padding:0}}body{{padding:2rem 2.5rem}}</style>"
|
||||
f"</head><body><div class='article-body'>{body_html}</div></body></html>"
|
||||
) if body_html else ""
|
||||
await render_template(
|
||||
"admin/partials/article_preview_doc.html", body_html=body_html
|
||||
)
|
||||
if body_html
|
||||
else ""
|
||||
)
|
||||
return await render_template("admin/partials/article_preview.html", preview_doc=preview_doc)
|
||||
|
||||
|
||||
|
||||
@@ -384,7 +384,7 @@
|
||||
<iframe
|
||||
srcdoc="{{ preview_doc | e }}"
|
||||
style="flex:1;width:100%;border:none;display:block;"
|
||||
sandbox="allow-same-origin"
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
title="Article preview"
|
||||
></iframe>
|
||||
{% else %}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<iframe
|
||||
srcdoc="{{ preview_doc | e }}"
|
||||
style="flex:1;width:100%;border:none;display:block;"
|
||||
sandbox="allow-same-origin"
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
title="Article preview"
|
||||
></iframe>
|
||||
{% else %}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{# Standalone HTML document used as iframe srcdoc for the article editor preview.
|
||||
Includes Leaflet so map shortcodes render correctly. #}
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/output.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.min.css') }}">
|
||||
<style>html,body{margin:0;padding:0}body{padding:2rem 2.5rem}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="article-body">{{ body_html | safe }}</div>
|
||||
<script>window.LEAFLET_JS_URL = '{{ url_for("static", filename="vendor/leaflet/leaflet.min.js") }}';</script>
|
||||
<script src="{{ url_for('static', filename='js/article-maps.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -16,8 +16,9 @@
|
||||
{% set wf = row.workflow %}
|
||||
{% set run = row.run %}
|
||||
{% set stale = row.stale %}
|
||||
<div style="border:1px solid #E2E8F0;border-radius:10px;padding:0.875rem;background:#FAFAFA">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
{% set is_running = run and run.status == 'running' and not stale %}
|
||||
<div style="border:1px solid {% if is_running %}#93C5FD{% else %}#E2E8F0{% endif %};border-radius:10px;padding:0.875rem;background:{% if is_running %}#EFF6FF{% else %}#FAFAFA{% endif %}">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
{% if not run %}
|
||||
<span class="status-dot pending"></span>
|
||||
{% elif stale %}
|
||||
@@ -33,6 +34,15 @@
|
||||
{% if stale %}
|
||||
<span class="badge-warning" style="font-size:10px;padding:1px 6px;margin-left:auto">stale</span>
|
||||
{% endif %}
|
||||
{% if is_running %}
|
||||
<span class="btn btn-sm ml-auto"
|
||||
style="padding:2px 8px;font-size:11px;opacity:0.6;cursor:default;pointer-events:none">
|
||||
<svg class="spinner-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<path d="M12 2a10 10 0 0 1 10 10" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Running
|
||||
</span>
|
||||
{% else %}
|
||||
<button type="button"
|
||||
class="btn btn-sm ml-auto"
|
||||
style="padding:2px 8px;font-size:11px"
|
||||
@@ -41,9 +51,17 @@
|
||||
hx-swap="outerHTML"
|
||||
hx-vals='{"extractor": "{{ wf.name }}", "csrf_token": "{{ csrf_token() }}"}'
|
||||
hx-confirm="Run {{ wf.name }} extractor?">Run</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if wf.description %}
|
||||
<p class="text-xs text-slate" style="margin-top:2px;margin-bottom:4px">{{ wf.description }}</p>
|
||||
{% endif %}
|
||||
<p class="text-xs text-slate">{{ wf.schedule_label }}</p>
|
||||
{% if run %}
|
||||
{% if is_running %}
|
||||
<p class="text-xs mt-1" style="color:#2563EB">
|
||||
Started {{ run.started_at[:16].replace('T', ' ') if run.started_at else '—' }} — running...
|
||||
</p>
|
||||
{% elif run %}
|
||||
<p class="text-xs mono text-slate-dark mt-1">{{ run.started_at[:16].replace('T', ' ') if run.started_at else '—' }}</p>
|
||||
{% if run.status == 'failed' and run.error_message %}
|
||||
<p class="text-xs text-danger mt-1" style="font-family:monospace;word-break:break-all">
|
||||
|
||||
@@ -171,7 +171,7 @@
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
placeholder="-- SELECT * FROM serving.city_market_profile -- WHERE country_code = 'DE' -- ORDER BY marktreife_score DESC -- LIMIT 20"
|
||||
placeholder="-- SELECT * FROM serving.location_profiles -- WHERE country_code = 'DE' AND city_slug IS NOT NULL -- ORDER BY market_score DESC -- LIMIT 20"
|
||||
></textarea>
|
||||
|
||||
<div class="query-controls">
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
|
||||
{% block title %}Preview - {{ preview.title }} - Admin{% endblock %}
|
||||
|
||||
{% block head %}{{ super() }}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.min.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<a href="{{ url_for('admin.template_detail', slug=config.slug) }}" class="text-sm text-slate">← Back to template</a>
|
||||
|
||||
@@ -21,11 +25,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Rendered article #}
|
||||
<div class="card">
|
||||
{# Rendered article — overflow:visible needed so Leaflet tile layers render #}
|
||||
<div class="card" style="overflow: visible;">
|
||||
<h2 class="text-lg mb-4">Rendered HTML</h2>
|
||||
<div class="prose" style="max-width: none;">
|
||||
<div class="article-body" style="max-width: none;">
|
||||
{{ preview.html | safe }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>window.LEAFLET_JS_URL = '{{ url_for("static", filename="vendor/leaflet/leaflet.min.js") }}';</script>
|
||||
<script src="{{ url_for('static', filename='js/article-maps.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -13,7 +13,7 @@ Usage:
|
||||
|
||||
rows = await fetch_analytics("SELECT * FROM serving.planner_defaults WHERE city_slug = ?", ["berlin"])
|
||||
|
||||
cols, rows, error, elapsed_ms = await execute_user_query("SELECT city_slug FROM serving.city_market_profile LIMIT 5")
|
||||
cols, rows, error, elapsed_ms = await execute_user_query("SELECT city_slug FROM serving.location_profiles LIMIT 5")
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
@@ -8,7 +8,7 @@ daily when the pipeline runs).
|
||||
from quart import Blueprint, abort, jsonify
|
||||
|
||||
from .analytics import fetch_analytics
|
||||
from .core import is_flag_enabled
|
||||
from .core import fetch_all, is_flag_enabled
|
||||
|
||||
bp = Blueprint("api", __name__)
|
||||
|
||||
@@ -32,12 +32,14 @@ async def countries():
|
||||
rows = await fetch_analytics("""
|
||||
SELECT country_code, country_name_en, country_slug,
|
||||
COUNT(*) AS city_count,
|
||||
SUM(padel_venue_count) AS total_venues,
|
||||
SUM(city_padel_venue_count) AS total_venues,
|
||||
ROUND(AVG(market_score), 1) AS avg_market_score,
|
||||
ROUND(AVG(opportunity_score), 1) AS avg_opportunity_score,
|
||||
AVG(lat) AS lat, AVG(lon) AS lon
|
||||
FROM serving.city_market_profile
|
||||
FROM serving.location_profiles
|
||||
WHERE city_slug IS NOT NULL
|
||||
GROUP BY country_code, country_name_en, country_slug
|
||||
HAVING SUM(padel_venue_count) > 0
|
||||
HAVING SUM(city_padel_venue_count) > 0
|
||||
ORDER BY total_venues DESC
|
||||
""")
|
||||
return jsonify(rows), 200, _CACHE_HEADERS
|
||||
@@ -51,14 +53,29 @@ async def country_cities(country_slug: str):
|
||||
rows = await fetch_analytics(
|
||||
"""
|
||||
SELECT city_name, city_slug, lat, lon,
|
||||
padel_venue_count, market_score, population
|
||||
FROM serving.city_market_profile
|
||||
WHERE country_slug = ?
|
||||
ORDER BY padel_venue_count DESC
|
||||
city_padel_venue_count AS padel_venue_count,
|
||||
market_score, opportunity_score, population
|
||||
FROM serving.location_profiles
|
||||
WHERE country_slug = ? AND city_slug IS NOT NULL
|
||||
ORDER BY city_padel_venue_count DESC
|
||||
LIMIT 200
|
||||
""",
|
||||
[country_slug],
|
||||
)
|
||||
# Check which cities have published articles (any language).
|
||||
article_rows = await fetch_all(
|
||||
"""SELECT url_path FROM articles
|
||||
WHERE url_path LIKE ? AND status = 'published'
|
||||
AND published_at <= datetime('now')""",
|
||||
(f"/markets/{country_slug}/%",),
|
||||
)
|
||||
article_slugs = set()
|
||||
for a in article_rows:
|
||||
parts = a["url_path"].rstrip("/").split("/")
|
||||
if len(parts) >= 4:
|
||||
article_slugs.add(parts[3])
|
||||
for row in rows:
|
||||
row["has_article"] = row["city_slug"] in article_slugs
|
||||
return jsonify(rows), 200, _CACHE_HEADERS
|
||||
|
||||
|
||||
@@ -88,9 +105,10 @@ async def opportunity(country_slug: str):
|
||||
rows = await fetch_analytics(
|
||||
"""
|
||||
SELECT location_name, location_slug, lat, lon,
|
||||
opportunity_score, nearest_padel_court_km,
|
||||
opportunity_score, market_score,
|
||||
nearest_padel_court_km,
|
||||
padel_venue_count, population
|
||||
FROM serving.location_opportunity_profile
|
||||
FROM serving.location_profiles
|
||||
WHERE country_slug = ? AND opportunity_score > 0
|
||||
ORDER BY opportunity_score DESC
|
||||
LIMIT 500
|
||||
|
||||
@@ -5,7 +5,7 @@ import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from quart import Quart, Response, abort, g, redirect, request, session, url_for
|
||||
from quart import Quart, Response, abort, g, redirect, render_template, request, session, url_for
|
||||
|
||||
from .analytics import close_analytics_db, open_analytics_db
|
||||
from .core import (
|
||||
@@ -270,6 +270,40 @@ def create_app() -> Quart:
|
||||
from .sitemap import sitemap_response
|
||||
return await sitemap_response(config.BASE_URL)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Error pages
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _error_lang() -> str:
|
||||
"""Best-effort language from URL path prefix (no g.lang in error handlers)."""
|
||||
path = request.path
|
||||
if path.startswith("/de/"):
|
||||
return "de"
|
||||
return "en"
|
||||
|
||||
@app.errorhandler(404)
|
||||
async def handle_404(error):
|
||||
import re
|
||||
lang = _error_lang()
|
||||
t = get_translations(lang)
|
||||
country_slug = None
|
||||
country_name = None
|
||||
m = re.match(r"^/(?:en|de)/markets/([^/]+)/[^/]+/?$", request.path)
|
||||
if m:
|
||||
country_slug = m.group(1)
|
||||
country_name = country_slug.replace("-", " ").title()
|
||||
return await render_template(
|
||||
"404.html", lang=lang, t=t, country_slug=country_slug,
|
||||
country_name=country_name or "",
|
||||
), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
async def handle_500(error):
|
||||
app.logger.exception("Unhandled 500 error: %s", error)
|
||||
lang = _error_lang()
|
||||
t = get_translations(lang)
|
||||
return await render_template("500.html", lang=lang, t=t), 500
|
||||
|
||||
# Health check
|
||||
@app.route("/health")
|
||||
async def health():
|
||||
|
||||
@@ -60,106 +60,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function() {
|
||||
var countryMapEl = document.getElementById('country-map');
|
||||
var cityMapEl = document.getElementById('city-map');
|
||||
if (!countryMapEl && !cityMapEl) return;
|
||||
|
||||
var TILES = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
|
||||
var TILES_ATTR = '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>';
|
||||
|
||||
function scoreColor(score) {
|
||||
if (score >= 60) return '#16A34A';
|
||||
if (score >= 30) return '#D97706';
|
||||
return '#DC2626';
|
||||
}
|
||||
|
||||
function makeIcon(size, color) {
|
||||
var s = Math.round(size);
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
html: '<div class="pn-marker" style="width:' + s + 'px;height:' + s + 'px;background:' + color + ';opacity:0.82;"></div>',
|
||||
iconSize: [s, s],
|
||||
iconAnchor: [s / 2, s / 2],
|
||||
});
|
||||
}
|
||||
|
||||
function initCountryMap(el) {
|
||||
var slug = el.dataset.countrySlug;
|
||||
var map = L.map(el, {scrollWheelZoom: false});
|
||||
L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
|
||||
var lang = document.documentElement.lang || 'en';
|
||||
fetch('/api/markets/' + slug + '/cities.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.padel_venue_count || 1; }));
|
||||
var bounds = [];
|
||||
data.forEach(function(c) {
|
||||
if (!c.lat || !c.lon) return;
|
||||
var size = 10 + 36 * Math.sqrt((c.padel_venue_count || 1) / maxV);
|
||||
var color = scoreColor(c.market_score);
|
||||
var pop = c.population >= 1000000
|
||||
? (c.population / 1000000).toFixed(1) + 'M'
|
||||
: (c.population >= 1000 ? Math.round(c.population / 1000) + 'K' : (c.population || ''));
|
||||
var tip = '<strong>' + c.city_name + '</strong><br>'
|
||||
+ (c.padel_venue_count || 0) + ' venues'
|
||||
+ (pop ? ' · ' + pop : '') + '<br>'
|
||||
+ '<span style="color:' + color + ';font-weight:600;">Score ' + Math.round(c.market_score) + '/100</span>';
|
||||
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/' + slug + '/' + c.city_slug; })
|
||||
.addTo(map);
|
||||
bounds.push([c.lat, c.lon]);
|
||||
});
|
||||
if (bounds.length) map.fitBounds(bounds, { padding: [24, 24] });
|
||||
});
|
||||
}
|
||||
|
||||
var VENUE_ICON = L.divIcon({
|
||||
className: '',
|
||||
html: '<div class="pn-venue"></div>',
|
||||
iconSize: [10, 10],
|
||||
iconAnchor: [5, 5],
|
||||
});
|
||||
|
||||
function initCityMap(el) {
|
||||
var countrySlug = el.dataset.countrySlug;
|
||||
var citySlug = el.dataset.citySlug;
|
||||
var lat = parseFloat(el.dataset.lat);
|
||||
var lon = parseFloat(el.dataset.lon);
|
||||
var map = L.map(el, {scrollWheelZoom: false}).setView([lat, lon], 13);
|
||||
L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
|
||||
fetch('/api/markets/' + countrySlug + '/' + citySlug + '/venues.json')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
data.forEach(function(v) {
|
||||
if (!v.lat || !v.lon) return;
|
||||
var indoor = v.indoor_court_count || 0;
|
||||
var outdoor = v.outdoor_court_count || 0;
|
||||
var total = v.court_count || (indoor + outdoor);
|
||||
var courtLine = total
|
||||
? total + ' court' + (total > 1 ? 's' : '')
|
||||
+ (indoor || outdoor
|
||||
? ' (' + [indoor ? indoor + ' indoor' : '', outdoor ? outdoor + ' outdoor' : ''].filter(Boolean).join(', ') + ')'
|
||||
: '')
|
||||
: '';
|
||||
var tip = '<strong>' + v.name + '</strong>' + (courtLine ? '<br>' + courtLine : '');
|
||||
L.marker([v.lat, v.lon], { icon: VENUE_ICON })
|
||||
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -7] })
|
||||
.addTo(map);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var script = document.createElement('script');
|
||||
script.src = '{{ url_for("static", filename="vendor/leaflet/leaflet.min.js") }}';
|
||||
script.onload = function() {
|
||||
if (countryMapEl) initCountryMap(countryMapEl);
|
||||
if (cityMapEl) initCityMap(cityMapEl);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
})();
|
||||
</script>
|
||||
<script>window.LEAFLET_JS_URL = '{{ url_for("static", filename="vendor/leaflet/leaflet.min.js") }}';</script>
|
||||
<script src="{{ url_for('static', filename='js/article-maps.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -102,9 +102,11 @@
|
||||
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 = '<strong>' + c.country_name_en + '</strong><br>'
|
||||
+ c.total_venues + ' venues · ' + c.city_count + ' cities<br>'
|
||||
+ '<span style="color:' + color + ';font-weight:600;">Score ' + c.avg_market_score + '/100</span>';
|
||||
+ '<span style="color:' + color + ';font-weight:600;">Padelnomics Market Score: ' + c.avg_market_score + '/100</span><br>'
|
||||
+ '<span style="color:' + oppColor + ';font-weight:600;">Padelnomics Opportunity Score: ' + (c.avg_opportunity_score || 0) + '/100</span>';
|
||||
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; })
|
||||
|
||||
@@ -1825,5 +1825,16 @@
|
||||
"affiliate_pros_label": "Vorteile",
|
||||
"affiliate_cons_label": "Nachteile",
|
||||
"affiliate_at_retailer": "bei {retailer}",
|
||||
"affiliate_our_picks": "Unsere Empfehlungen"
|
||||
"affiliate_our_picks": "Unsere Empfehlungen",
|
||||
|
||||
"error_404_title": "Seite nicht gefunden",
|
||||
"error_404_heading": "Diese Seite gibt es nicht",
|
||||
"error_404_message": "Die gesuchte Seite wurde verschoben oder existiert noch nicht.",
|
||||
"error_404_city_message": "Die Marktanalyse für diese Stadt ist noch nicht verfügbar.",
|
||||
"error_404_back_home": "Zur Startseite",
|
||||
"error_404_back_country": "Zurück zur {country}-Übersicht",
|
||||
"error_500_title": "Etwas ist schiefgelaufen",
|
||||
"error_500_heading": "Etwas ist schiefgelaufen",
|
||||
"error_500_message": "Wir arbeiten an einer Lösung. Bitte versuche es gleich noch einmal.",
|
||||
"error_500_back_home": "Zur Startseite"
|
||||
}
|
||||
@@ -1828,5 +1828,16 @@
|
||||
"affiliate_pros_label": "Pros",
|
||||
"affiliate_cons_label": "Cons",
|
||||
"affiliate_at_retailer": "at {retailer}",
|
||||
"affiliate_our_picks": "Our picks"
|
||||
"affiliate_our_picks": "Our picks",
|
||||
|
||||
"error_404_title": "Page Not Found",
|
||||
"error_404_heading": "This page doesn't exist",
|
||||
"error_404_message": "The page you're looking for may have been moved or doesn't exist yet.",
|
||||
"error_404_city_message": "The market analysis for this city isn't available yet.",
|
||||
"error_404_back_home": "Back to Home",
|
||||
"error_404_back_country": "Back to {country} overview",
|
||||
"error_500_title": "Something Went Wrong",
|
||||
"error_500_heading": "Something went wrong",
|
||||
"error_500_message": "We're working on fixing this. Please try again in a moment.",
|
||||
"error_500_back_home": "Back to Home"
|
||||
}
|
||||
@@ -80,7 +80,8 @@ async def opportunity_map():
|
||||
abort(404)
|
||||
countries = await fetch_analytics("""
|
||||
SELECT DISTINCT country_slug, country_name_en
|
||||
FROM serving.city_market_profile
|
||||
FROM serving.location_profiles
|
||||
WHERE city_slug IS NOT NULL
|
||||
ORDER BY country_name_en
|
||||
""")
|
||||
return await render_template("opportunity_map.html", countries=countries)
|
||||
|
||||
@@ -104,8 +104,10 @@
|
||||
var dist = loc.nearest_padel_court_km != null
|
||||
? loc.nearest_padel_court_km.toFixed(1) + ' km to nearest court'
|
||||
: 'No nearby courts';
|
||||
var mktColor = loc.market_score >= 60 ? '#16A34A' : (loc.market_score >= 30 ? '#D97706' : '#DC2626');
|
||||
var tip = '<strong>' + loc.location_name + '</strong><br>'
|
||||
+ '<span style="color:' + color + ';font-weight:600;">Score ' + loc.opportunity_score + '/100</span><br>'
|
||||
+ '<span style="color:' + color + ';font-weight:600;">Padelnomics Opportunity Score: ' + loc.opportunity_score + '/100</span><br>'
|
||||
+ '<span style="color:' + mktColor + ';font-weight:600;">Padelnomics Market Score: ' + (loc.market_score || 0) + '/100</span><br>'
|
||||
+ dist + ' · Pop. ' + fmtPop(loc.population);
|
||||
L.marker([loc.lat, loc.lon], { icon: makeIcon(size, color) })
|
||||
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] })
|
||||
|
||||
@@ -892,6 +892,18 @@
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Non-article city markers: faded + dashed border, no click affordance */
|
||||
.pn-marker--muted {
|
||||
opacity: 0.45;
|
||||
border: 2px dashed rgba(255,255,255,0.6);
|
||||
cursor: default;
|
||||
filter: saturate(0.7);
|
||||
}
|
||||
.pn-marker--muted:hover {
|
||||
transform: none;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.28);
|
||||
}
|
||||
|
||||
/* Small fixed venue dot */
|
||||
.pn-venue {
|
||||
width: 10px;
|
||||
|
||||
@@ -455,6 +455,8 @@
|
||||
border-radius: 18px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.chart-container__label {
|
||||
font-size: 11px;
|
||||
|
||||
122
web/src/padelnomics/static/js/article-maps.js
Normal file
122
web/src/padelnomics/static/js/article-maps.js
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Leaflet map initialisation for article pages (country + city maps).
|
||||
*
|
||||
* Looks for #country-map and #city-map elements. If neither exists, does nothing.
|
||||
* Expects data-* attributes on the map elements and a global LEAFLET_JS_URL
|
||||
* variable pointing to the Leaflet JS bundle.
|
||||
*/
|
||||
(function() {
|
||||
var countryMapEl = document.getElementById('country-map');
|
||||
var cityMapEl = document.getElementById('city-map');
|
||||
if (!countryMapEl && !cityMapEl) return;
|
||||
|
||||
var TILES = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
|
||||
var TILES_ATTR = '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>';
|
||||
|
||||
function scoreColor(score) {
|
||||
if (score >= 60) return '#16A34A';
|
||||
if (score >= 30) return '#D97706';
|
||||
return '#DC2626';
|
||||
}
|
||||
|
||||
function makeIcon(size, color, muted) {
|
||||
var s = Math.round(size);
|
||||
var cls = 'pn-marker' + (muted ? ' pn-marker--muted' : '');
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
html: '<div class="' + cls + '" style="width:' + s + 'px;height:' + s + 'px;background:' + color + ';"></div>',
|
||||
iconSize: [s, s],
|
||||
iconAnchor: [s / 2, s / 2],
|
||||
});
|
||||
}
|
||||
|
||||
function initCountryMap(el) {
|
||||
var slug = el.dataset.countrySlug;
|
||||
var map = L.map(el, {scrollWheelZoom: false});
|
||||
L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
|
||||
var lang = document.documentElement.lang || 'en';
|
||||
fetch('/api/markets/' + slug + '/cities.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.padel_venue_count || 1; }));
|
||||
var bounds = [];
|
||||
data.forEach(function(c) {
|
||||
if (!c.lat || !c.lon) return;
|
||||
var size = 10 + 36 * Math.sqrt((c.padel_venue_count || 1) / maxV);
|
||||
var hasArticle = c.has_article !== false;
|
||||
var color = scoreColor(c.market_score);
|
||||
var pop = c.population >= 1000000
|
||||
? (c.population / 1000000).toFixed(1) + 'M'
|
||||
: (c.population >= 1000 ? Math.round(c.population / 1000) + 'K' : (c.population || ''));
|
||||
var oppColor = c.opportunity_score >= 60 ? '#16A34A' : (c.opportunity_score >= 30 ? '#D97706' : '#3B82F6');
|
||||
var tip = '<strong>' + c.city_name + '</strong><br>'
|
||||
+ (c.padel_venue_count || 0) + ' venues'
|
||||
+ (pop ? ' · ' + pop : '')
|
||||
+ '<br><span style="color:' + color + ';font-weight:600;">Padelnomics Market Score: ' + Math.round(c.market_score) + '/100</span>'
|
||||
+ '<br><span style="color:' + oppColor + ';font-weight:600;">Padelnomics Opportunity Score: ' + Math.round(c.opportunity_score || 0) + '/100</span>';
|
||||
if (hasArticle) {
|
||||
tip += '<br><span style="color:#94A3B8;font-size:0.75rem;">Click to explore →</span>';
|
||||
} else {
|
||||
tip += '<br><span style="color:#94A3B8;font-size:0.75rem;">Coming soon</span>';
|
||||
}
|
||||
var marker = L.marker([c.lat, c.lon], { icon: makeIcon(size, color, !hasArticle) })
|
||||
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] })
|
||||
.addTo(map);
|
||||
if (hasArticle) {
|
||||
marker.on('click', function() { window.location = '/' + lang + '/markets/' + slug + '/' + c.city_slug; });
|
||||
}
|
||||
bounds.push([c.lat, c.lon]);
|
||||
});
|
||||
if (bounds.length) map.fitBounds(bounds, { padding: [24, 24] });
|
||||
})
|
||||
.catch(function(err) { console.error('Country map fetch failed:', err); });
|
||||
}
|
||||
|
||||
function initCityMap(el, venueIcon) {
|
||||
var countrySlug = el.dataset.countrySlug;
|
||||
var citySlug = el.dataset.citySlug;
|
||||
var lat = parseFloat(el.dataset.lat);
|
||||
var lon = parseFloat(el.dataset.lon);
|
||||
var map = L.map(el, {scrollWheelZoom: false}).setView([lat, lon], 13);
|
||||
L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
|
||||
fetch('/api/markets/' + countrySlug + '/' + citySlug + '/venues.json')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
data.forEach(function(v) {
|
||||
if (!v.lat || !v.lon) return;
|
||||
var indoor = v.indoor_court_count || 0;
|
||||
var outdoor = v.outdoor_court_count || 0;
|
||||
var total = v.court_count || (indoor + outdoor);
|
||||
var courtLine = total
|
||||
? total + ' court' + (total > 1 ? 's' : '')
|
||||
+ (indoor || outdoor
|
||||
? ' (' + [indoor ? indoor + ' indoor' : '', outdoor ? outdoor + ' outdoor' : ''].filter(Boolean).join(', ') + ')'
|
||||
: '')
|
||||
: '';
|
||||
var tip = '<strong>' + v.name + '</strong>' + (courtLine ? '<br>' + courtLine : '');
|
||||
L.marker([v.lat, v.lon], { icon: venueIcon })
|
||||
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -7] })
|
||||
.addTo(map);
|
||||
});
|
||||
})
|
||||
.catch(function(err) { console.error('City map fetch failed:', err); });
|
||||
}
|
||||
|
||||
/* Dynamically load Leaflet JS then init maps */
|
||||
var script = document.createElement('script');
|
||||
script.src = window.LEAFLET_JS_URL || '/static/vendor/leaflet/leaflet.min.js';
|
||||
script.onload = function() {
|
||||
if (countryMapEl) initCountryMap(countryMapEl);
|
||||
if (cityMapEl) {
|
||||
var venueIcon = L.divIcon({
|
||||
className: '',
|
||||
html: '<div class="pn-venue"></div>',
|
||||
iconSize: [10, 10],
|
||||
iconAnchor: [5, 5],
|
||||
});
|
||||
initCityMap(cityMapEl, venueIcon);
|
||||
}
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
})();
|
||||
23
web/src/padelnomics/templates/404.html
Normal file
23
web/src/padelnomics/templates/404.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ t.error_404_title }} — {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-page py-12">
|
||||
<div style="max-width:28rem;margin:0 auto;text-align:center;">
|
||||
<p style="font-size:6rem;font-weight:800;line-height:1;color:var(--slate);opacity:0.3;margin:0;">404</p>
|
||||
<h1 class="text-navy" style="font-size:1.5rem;font-weight:700;margin:1rem 0 0.5rem;">{{ t.error_404_heading }}</h1>
|
||||
{% if country_slug %}
|
||||
<p class="text-slate" style="font-size:1rem;line-height:1.6;">{{ t.error_404_city_message }}</p>
|
||||
<a href="/{{ lang }}/markets/{{ country_slug }}" class="btn" style="margin-top:1.5rem;display:inline-block;">
|
||||
{{ t.error_404_back_country.replace('{country}', country_name) }}
|
||||
</a>
|
||||
{% else %}
|
||||
<p class="text-slate" style="font-size:1rem;line-height:1.6;">{{ t.error_404_message }}</p>
|
||||
<a href="/{{ lang }}" class="btn" style="margin-top:1.5rem;display:inline-block;">
|
||||
{{ t.error_404_back_home }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
16
web/src/padelnomics/templates/500.html
Normal file
16
web/src/padelnomics/templates/500.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ t.error_500_title }} — {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-page py-12">
|
||||
<div style="max-width:28rem;margin:0 auto;text-align:center;">
|
||||
<p style="font-size:6rem;font-weight:800;line-height:1;color:var(--slate);opacity:0.3;margin:0;">500</p>
|
||||
<h1 class="text-navy" style="font-size:1.5rem;font-weight:700;margin:1rem 0 0.5rem;">{{ t.error_500_heading }}</h1>
|
||||
<p class="text-slate" style="font-size:1rem;line-height:1.6;">{{ t.error_500_message }}</p>
|
||||
<a href="/{{ lang }}" class="btn" style="margin-top:1.5rem;display:inline-block;">
|
||||
{{ t.error_500_back_home }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -737,9 +737,9 @@ async def handle_run_extraction(payload: dict) -> None:
|
||||
|
||||
@task("run_transform")
|
||||
async def handle_run_transform(payload: dict) -> None:
|
||||
"""Run SQLMesh transform (prod plan --auto-apply) in the background.
|
||||
"""Run SQLMesh transform (prod run) in the background.
|
||||
|
||||
Shells out to `uv run sqlmesh -p transform/sqlmesh_padelnomics plan prod --auto-apply`.
|
||||
Shells out to `uv run sqlmesh -p transform/sqlmesh_padelnomics run prod`.
|
||||
2-hour absolute timeout — same as extraction.
|
||||
"""
|
||||
import subprocess
|
||||
@@ -748,7 +748,7 @@ async def handle_run_transform(payload: dict) -> None:
|
||||
repo_root = Path(__file__).resolve().parents[4]
|
||||
result = await asyncio.to_thread(
|
||||
subprocess.run,
|
||||
["uv", "run", "sqlmesh", "-p", "transform/sqlmesh_padelnomics", "plan", "prod", "--auto-apply"],
|
||||
["uv", "run", "sqlmesh", "-p", "transform/sqlmesh_padelnomics", "run", "prod"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=7200,
|
||||
@@ -803,7 +803,7 @@ async def handle_run_pipeline(payload: dict) -> None:
|
||||
),
|
||||
(
|
||||
"transform",
|
||||
["uv", "run", "sqlmesh", "-p", "transform/sqlmesh_padelnomics", "plan", "prod", "--auto-apply"],
|
||||
["uv", "run", "sqlmesh", "-p", "transform/sqlmesh_padelnomics", "run", "prod"],
|
||||
7200,
|
||||
),
|
||||
(
|
||||
|
||||
@@ -64,7 +64,7 @@ def serving_meta_dir():
|
||||
meta = {
|
||||
"exported_at_utc": "2026-02-25T08:30:00+00:00",
|
||||
"tables": {
|
||||
"city_market_profile": {"row_count": 612},
|
||||
"location_profiles": {"row_count": 612},
|
||||
"planner_defaults": {"row_count": 612},
|
||||
"pseo_city_costs_de": {"row_count": 487},
|
||||
},
|
||||
@@ -78,16 +78,16 @@ def serving_meta_dir():
|
||||
# ── Schema + query mocks ──────────────────────────────────────────────────────
|
||||
|
||||
_MOCK_SCHEMA_ROWS = [
|
||||
{"table_name": "city_market_profile", "column_name": "city_slug", "data_type": "VARCHAR", "ordinal_position": 1},
|
||||
{"table_name": "city_market_profile", "column_name": "country_code", "data_type": "VARCHAR", "ordinal_position": 2},
|
||||
{"table_name": "city_market_profile", "column_name": "marktreife_score", "data_type": "DOUBLE", "ordinal_position": 3},
|
||||
{"table_name": "location_profiles", "column_name": "city_slug", "data_type": "VARCHAR", "ordinal_position": 1},
|
||||
{"table_name": "location_profiles", "column_name": "country_code", "data_type": "VARCHAR", "ordinal_position": 2},
|
||||
{"table_name": "location_profiles", "column_name": "market_score", "data_type": "DOUBLE", "ordinal_position": 3},
|
||||
{"table_name": "planner_defaults", "column_name": "city_slug", "data_type": "VARCHAR", "ordinal_position": 1},
|
||||
]
|
||||
|
||||
_MOCK_TABLE_EXISTS = [{"1": 1}]
|
||||
_MOCK_SAMPLE_ROWS = [
|
||||
{"city_slug": "berlin", "country_code": "DE", "marktreife_score": 82.5},
|
||||
{"city_slug": "munich", "country_code": "DE", "marktreife_score": 77.0},
|
||||
{"city_slug": "berlin", "country_code": "DE", "market_score": 82.5},
|
||||
{"city_slug": "munich", "country_code": "DE", "market_score": 77.0},
|
||||
]
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ def _make_fetch_analytics_mock(schema=True):
|
||||
return [r for r in _MOCK_SCHEMA_ROWS if r["table_name"] == params[0]]
|
||||
if "information_schema.columns" in sql:
|
||||
return _MOCK_SCHEMA_ROWS
|
||||
if "city_market_profile" in sql:
|
||||
if "location_profiles" in sql:
|
||||
return _MOCK_SAMPLE_ROWS
|
||||
return []
|
||||
return _mock
|
||||
@@ -162,7 +162,7 @@ async def test_pipeline_overview(admin_client, state_db_dir, serving_meta_dir):
|
||||
resp = await admin_client.get("/admin/pipeline/overview")
|
||||
assert resp.status_code == 200
|
||||
data = await resp.get_data(as_text=True)
|
||||
assert "city_market_profile" in data
|
||||
assert "location_profiles" in data
|
||||
assert "612" in data # row count from serving meta
|
||||
|
||||
|
||||
@@ -314,7 +314,7 @@ async def test_pipeline_catalog(admin_client, serving_meta_dir):
|
||||
resp = await admin_client.get("/admin/pipeline/catalog")
|
||||
assert resp.status_code == 200
|
||||
data = await resp.get_data(as_text=True)
|
||||
assert "city_market_profile" in data
|
||||
assert "location_profiles" in data
|
||||
assert "612" in data # row count from serving meta
|
||||
|
||||
|
||||
@@ -322,7 +322,7 @@ async def test_pipeline_catalog(admin_client, serving_meta_dir):
|
||||
async def test_pipeline_table_detail(admin_client):
|
||||
"""Table detail returns columns and sample rows."""
|
||||
with patch("padelnomics.analytics.fetch_analytics", side_effect=_make_fetch_analytics_mock()):
|
||||
resp = await admin_client.get("/admin/pipeline/catalog/city_market_profile")
|
||||
resp = await admin_client.get("/admin/pipeline/catalog/location_profiles")
|
||||
assert resp.status_code == 200
|
||||
data = await resp.get_data(as_text=True)
|
||||
assert "city_slug" in data
|
||||
@@ -362,7 +362,7 @@ async def test_pipeline_query_editor_loads(admin_client):
|
||||
data = await resp.get_data(as_text=True)
|
||||
assert "query-editor" in data
|
||||
assert "schema-panel" in data
|
||||
assert "city_market_profile" in data
|
||||
assert "location_profiles" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -380,7 +380,7 @@ async def test_pipeline_query_execute_valid(admin_client):
|
||||
with patch("padelnomics.analytics.execute_user_query", new_callable=AsyncMock, return_value=mock_result):
|
||||
resp = await admin_client.post(
|
||||
"/admin/pipeline/query/execute",
|
||||
form={"csrf_token": "test", "sql": "SELECT city_slug, country_code FROM serving.city_market_profile"},
|
||||
form={"csrf_token": "test", "sql": "SELECT city_slug, country_code FROM serving.location_profiles"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = await resp.get_data(as_text=True)
|
||||
@@ -397,7 +397,7 @@ async def test_pipeline_query_execute_blocked_keyword(admin_client):
|
||||
with patch("padelnomics.analytics.execute_user_query", new_callable=AsyncMock) as mock_q:
|
||||
resp = await admin_client.post(
|
||||
"/admin/pipeline/query/execute",
|
||||
form={"csrf_token": "test", "sql": "DROP TABLE serving.city_market_profile"},
|
||||
form={"csrf_token": "test", "sql": "DROP TABLE serving.location_profiles"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = await resp.get_data(as_text=True)
|
||||
@@ -532,8 +532,8 @@ def test_load_serving_meta(serving_meta_dir):
|
||||
with patch.object(pipeline_mod, "_SERVING_DUCKDB_PATH", str(Path(serving_meta_dir) / "analytics.duckdb")):
|
||||
meta = pipeline_mod._load_serving_meta()
|
||||
assert meta is not None
|
||||
assert "city_market_profile" in meta["tables"]
|
||||
assert meta["tables"]["city_market_profile"]["row_count"] == 612
|
||||
assert "location_profiles" in meta["tables"]
|
||||
assert meta["tables"]["location_profiles"]["row_count"] == 612
|
||||
|
||||
|
||||
def test_load_serving_meta_missing():
|
||||
|
||||
Reference in New Issue
Block a user