Compare commits

...

16 Commits

Author SHA1 Message Date
Deeman
301f3b76c3 feat: add scripts/prod_query.py — SSH query tool for prod DuckDB
All checks were successful
CI / test (push) Successful in 56s
CI / tag (push) Successful in 3s
Runs read-only SQL against analytics.duckdb (default) or lakehouse.duckdb
on the prod server over SSH. SQL is base64-encoded to avoid shell escaping.
Supports TSV (default) and JSON output. Blocks mutation keywords.

For lakehouse, works around the DuckDB catalog naming issue (SQLMesh views
reference "local" but the file creates catalog "lakehouse") by attaching
the file as the "local" catalog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:15:38 +01:00
Deeman
018eacb0f3 feat(analytics): add Microsoft Clarity with consent-gated loading
Some checks failed
CI / tag (push) Has been cancelled
CI / test (push) Has been cancelled
Gate Clarity behind functional cookie consent (TTDSG § 25 + GDPR).
Script loads on page if consent already given, bootstraps immediately
on banner accept without reload. Privacy policy (EN + DE) updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:14:58 +01:00
Deeman
abacaac3f5 docs: add IndexNow integration to CHANGELOG
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:04:46 +01:00
Deeman
241e0de78e merge: IndexNow integration for instant Bing/Yandex URL submission 2026-03-10 15:53:46 +01:00
Deeman
fc21c25c82 feat(seo): add IndexNow integration for instant Bing/Yandex URL submission
Push-notify search engines (Bing, Yandex, Seznam, Naver) when content
changes instead of waiting for sitemap crawls. Especially valuable for
batch article publishing and supplier directory updates.

- Add INDEXNOW_KEY config var and key verification route
- New seo/_indexnow.py: async fire-and-forget POST to IndexNow API
- notify_indexnow() wrapper in sitemap.py expands paths to all lang variants
- Integrated at all article publish/unpublish/edit and supplier create points
- Bulk operations batch all URLs into a single IndexNow request
- Skips silently when key is empty (dev environments)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 15:52:45 +01:00
Deeman
bd7fa1ae9a fix(pipeline): stg_playtomic_availability glob reads all files, filters by date range
All checks were successful
CI / test (push) Successful in 1m0s
CI / tag (push) Successful in 3s
The @start_ds in the glob pattern only matched files for the first day
of the batch, so incremental restates only loaded 1 day of data.
Changed to wildcard glob with explicit BETWEEN @start_ds AND @end_ds
filter on the date column.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 15:48:10 +01:00
Deeman
511a0ebac7 fix(supervisor): always deploy web app on new tag
All checks were successful
CI / test (push) Successful in 1m0s
CI / tag (push) Successful in 3s
The previous approach diffed HEAD~1 vs HEAD to detect web/ changes,
but this missed changes inside merge commits (HEAD~1 IS the merge,
so the diff only saw the follow-up CHANGELOG commit). Result: web
containers never got rebuilt after merge-based pushes.

Simpler and deterministic: always run deploy.sh on every new tag.
Blue/green swap is zero-downtime and Docker layer caching makes
no-op builds fast (~10s). Removes web_code_changed() entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:48:07 +01:00
Deeman
97ba13c42a docs: add SEO audit fixes to CHANGELOG
All checks were successful
CI / test (push) Successful in 56s
CI / tag (push) Successful in 3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:56:19 +01:00
Deeman
1bd5bae90d merge: SEO audit fixes — sitemap, redirects, OG tags, schema markup 2026-03-10 10:53:59 +01:00
Deeman
608f16f578 fix(seo): pre-GSC audit — sitemap, redirects, OG tags, schema markup
- sitemap: replace /market-score with /padelnomics-score, add /opportunity-map,
  remove /billing/pricing (blocked by robots.txt), deduplicate articles query
- app: fix /market-score redirect chain (→ /en/padelnomics-score directly)
- base.html: move default OG tags inside {% block head %} so child overrides
  replace them instead of duplicating
- features, planner, directory: add JSON-LD WebPage + BreadcrumbList schema
- export pages: add meta descriptions and {% block head %} where missing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:53:20 +01:00
Deeman
927f77ae5e fix: country_supply column name in location_profiles
All checks were successful
CI / test (push) Successful in 55s
CI / tag (push) Successful in 3s
2026-03-10 10:12:09 +01:00
Deeman
adf6f0c1ef fix(score): country_supply uses dim_cities.padel_venue_count (not city_padel_venue_count)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:09:30 +01:00
Deeman
9dc705970e merge: Opportunity Score v8 — better spread/discrimination
All checks were successful
CI / test (push) Successful in 54s
CI / tag (push) Successful in 3s
# Conflicts:
#	CHANGELOG.md
2026-03-09 22:24:43 +01:00
Deeman
9c5bed01f5 docs: add Score v8 entry to CHANGELOG
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:15:34 +01:00
Deeman
3ce97cd41b docs(i18n): update methodology weights for Score v8
Addressable Market 20→15, Economic Power 15→10, Supply Deficit 40→50.
Update scaling description (LN/500K → SQRT/1M) and add existence
dampener explanation to supply deficit description.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:15:18 +01:00
Deeman
ff6401254a feat(score): Opportunity Score v8 — better spread/discrimination
Reweight: addressable market 20→15, economic power 15→10, supply deficit 40→50.
Supply deficit existence dampener (country_venues/50, floor 0.1): zero-venue
countries drop from ~80 to ~17. Steeper addressable market curve (LN/500K →
SQRT/1M). NULL distance gap → 0.0 (was 0.5). Added country_percentile output
column (PERCENT_RANK within country, 0–100).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:14:30 +01:00
25 changed files with 451 additions and 113 deletions

View File

@@ -74,12 +74,13 @@ GSC_SERVICE_ACCOUNT_PATH=
GSC_SITE_URL=
BING_WEBMASTER_API_KEY=
BING_SITE_URL=
CLARITY_PROJECT_ID=ENC[AES256_GCM,data:PQ==,iv:GqQLR3UERBEGtqpZXAkZ8ETyVdj7+pk4YwuBPVxcjyE=,tag:1uuH2Gw3zE78Pugy6i6eDg==,type:str]
#ENC[AES256_GCM,data:ECsuDMQipS6YmFpSm1vqCsR2fUW2zN1Mg9VcUlw0roM=,iv:j+F6Akx2bklGMkFTux230YcZjMibA+Qp+qvgkGXl4Jw=,tag:7aO0wbmP/qB73wLgtiSJ2w==,type:comment]
GEONAMES_USERNAME=ENC[AES256_GCM,data:aSkVdLNrhiF6tlg=,iv:eemFGwDIv3EG/P3lVHGZj96MieIsr85e4xYmEIpZyfM=,tag:McpZMNOIO3FDkSebae2gOQ==,type:str]
CENSUS_API_KEY=ENC[AES256_GCM,data:qqG971573aGq9MiHI2xLlanKKFwjfcNNoMXtm8LNbyh0rMbQN2XukQ==,iv:az2i0ldH75nHGah4DeOxaXmDbVYqmC1c77ptZqFA9BI=,tag:zoDdKj9bR7fgIDo1/dEU2g==,type:str]
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxNWNmUzVNUGdWRnE0ZFpF\nM0JQZWZ3UDdEVzlwTmIxakxOZXBkT2x2ZlNrClRtV2M3S2daSGxUZmFDSWQ2Nmh4\neU51QndFcUxlSE00RFovOVJTcDZmUUUKLS0tIDcvL3hRMDRoMWZZSXljNzA3WG5o\nMWFic21MV0krMzlIaldBTVU0ZDdlTE0K7euGQtA+9lHNws+x7TMCArZamm9att96\nL8cXoUDWe5fNI5+M1bXReqVfNwPTwZsV6j/+ZtYKybklIzWz02Ex4A==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_recipient=age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a
sops_lastmodified=2026-03-03T15:16:35Z
sops_mac=ENC[AES256_GCM,data:T0qph3KPd68Lo4hxd6ECP+wv87uwRFsAFZwnVyf/MXvuG7raraUW02RLox0xklVcKBJXk+9jM7ycQ1nuk95UIuu7uRU88g11RaAm67XaOsafgwDMrC17AjIlg0Vf0w64WAJBrQLaXhJlh/Gz45bXlz82F+XVnTW8fGCpHRZooMY=,iv:cDgMZX6FRVe9JqQXLN6OhO06Ysfg2AKP2hG0B/GeajU=,tag:vHavf9Hw2xqJrqM3vVUTjA==,type:str]
sops_lastmodified=2026-03-10T15:07:09Z
sops_mac=ENC[AES256_GCM,data:mYPhIGSZIN+nqFEQE5VmLGaoTOvxFQ7fXvOHWcYtjr+AL/Zmnt81bo8Icgja5IMQPplSWoBo4J/7N08kSHATuBDuvCxNrsJaqTzCriTwfXq0WFa5yvoce/Sd29JEDAN505L+mR1PovhfIPndTR/E1bLvcyTz2NuAq5VGSg6KcUU=,iv:6mrSOWqIOItVt7Dp6jNecvzLjaTw/qQMr5b28I/bZWU=,tag:bT6Zi0Mb5Ci0CZBqr9iB3g==,type:str]
sops_unencrypted_suffix=_unencrypted
sops_version=3.12.1

View File

@@ -50,6 +50,8 @@ GSC_SERVICE_ACCOUNT_PATH=ENC[AES256_GCM,data:Vki6yHk+gd4n,iv:rxzKvwrGnAkLcpS41EZ
GSC_SITE_URL=ENC[AES256_GCM,data:K0i1xRym+laMP6kgOMEfUyoAn2eNgQ==,iv:kyb+grzFq1e5CG/0NJRO3LkSXexOuCK07uJYApAdWsA=,tag:faljHqYjGTgrR/Zbh27/Yw==,type:str]
BING_WEBMASTER_API_KEY=ENC[AES256_GCM,data:kSQxJOpsYCuJ,iv:Kc4jJpOd64PATeBjidNHTwBr/bNnCeqsTrUqAAYM5Vs=,tag:4jBxqgpyomzMLwiC9XpfVQ==,type:str]
BING_SITE_URL=ENC[AES256_GCM,data:M33VI97DyxH8gRR3ZUXoXg4QrEv5og==,iv:GxZtwfbBVihUbp6XNQKzAalhO1GfQF1l1j1MeEIBCFQ=,tag:9njlBp4v684PeFl3HebyIg==,type:str]
INDEXNOW_KEY=ENC[AES256_GCM,data:3AJnmPOQJoKw525QR7jx6QBzV9kznUsWqHRmQjv1cU8=,iv:4XRmcPKrFE8S3GzsfNbxUdaUNaKc6z9T+ihUUwjZ8Y0=,tag:wERrhY9whJ9yTEgt8ewaMQ==,type:str]
CLARITY_PROJECT_ID=ENC[AES256_GCM,data:mLQ4vvtDFpZOpCg=,iv:S58K5Qf32EFlAuh8xkjo603wVpCOhNodZLJ4ZyaGF6c=,tag:LUQor6rIRc7unYCyytSgSg==,type:str]
#ENC[AES256_GCM,data:OTUMKNkRW0zrupNppXthwE1oieILhNjM+cjx5hFn69g=,iv:48ID2qtSe9ggD2X+G/iUqp3v2uwEc7fZw8lxHIvVXmk=,tag:okBn0Npk1K9dDOFWA/AB1A==,type:comment]
GEONAMES_USERNAME=ENC[AES256_GCM,data:UXd/S2TzXPiGmLY=,iv:OMURM5E6SFEsaqroUlH76DEnr7C/ujNk9UQnbWT0hK4=,tag:VsjjS12QDbudiEhdAQ/OCQ==,type:str]
CENSUS_API_KEY=ENC[AES256_GCM,data:9RbKlxSD17LqIuuNXaOKSgZ8LnFh9Wbze3XHgpctfV/1TqBMZTIedQ==,iv:WwsmR3HLUEcgUpLliGRaUPhGM9vFNPMGXSAQQ6+9UVc=,tag:R4EMNy5MxxvK0UTaCL0umA==,type:str]
@@ -64,7 +66,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-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_lastmodified=2026-03-10T15:05:54Z
sops_mac=ENC[AES256_GCM,data:85sRBn6/gjXZFgyZlFk2RyMQGYK/e6rVC879F7/APj0xeguY5q4ui4OaE7OpO+joRMoLbE+rCWjYEyTeToTTdCNJ30yLiwlTrKR+tnmegJ/8wUAdyJtI8KO6XxKZpAesbiKl+o4F38iBZMhuZ6iybQx1RGF8SzQRu+E3fUEIiKk=,iv:/uW53lMRNNk/a/bzPvWqwDzP0un5/1muBDvDLSRet58=,tag:56LC972g6HSipvwxfSepJg==,type:str]
sops_unencrypted_suffix=_unencrypted
sops_version=3.12.1

View File

@@ -6,7 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Added
- **Microsoft Clarity integration** — consent-gated heatmaps and session recordings (project ID via `CLARITY_PROJECT_ID` env var). Script only loads when the user has accepted functional cookies; bootstraps immediately on consent without requiring a reload. Privacy policy (EN + DE) updated with Clarity disclosure: data collection, sub-processor, cookies (`_clck`, `_clsk`), and international transfers.
- **IndexNow integration** — push-notify Bing, Yandex, Seznam, and Naver when articles are published/unpublished/edited or suppliers are created. Bulk operations batch all URLs into a single request. Skips silently in dev (no key configured). Serves key verification file at `/{key}.txt`.
### Fixed
- **SEO audit fixes** — sitemap: replaced `/market-score` with `/padelnomics-score`, added `/opportunity-map`, removed `/billing/pricing` (blocked by robots.txt), deduplicated articles query (was producing 4 entries per article instead of 2). Fixed `/market-score` redirect chain (1 hop instead of 2). Moved default OG tags inside `{% block head %}` so child templates replace rather than duplicate them. Added JSON-LD WebPage + BreadcrumbList schema to features, planner, and directory pages. Added meta descriptions to export pages.
### Changed
- **Opportunity Score v7 → v8** — better spread and discrimination across the full 0-100 range. Addressable market weight reduced (20→15 pts) with steeper sqrt curve (ceiling 1M, was LN/500K). Economic power reduced (15→10 pts). Supply deficit increased (40→50 pts) with market existence dampener: countries with zero padel venues get max 5 pts supply deficit (factor 0.1), scaling linearly to full credit at 50+ venues. NULL nearest-court distance now treated as 0 (assume nearby) instead of 0.5. Added `country_percentile` output column (PERCENT_RANK within country). Target: P5-P95 spread ≥40 pts (was 22), zero-venue countries avg <30.
- **Opportunity Score v6 → v7 (calibration fix)** — two fixes for inflated scores in saturated markets. (1) `dim_locations` now sources venue coordinates from `dim_venues` (deduplicated OSM + Playtomic) instead of `stg_padel_courts` (OSM only), making Playtomic-only venues visible to spatial lookups. (2) Country-level supply saturation dampener on the 40-pt supply deficit component: saturated countries (Spain ~4.5/100k) get dampened supply deficit (×0.55 → 22 pts max), emerging markets (Germany ~0.7/100k) are nearly unaffected (×0.93 → ~37 pts).
- **Single-score simplification** — consolidated two public-facing scores (Market Score + Opportunity Score) into one **Padelnomics Score** (internally: `opportunity_score`). All maps, tooltips, article templates, and the methodology page now show a single score. Dual-ring markers reverted to single-color markers. `/market-score` route renamed to `/padelnomics-score` (old URL 301-redirects). All `mscore_*` i18n keys replaced with `pnscore_*`. Business plan queries `opportunity_score` from `location_profiles` (replaces legacy `city_market_overview` view). Map tooltip strings now i18n'd via `window.__MAP_T` (12 keys, EN + DE).

124
scripts/prod_query.py Normal file
View File

@@ -0,0 +1,124 @@
"""
Run a read-only SQL query against the production DuckDB (analytics or lakehouse).
Usage:
uv run python scripts/prod_query.py "SELECT COUNT(*) FROM serving.location_profiles"
uv run python scripts/prod_query.py --db lakehouse "SELECT * FROM foundation.dim_countries LIMIT 5"
echo "SELECT 1" | uv run python scripts/prod_query.py --stdin
The script SSHes to the prod server, runs the query via Python/DuckDB, and prints
tab-separated results with a header row. Read-only: DuckDB is opened with read_only=True.
For lakehouse queries, automatically aliases the catalog as "local" so SQLMesh views work.
Designed for Claude Code to call without nested shell escaping nightmares.
"""
import argparse
import base64
import subprocess
import sys
SSH_HOST = "hetzner_root"
SSH_USER = "padelnomics_service"
DB_PATHS = {
"analytics": "/opt/padelnomics/data/analytics.duckdb",
"lakehouse": "/opt/padelnomics/data/lakehouse.duckdb",
}
MAX_ROWS = 500
TIMEOUT_SECONDS = 30
# Mutation keywords blocked (defense in depth — DB is read_only anyway)
BLOCKED_KEYWORDS = {"CREATE", "DROP", "ALTER", "INSERT", "UPDATE", "DELETE", "ATTACH", "COPY", "EXPORT", "INSTALL", "LOAD"}
# Remote Python script template. Receives SQL as base64 via {b64_sql}.
# Uses ATTACH + USE to alias the lakehouse catalog as "local" for SQLMesh view compat.
REMOTE_SCRIPT = """\
import duckdb, json, sys, base64
db_path = "{db_path}"
sql = base64.b64decode("{b64_sql}").decode()
max_rows = {max_rows}
output_json = {output_json}
try:
if "lakehouse" in db_path:
con = duckdb.connect(":memory:")
con.execute(f"ATTACH '{db_path}' AS local (READ_ONLY)")
con.execute("USE local")
else:
con = duckdb.connect(db_path, read_only=True)
result = con.execute(sql)
cols = [d[0] for d in result.description]
rows = result.fetchmany(max_rows)
if output_json:
print(json.dumps({{"columns": cols, "rows": [list(r) for r in rows], "count": len(rows)}}, default=str))
else:
print("\\t".join(cols))
for row in rows:
print("\\t".join(str(v) if v is not None else "NULL" for v in row))
if len(rows) == max_rows:
print(f"... truncated at {{max_rows}} rows", file=sys.stderr)
except Exception as e:
print(f"ERROR: {{e}}", file=sys.stderr)
sys.exit(1)
"""
def main():
parser = argparse.ArgumentParser(description="Query prod DuckDB over SSH")
parser.add_argument("sql", nargs="?", help="SQL query to run")
parser.add_argument("--stdin", action="store_true", help="Read SQL from stdin")
parser.add_argument(
"--db",
choices=list(DB_PATHS.keys()),
default="analytics",
help="Which database (default: analytics)",
)
parser.add_argument(
"--max-rows", type=int, default=MAX_ROWS, help=f"Max rows (default: {MAX_ROWS})"
)
parser.add_argument("--json", action="store_true", help="Output JSON instead of TSV")
args = parser.parse_args()
sql = args.sql
if args.stdin or sql is None:
sql = sys.stdin.read().strip()
if not sql:
print("ERROR: No SQL provided", file=sys.stderr)
sys.exit(1)
sql_upper = sql.upper()
for kw in BLOCKED_KEYWORDS:
if kw in sql_upper:
print(f"ERROR: Blocked keyword '{kw}' in query", file=sys.stderr)
sys.exit(1)
b64_sql = base64.b64encode(sql.encode()).decode()
remote_script = REMOTE_SCRIPT.format(
db_path=DB_PATHS[args.db],
b64_sql=b64_sql,
max_rows=args.max_rows,
output_json=args.json,
)
cmd = [
"ssh", SSH_HOST,
f"sudo -u {SSH_USER} bash -lc 'cd /opt/padelnomics && uv run python3 -'",
]
result = subprocess.run(
cmd,
input=remote_script,
capture_output=True,
text=True,
timeout=TIMEOUT_SECONDS + 10,
)
if result.stdout:
print(result.stdout, end="")
if result.stderr:
print(result.stderr, end="", file=sys.stderr)
sys.exit(result.returncode)
if __name__ == "__main__":
main()

View File

@@ -267,48 +267,6 @@ def run_export() -> None:
send_alert(f"[export] {err}")
_last_seen_head: str | None = None
def web_code_changed() -> bool:
"""True on the first tick after a commit that changed web app code or secrets.
Compares the current HEAD to the HEAD from the previous tick. On first call
after process start (e.g. after os.execv reloads new code), falls back to
HEAD~1 so the just-deployed commit is evaluated exactly once.
Records HEAD before returning so the same commit never triggers twice.
"""
global _last_seen_head
result = subprocess.run(
["git", "rev-parse", "HEAD"], capture_output=True, text=True, timeout=10,
)
if result.returncode != 0:
return False
current_head = result.stdout.strip()
if _last_seen_head is None:
# Fresh process — use HEAD~1 as base (evaluates the newly deployed tag).
base_result = subprocess.run(
["git", "rev-parse", "HEAD~1"], capture_output=True, text=True, timeout=10,
)
base = base_result.stdout.strip() if base_result.returncode == 0 else current_head
else:
base = _last_seen_head
_last_seen_head = current_head # advance now — won't fire again for this HEAD
if base == current_head:
return False
diff = subprocess.run(
["git", "diff", "--name-only", base, current_head, "--",
"web/", "Dockerfile", ".env.prod.sops"],
capture_output=True, text=True, timeout=30,
)
return bool(diff.stdout.strip())
def current_deployed_tag() -> str | None:
"""Return the highest-version tag pointing at HEAD, or None.
@@ -360,6 +318,15 @@ def git_pull_and_sync() -> None:
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")
# Always redeploy the web app on new tag — blue/green swap is zero-downtime
# and Docker layer caching makes no-op builds fast. Previous approach of
# diffing HEAD~1 missed changes inside merge commits.
logger.info("Deploying web app (blue/green swap)")
ok, err = run_shell("./deploy.sh")
if ok:
send_alert(f"[deploy] {latest} ok")
else:
send_alert(f"[deploy] {latest} failed: {err}")
# 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")
@@ -408,14 +375,6 @@ def tick() -> None:
# Export serving tables
run_export()
# Deploy web app if code changed
if os.getenv("SUPERVISOR_GIT_PULL") and web_code_changed():
logger.info("Web code changed — deploying")
ok, err = run_shell("./deploy.sh")
if ok:
send_alert("[deploy] ok")
else:
send_alert(f"[deploy] failed: {err}")
finally:
conn.close()

View File

@@ -19,20 +19,23 @@
-- 10 pts economic context — income PPS normalised to 25,000 ceiling
-- 10 pts data quality — completeness discount
--
-- Padelnomics Opportunity Score (Marktpotenzial-Score v7, 0100):
-- Padelnomics Opportunity Score (Marktpotenzial-Score v8, 0100):
-- "Where should I build a padel court?"
-- Computed for ALL locations — zero-court locations score highest on supply deficit.
-- H3 catchment methodology: addressable market and supply deficit use a regional
-- H3 catchment (res-5 cell + 6 neighbours, ~24km radius).
--
-- v7 changes: country-level supply saturation dampener on supply deficit.
-- Saturated countries (Spain 7.4/100k) get dampened supply deficit (×0.30 → 12 pts max).
-- Emerging markets (Germany 0.24/100k) are nearly unaffected (×0.98 → ~39 pts).
-- Floor at 0.3 so supply deficit never fully vanishes.
-- v8 changes: better spread/discrimination.
-- - Reweight: addressable market 20→15, economic power 15→10, supply deficit 40→50.
-- - Supply deficit existence dampener: country_venues/50 factor (0.11.0).
-- Zero-venue countries get max 5 pts supply deficit (was 50).
-- - Steeper addressable market curve: LN/500K → SQRT/1M.
-- - NULL distance gap → 0.0 (was 0.5). Unknown = assume nearby.
-- - Added country_percentile output column (PERCENT_RANK within country).
--
-- 20 pts addressable market — log-scaled catchment population, ceiling 500K
-- 15 pts economic power — income PPS, normalised to 35,000
-- 40 pts supply deficit — max(density gap, distance gap) × country dampener
-- 15 pts addressable market — sqrt-scaled catchment population, ceiling 1M
-- 10 pts economic power — income PPS, normalised to 35,000
-- 50 pts supply deficit — max(density gap, distance gap) × existence dampener
-- 10 pts sports culture — tennis court density as racquet-sport adoption proxy
-- 5 pts construction affordability — income relative to construction costs (PLI)
-- 10 pts market headroom — inverse country-level avg market maturity
@@ -215,10 +218,10 @@ country_market AS (
country_supply AS (
SELECT
country_code,
SUM(city_padel_venue_count) AS country_venues,
SUM(padel_venue_count) AS country_venues,
SUM(population) AS country_pop,
CASE WHEN SUM(population) > 0
THEN SUM(city_padel_venue_count) * 100000.0 / SUM(population)
THEN SUM(padel_venue_count) * 100000.0 / SUM(population)
ELSE 0
END AS venues_per_100k
FROM foundation.dim_cities
@@ -228,28 +231,29 @@ country_supply AS (
-- Step 4: add opportunity_score using country market validation + supply saturation.
scored AS (
SELECT ms.*,
-- ── Opportunity Score (Marktpotenzial-Score v7, H3 catchment) ──────────
-- ── Opportunity Score (Marktpotenzial-Score v8, H3 catchment) ──────────
ROUND(
-- Addressable market (20 pts): log-scaled catchment population, ceiling 500K
20.0 * LEAST(1.0, LN(GREATEST(catchment_population, 1)) / LN(500000))
-- Economic power (15 pts): income PPS normalised to 35,000
+ 15.0 * LEAST(1.0, COALESCE(median_income_pps, 15000) / 35000.0)
-- Supply deficit (40 pts): max of density gap and distance gap.
-- Dampened by country-level supply saturation:
-- Spain (7.4/100k) → dampener 0.3012 pts max
-- Germany (0.24/100k) → dampener 0.98 → ~39 pts max
+ 40.0 * GREATEST(
-- Addressable market (15 pts): sqrt-scaled catchment population, ceiling 1M
15.0 * LEAST(1.0, SQRT(GREATEST(catchment_population, 1) / 1000000.0))
-- Economic power (10 pts): income PPS normalised to 35,000
+ 10.0 * LEAST(1.0, COALESCE(median_income_pps, 15000) / 35000.0)
-- Supply deficit (50 pts): max of density gap and distance gap.
-- Dampened by market existence: country_venues/50 (0.11.0).
-- 0 venues in country → factor 0.1max 5 pts supply deficit
-- 10 venues → 0.2 → max 10 pts
-- 50+ venues → 1.0 → full credit
+ 50.0 * GREATEST(
-- density-based gap (H3 catchment): 0 courts = 1.0, 5/100k = 0.0
GREATEST(0.0, 1.0 - COALESCE(
CASE WHEN catchment_population > 0
THEN GREATEST(catchment_padel_courts, COALESCE(city_padel_venue_count, 0))::DOUBLE / catchment_population * 100000
ELSE 0.0
END, 0.0) / 5.0),
-- distance-based gap: 30km+ = 1.0, 0km = 0.0; NULL = 0.5
COALESCE(LEAST(1.0, nearest_padel_court_km / 30.0), 0.5)
-- distance-based gap: 30km+ = 1.0, 0km = 0.0; NULL = 0.0 (assume nearby)
COALESCE(LEAST(1.0, nearest_padel_court_km / 30.0), 0.0)
)
-- Country supply dampener: floor 0.3 so deficit never fully vanishes
* GREATEST(0.3, 1.0 - COALESCE(cs.venues_per_100k, 0.0) / 10.0)
-- Market existence dampener: zero-venue countries get 0.1, 50+ venues = 1.0
* GREATEST(0.1, LEAST(1.0, COALESCE(cs.country_venues, 0) / 50.0))
-- Sports culture (10 pts): tennis density as racquet-sport adoption proxy.
-- Ceiling 50 courts within 25km. Harmless when tennis data is zero (contributes 0).
+ 10.0 * LEAST(1.0, COALESCE(tennis_courts_within_25km, 0) / 50.0)
@@ -301,6 +305,9 @@ SELECT
END AS catchment_venues_per_100k,
LEAST(GREATEST(s.market_score, 0), 100) AS market_score,
LEAST(GREATEST(s.opportunity_score, 0), 100) AS opportunity_score,
ROUND(PERCENT_RANK() OVER (
PARTITION BY s.country_code ORDER BY s.opportunity_score
) * 100, 0) AS country_percentile,
s.median_hourly_rate,
s.median_peak_rate,
s.median_offpeak_rate,

View File

@@ -34,7 +34,7 @@ all_jsonl AS (
tenant_id,
slots AS slots_json
FROM read_json(
@LANDING_DIR || '/playtomic/*/*/availability_' || @start_ds || '*.jsonl.gz',
@LANDING_DIR || '/playtomic/*/*/availability_*.jsonl.gz',
format = 'newline_delimited',
columns = {
date: 'VARCHAR',
@@ -46,6 +46,7 @@ all_jsonl AS (
filename = true
)
WHERE tenant_id IS NOT NULL
AND CAST(date AS DATE) BETWEEN @start_ds AND @end_ds
),
raw_resources AS (
SELECT

View File

@@ -1105,6 +1105,10 @@ async def supplier_new():
category, tier, contact_name, contact_email, contact_role,
services_offered, linkedin_url, instagram_url, youtube_url, now),
)
from ..sitemap import invalidate_sitemap_cache, notify_indexnow
invalidate_sitemap_cache()
await notify_indexnow([f"/directory/{slug}"])
await flash(f"Supplier '{name}' created.", "success")
return redirect(url_for("admin.supplier_detail", supplier_id=supplier_id))
@@ -2625,20 +2629,28 @@ async def articles_bulk():
)
if action == "publish":
affected = await fetch_all(
f"SELECT DISTINCT url_path FROM articles WHERE {where}", tuple(where_params)
)
await execute(
f"UPDATE articles SET status = 'published', updated_at = ? WHERE {where}",
(now, *where_params),
)
from ..sitemap import invalidate_sitemap_cache
from ..sitemap import invalidate_sitemap_cache, notify_indexnow
invalidate_sitemap_cache()
await notify_indexnow([r["url_path"] for r in affected])
elif action == "unpublish":
affected = await fetch_all(
f"SELECT DISTINCT url_path FROM articles WHERE {where}", tuple(where_params)
)
await execute(
f"UPDATE articles SET status = 'draft', updated_at = ? WHERE {where}",
(now, *where_params),
)
from ..sitemap import invalidate_sitemap_cache
from ..sitemap import invalidate_sitemap_cache, notify_indexnow
invalidate_sitemap_cache()
await notify_indexnow([r["url_path"] for r in affected])
elif action == "toggle_noindex":
await execute(
@@ -2685,16 +2697,26 @@ async def articles_bulk():
f"UPDATE articles SET status = 'published', updated_at = ? WHERE id IN ({placeholders})",
(now, *article_ids),
)
from ..sitemap import invalidate_sitemap_cache
from ..sitemap import invalidate_sitemap_cache, notify_indexnow
invalidate_sitemap_cache()
affected = await fetch_all(
f"SELECT DISTINCT url_path FROM articles WHERE id IN ({placeholders})",
tuple(article_ids),
)
await notify_indexnow([r["url_path"] for r in affected])
elif action == "unpublish":
await execute(
f"UPDATE articles SET status = 'draft', updated_at = ? WHERE id IN ({placeholders})",
(now, *article_ids),
)
from ..sitemap import invalidate_sitemap_cache
from ..sitemap import invalidate_sitemap_cache, notify_indexnow
invalidate_sitemap_cache()
affected = await fetch_all(
f"SELECT DISTINCT url_path FROM articles WHERE id IN ({placeholders})",
tuple(article_ids),
)
await notify_indexnow([r["url_path"] for r in affected])
elif action == "toggle_noindex":
await execute(
@@ -2808,8 +2830,10 @@ async def article_new():
(url_path, article_slug, title, meta_description, og_image_url,
country, region, language, status, pub_dt, seo_head, article_type),
)
from ..sitemap import invalidate_sitemap_cache
from ..sitemap import invalidate_sitemap_cache, notify_indexnow
invalidate_sitemap_cache()
if status == "published":
await notify_indexnow([url_path])
await flash(f"Article '{title}' created.", "success")
return redirect(url_for("admin.articles"))
@@ -2881,6 +2905,9 @@ async def article_edit(article_id: int):
(title, url_path, meta_description, og_image_url,
country, region, language, status, pub_dt, seo_head, article_type, now, article_id),
)
if status == "published":
from ..sitemap import notify_indexnow
await notify_indexnow([url_path])
await flash("Article updated.", "success")
return redirect(url_for("admin.articles"))
@@ -2976,8 +3003,11 @@ async def article_publish(article_id: int):
(new_status, now, article_id),
)
from ..sitemap import invalidate_sitemap_cache
from ..sitemap import invalidate_sitemap_cache, notify_indexnow
invalidate_sitemap_cache()
toggled = await fetch_one("SELECT url_path FROM articles WHERE id = ?", (article_id,))
if toggled:
await notify_indexnow([toggled["url_path"]])
if request.headers.get("HX-Request"):
updated = await fetch_one(

View File

@@ -284,6 +284,12 @@ def create_app() -> Quart:
from .sitemap import sitemap_response
return await sitemap_response(config.BASE_URL)
# IndexNow key verification — only register if key is configured
if config.INDEXNOW_KEY:
@app.route(f"/{config.INDEXNOW_KEY}.txt")
async def indexnow_key():
return Response(config.INDEXNOW_KEY, content_type="text/plain")
# -------------------------------------------------------------------------
# Error pages
# -------------------------------------------------------------------------
@@ -401,7 +407,7 @@ def create_app() -> Quart:
@app.route("/market-score")
async def legacy_market_score():
return redirect("/en/market-score", 301)
return redirect("/en/padelnomics-score", 301)
# -------------------------------------------------------------------------
# Blueprint registration

View File

@@ -72,6 +72,8 @@ class Config:
GSC_SITE_URL: str = os.getenv("GSC_SITE_URL", "")
BING_WEBMASTER_API_KEY: str = os.getenv("BING_WEBMASTER_API_KEY", "")
BING_SITE_URL: str = os.getenv("BING_SITE_URL", "")
INDEXNOW_KEY: str = os.getenv("INDEXNOW_KEY", "")
CLARITY_PROJECT_ID: str = os.getenv("CLARITY_PROJECT_ID", "")
RESEND_API_KEY: str = os.getenv("RESEND_API_KEY", "")
EMAIL_FROM: str = _env("EMAIL_FROM", "hello@padelnomics.io")

View File

@@ -6,6 +6,28 @@
<meta name="description" content="{{ t.dir_page_meta_desc | tformat(count=total_suppliers, countries=total_countries) }}">
<meta property="og:title" content="{{ t.dir_page_title }} - {{ config.APP_NAME }}">
<meta property="og:description" content="{{ t.dir_page_og_desc | tformat(count=total_suppliers, countries=total_countries) }}">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebPage",
"name": "{{ t.dir_page_title }} - {{ config.APP_NAME }}",
"description": "{{ t.dir_page_meta_desc | tformat(count=total_suppliers, countries=total_countries) }}",
"url": "{{ config.BASE_URL }}/{{ lang }}/directory/",
"inLanguage": "{{ lang }}",
"isPartOf": {"@type": "WebSite", "name": "Padelnomics", "url": "{{ config.BASE_URL }}"}
},
{
"@type": "BreadcrumbList",
"itemListElement": [
{"@type": "ListItem", "position": 1, "name": "Home", "item": "{{ config.BASE_URL }}/{{ lang }}"},
{"@type": "ListItem", "position": 2, "name": "{{ t.dir_page_title }}", "item": "{{ config.BASE_URL }}/{{ lang }}/directory/"}
]
}
]
}
</script>
<style>
:root {
--dir-green: #15803D;

View File

@@ -1711,12 +1711,12 @@
"pnscore_what_intro": "Der Padelnomics Score ist ein Komposit-Index von 0 bis 100, der bewertet, wie attraktiv ein Standort für eine neue Padelanlage ist. Er kombiniert angebotsseitige Lücken (gibt es genug Courts?) mit nachfrageseitigen Signalen (Bevölkerung, Einkommen, Sportaffinität) und berücksichtigt die Marktreife. Ein hoher Score bedeutet: Es gibt adressierbare Nachfrage, das Gebiet ist unterversorgt und die Rahmenbedingungen begünstigen ein Investment.",
"pnscore_components_h2": "Was der Score misst",
"pnscore_components_intro": "Sechs gewichtete Komponenten fließen in den Gesamtscore ein. Jede erfasst einen anderen Aspekt des Investitionspotenzials.",
"pnscore_cat_market_h3": "Adressierbarer Markt (20 Pkt)",
"pnscore_cat_market_p": "Einzugsgebiet-Bevölkerung im Umkreis von ~24 km (H3 Res-5-Zelle + Nachbarn). Logarithmisch skaliert — eine Stadt mit 500K Einwohnern erreicht das Maximum. Größeres Einzugsgebiet bedeutet mehr potenzielle Spieler.",
"pnscore_cat_econ_h3": "Wirtschaftskraft (15 Pkt)",
"pnscore_cat_market_h3": "Adressierbarer Markt (15 Pkt)",
"pnscore_cat_market_p": "Einzugsgebiet-Bevölkerung im Umkreis von ~24 km (H3 Res-5-Zelle + Nachbarn). Wurzelskaliert — ein Einzugsgebiet von 1 Mio. erreicht das Maximum. Größeres Einzugsgebiet bedeutet mehr potenzielle Spieler.",
"pnscore_cat_econ_h3": "Wirtschaftskraft (10 Pkt)",
"pnscore_cat_econ_p": "Regionales Einkommen in Kaufkraftstandards (KKS). Höheres verfügbares Einkommen stützt Premium-Preise und häufigeres Spielen. Daten von Eurostat (EU), Census (USA), ONS (UK).",
"pnscore_cat_gap_h3": "Versorgungslücke (40 Pkt)",
"pnscore_cat_gap_p": "Die gewichtigste Komponente. Misst zwei Signale: Anlagendichte-Lücke (wie weit unter 5 Courts pro 100K?) und Entfernungslücke (wie weit zur nächsten Anlage?). Null Courts = maximale Punktzahl. Bereits gut versorgte Gebiete erhalten kaum Punkte.",
"pnscore_cat_gap_h3": "Versorgungslücke (50 Pkt)",
"pnscore_cat_gap_p": "Die gewichtigste Komponente. Misst zwei Signale: Anlagendichte-Lücke (wie weit unter 5 Courts pro 100K?) und Entfernungslücke (wie weit zur nächsten Anlage?). Gedämpft nach Marktreife — Länder mit wenigen oder keinen Padel-Anlagen erhalten reduzierten Punktwert, da eine Versorgungslücke ohne nachgewiesene Nachfrage spekulativ ist. Voller Punktwert erst ab 50+ Anlagen im Land.",
"pnscore_cat_sports_h3": "Sportaffinität (10 Pkt)",
"pnscore_cat_sports_p": "Tennisplatz-Dichte im Umkreis von 25 km als Proxy für Racketsport-Affinität. Regionen mit starker Tennis-Infrastruktur haben ein bereites Publikum für Padel — einen eng verwandten Sport mit niedrigerer Einstiegshürde.",
"pnscore_cat_catchment_h3": "Baukosten-Erschwinglichkeit (5 Pkt)",

View File

@@ -1742,12 +1742,12 @@
"pnscore_what_intro": "The Padelnomics Score is a 0-100 composite index that evaluates how attractive a location is for a new padel facility. It combines supply-side gaps (are there enough courts?) with demand-side signals (population, income, sports culture) and adjusts for market maturity. A high score means: there is addressable demand, the area is underserved, and conditions favor a new investment.",
"pnscore_components_h2": "What It Measures",
"pnscore_components_intro": "Six weighted components combine into the final score. Each captures a different aspect of investment potential.",
"pnscore_cat_market_h3": "Addressable Market (20 pts)",
"pnscore_cat_market_p": "Catchment population within ~24 km (H3 res-5 cell + neighbors). Log-scaled — a city of 500K scores the maximum. Larger catchment means more potential players.",
"pnscore_cat_econ_h3": "Economic Power (15 pts)",
"pnscore_cat_market_h3": "Addressable Market (15 pts)",
"pnscore_cat_market_p": "Catchment population within ~24 km (H3 res-5 cell + neighbors). Square-root scaled — a catchment of 1M scores the maximum. Larger catchment means more potential players.",
"pnscore_cat_econ_h3": "Economic Power (10 pts)",
"pnscore_cat_econ_p": "Regional income in purchasing power standard (PPS). Higher disposable income supports premium pricing and more frequent play. Data from Eurostat (EU), Census (US), ONS (UK).",
"pnscore_cat_gap_h3": "Supply Deficit (40 pts)",
"pnscore_cat_gap_p": "The single biggest component. Measures two signals: court density gap (how far below 5 courts per 100K?) and distance gap (how far to the nearest existing court?). Zero courts = maximum score. Already well-served areas score near zero.",
"pnscore_cat_gap_h3": "Supply Deficit (50 pts)",
"pnscore_cat_gap_p": "The single biggest component. Measures two signals: court density gap (how far below 5 courts per 100K?) and distance gap (how far to the nearest existing court?). Dampened by market existence — countries with few or no padel venues get reduced credit, since a supply gap without proven demand is speculative. Full credit requires 50+ venues nationally.",
"pnscore_cat_sports_h3": "Sports Culture (10 pts)",
"pnscore_cat_sports_p": "Tennis court density within 25 km as a proxy for racquet sport adoption. Regions with strong tennis infrastructure have a ready audience for padel — a closely related sport with a lower barrier to entry.",
"pnscore_cat_catchment_h3": "Construction Affordability (5 pts)",

View File

@@ -3,6 +3,7 @@
{% block paddle %}{% include "_payment_js.html" %}{% endblock %}
{% block head %}
<meta name="description" content="{{ t.export_title }}">
<style>
.exp-wrap { max-width: 640px; margin: 0 auto; padding: 3rem 0; }
.exp-hero { text-align: center; margin-bottom: 2rem; }

View File

@@ -2,6 +2,7 @@
{% block title %}Business Plan Details — {{ config.APP_NAME }}{% endblock %}
{% block head %}
<meta name="description" content="Business Plan Details — {{ config.APP_NAME }}">
<style>
.bp-wrap { max-width: 680px; margin: 0 auto; padding: 3rem 0; }
.bp-hero { margin-bottom: 2rem; }

View File

@@ -1,6 +1,10 @@
{% extends "base.html" %}
{% block title %}{{ t.export_success_title }} - {{ config.APP_NAME }}{% endblock %}
{% block head %}
<meta name="description" content="{{ t.export_success_title }}">
{% endblock %}
{% block content %}
<main class="container-page" style="max-width:500px;margin:0 auto;padding:4rem 1rem;text-align:center">
<div style="font-size:3rem;margin-bottom:1rem">&#10003;</div>

View File

@@ -2,6 +2,10 @@
{% block title %}{{ t.export_waitlist_title }} - {{ config.APP_NAME }}{% endblock %}
{% block head %}
<meta name="description" content="{{ t.export_waitlist_title }} - {{ config.APP_NAME }}">
{% endblock %}
{% block content %}
<main class="container-page py-12">
<div class="card max-w-md mx-auto mt-8 text-center">

View File

@@ -9,6 +9,35 @@
<meta property="og:image" content="{{ url_for('static', filename='images/planner-screenshot.png', _external=True) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/planner.css') }}?v={{ v }}">
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebPage",
"name": "{{ t.planner_page_title }} - {{ config.APP_NAME }}",
"description": "{{ t.planner_meta_desc }}",
"url": "{{ config.BASE_URL }}/{{ lang }}/planner/",
"inLanguage": "{{ lang }}",
"isPartOf": {"@type": "WebSite", "name": "Padelnomics", "url": "{{ config.BASE_URL }}"}
},
{
"@type": "BreadcrumbList",
"itemListElement": [
{"@type": "ListItem", "position": 1, "name": "Home", "item": "{{ config.BASE_URL }}/{{ lang }}"},
{"@type": "ListItem", "position": 2, "name": "{{ t.nav_planner }}", "item": "{{ config.BASE_URL }}/{{ lang }}/planner/"}
]
},
{
"@type": "SoftwareApplication",
"name": "Padelnomics Padel Court Planner",
"applicationCategory": "BusinessApplication",
"operatingSystem": "Web",
"offers": {"@type": "Offer", "price": "0", "priceCurrency": "EUR"}
}
]
}
</script>
{% endblock %}
{% macro slider(name, label, min, max, step, value, tip='') %}

View File

@@ -7,6 +7,28 @@
<meta property="og:title" content="{{ t.features_title_prefix }} | {{ config.APP_NAME }}">
<meta property="og:description" content="{{ t.features_meta_desc }}">
<meta property="og:image" content="{{ url_for('static', filename='images/planner-screenshot.png', _external=True) }}">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebPage",
"name": "{{ t.features_title_prefix }} | {{ config.APP_NAME }}",
"description": "{{ t.features_meta_desc }}",
"url": "{{ config.BASE_URL }}/{{ lang }}/features",
"inLanguage": "{{ lang }}",
"isPartOf": {"@type": "WebSite", "name": "Padelnomics", "url": "{{ config.BASE_URL }}"}
},
{
"@type": "BreadcrumbList",
"itemListElement": [
{"@type": "ListItem", "position": 1, "name": "Home", "item": "{{ config.BASE_URL }}/{{ lang }}"},
{"@type": "ListItem", "position": 2, "name": "{{ t.features_title_prefix }}", "item": "{{ config.BASE_URL }}/{{ lang }}/features"}
]
}
]
}
</script>
{% endblock %}
{% block content %}

View File

@@ -3,14 +3,14 @@
{% block title %}Datenschutzerklärung - {{ config.APP_NAME }}{% endblock %}
{% block head %}
<meta name="description" content="Datenschutzerklärung für Padelnomics. DSGVO-konform. Erfahren Sie, wie wir Ihre Daten erheben, verwenden und schützen. Wir nutzen Umami (cookielose Analyse), Paddle (Zahlungen) und Resend (E-Mail). Wir verkaufen Ihre Daten niemals.">
<meta name="description" content="Datenschutzerklärung für Padelnomics. DSGVO-konform. Erfahren Sie, wie wir Ihre Daten erheben, verwenden und schützen. Wir nutzen Umami (cookielose Analyse), Microsoft Clarity (Sitzungsaufzeichnungen, nur mit Einwilligung), Paddle (Zahlungen) und Resend (E-Mail). Wir verkaufen Ihre Daten niemals.">
{% endblock %}
{% block content %}
<main class="container-page py-12">
<div class="card max-w-3xl mx-auto">
<h1 class="text-2xl mb-1">Datenschutzerklärung</h1>
<p class="text-sm text-slate mb-8">Stand: Februar 2026 &mdash; <a href="{{ url_for('public.privacy', lang='en') }}" style="text-decoration:underline">Read in English</a></p>
<p class="text-sm text-slate mb-8">Stand: März 2026 &mdash; <a href="{{ url_for('public.privacy', lang='en') }}" style="text-decoration:underline">Read in English</a></p>
<div class="space-y-6 text-slate-dark leading-relaxed">
@@ -36,6 +36,7 @@
<p class="mt-3"><strong>Automatisch erhobene Daten:</strong></p>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li>Aggregierte, anonymisierte Seitenaufruf-Daten über Umami (keine IP-Speicherung, kein siteübergreifendes Tracking)</li>
<li>Anonymisierte Interaktionsdaten (Klicks, Scrolltiefe, Sitzungsaufzeichnungen) über Microsoft Clarity — nur mit Ihrer Einwilligung</li>
<li>Session-Cookie zur Aufrechterhaltung der Anmeldung</li>
</ul>
<p class="mt-3"><strong>Beim Checkout erhobene Daten (durch Paddle, unseren Zahlungsdienstleister):</strong></p>
@@ -60,6 +61,7 @@
<li><strong>Umami</strong> (selbst gehostet auf unserer eigenen Infrastruktur) — cookielose, datenschutzfreundliche Webanalyse. Keine Übermittlung personenbezogener Daten an Dritte.</li>
<li><strong>Paddle</strong> (paddle.com, UK/USA) — Zahlungsabwicklung und Abonnementverwaltung. Paddle agiert als Merchant of Record. Siehe <a href="https://www.paddle.com/legal/privacy" target="_blank" rel="noopener" style="text-decoration:underline">Datenschutzerklärung von Paddle</a>.</li>
<li><strong>Resend</strong> (resend.com, USA) — Versand transaktionaler E-Mails (Magic Links, Belege). Die Übermittlung erfolgt auf Basis von Standardvertragsklauseln (SCC) der Europäischen Kommission. Siehe <a href="https://resend.com/legal/privacy-policy" target="_blank" rel="noopener" style="text-decoration:underline">Datenschutzerklärung von Resend</a>.</li>
<li><strong>Microsoft Clarity</strong> (clarity.microsoft.com, USA) — Heatmaps und Sitzungsaufzeichnungen zur Verbesserung der Nutzererfahrung. Nur aktiv mit Ihrer Einwilligung (funktionale Cookies). Die Übermittlung erfolgt auf Basis von Standardvertragsklauseln (SCC). Siehe <a href="https://privacy.microsoft.com/privacystatement" target="_blank" rel="noopener" style="text-decoration:underline">Datenschutzerklärung von Microsoft</a>.</li>
</ul>
</section>
@@ -87,6 +89,8 @@
<p class="mt-3 font-semibold text-sm">Funktional (erfordert Einwilligung)</p>
<ul class="list-disc pl-6 mt-1 space-y-1">
<li><strong>ab_*</strong> — Weist Ihnen eine A/B-Testvariante zu, um unsere Website zu verbessern. Läuft nach 30 Tagen ab. Wird nur gesetzt, wenn Sie funktionalen Cookies zugestimmt haben.</li>
<li><strong>_clck</strong> — Microsoft Clarity Nutzerkennung. Gültig 12 Monate. Wird nur gesetzt, wenn Sie funktionalen Cookies zugestimmt haben.</li>
<li><strong>_clsk</strong> — Microsoft Clarity Sitzungskennung. Gültig bis zum Ende der Sitzung. Wird nur gesetzt, wenn Sie funktionalen Cookies zugestimmt haben.</li>
</ul>
<p class="mt-3 font-semibold text-sm">Zahlung (nur beim Checkout)</p>
@@ -115,7 +119,7 @@
<section>
<h2 class="text-lg mb-2">8. Internationale Datenübermittlung</h2>
<p>Resend verarbeitet Daten in den USA. Die Übermittlung erfolgt auf Basis von Standardvertragsklauseln (SCC) der Europäischen Kommission. Paddle unterliegt dem UK-DSGVO mit Angemessenheitsbeschluss. Umami läuft auf unserer eigenen EU-Infrastruktur — keine Daten verlassen die EU.</p>
<p>Resend und Microsoft Clarity verarbeiten Daten in den USA. Die Übermittlung erfolgt auf Basis von Standardvertragsklauseln (SCC) der Europäischen Kommission. Paddle unterliegt dem UK-DSGVO mit Angemessenheitsbeschluss. Umami läuft auf unserer eigenen EU-Infrastruktur — keine Daten verlassen die EU.</p>
</section>
<section>

View File

@@ -3,14 +3,14 @@
{% block title %}Privacy Policy - {{ config.APP_NAME }}{% endblock %}
{% block head %}
<meta name="description" content="Privacy Policy for Padelnomics. GDPR compliant. Learn how we collect, use, and protect your data. We use Umami (cookieless analytics), Paddle (payments), and Resend (email). We never sell your personal information.">
<meta name="description" content="Privacy Policy for Padelnomics. GDPR compliant. Learn how we collect, use, and protect your data. We use Umami (cookieless analytics), Microsoft Clarity (session recordings, with consent), Paddle (payments), and Resend (email). We never sell your personal information.">
{% endblock %}
{% block content %}
<main class="container-page py-12">
<div class="card max-w-3xl mx-auto">
<h1 class="text-2xl mb-1">Privacy Policy</h1>
<p class="text-sm text-slate mb-8">Last updated: February 2026 &mdash; <a href="{{ url_for('public.privacy', lang='de') }}" style="text-decoration:underline">Datenschutzerklärung auf Deutsch</a></p>
<p class="text-sm text-slate mb-8">Last updated: March 2026 &mdash; <a href="{{ url_for('public.privacy', lang='de') }}" style="text-decoration:underline">Datenschutzerklärung auf Deutsch</a></p>
<div class="space-y-6 text-slate-dark leading-relaxed">
@@ -36,6 +36,7 @@
<p class="mt-3"><strong>Data collected automatically:</strong></p>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li>Aggregated, anonymised page-view data via Umami (no IP address stored, no cross-site tracking)</li>
<li>Anonymised interaction data (clicks, scroll depth, session recordings) via Microsoft Clarity — only with your consent</li>
<li>Session cookie to keep you signed in</li>
</ul>
<p class="mt-3"><strong>Data collected at checkout (by Paddle, our payment processor):</strong></p>
@@ -60,6 +61,7 @@
<li><strong>Umami</strong> (self-hosted on our own infrastructure) — cookieless, privacy-first analytics. No personal data transferred to third parties.</li>
<li><strong>Paddle</strong> (paddle.com, UK/USA) — payment processing and subscription management. Paddle acts as merchant of record. See <a href="https://www.paddle.com/legal/privacy" target="_blank" rel="noopener" style="text-decoration:underline">Paddle's Privacy Policy</a>.</li>
<li><strong>Resend</strong> (resend.com, USA) — transactional email delivery (magic links, receipts). Data is transferred under Standard Contractual Clauses. See <a href="https://resend.com/legal/privacy-policy" target="_blank" rel="noopener" style="text-decoration:underline">Resend's Privacy Policy</a>.</li>
<li><strong>Microsoft Clarity</strong> (clarity.microsoft.com, USA) — heatmaps and session recordings for UX improvement. Only active with your consent (functional cookies). Data is transferred under Standard Contractual Clauses. See <a href="https://privacy.microsoft.com/privacystatement" target="_blank" rel="noopener" style="text-decoration:underline">Microsoft Privacy Statement</a>.</li>
</ul>
</section>
@@ -87,6 +89,8 @@
<p class="mt-3 font-semibold text-sm">Functional (require consent)</p>
<ul class="list-disc pl-6 mt-1 space-y-1">
<li><strong>ab_*</strong> — Assigns you to an A/B test variant to help us improve the site. Expires after 30 days. Only set if you accept functional cookies.</li>
<li><strong>_clck</strong> — Microsoft Clarity user identifier. Expires after 12 months. Only set if you accept functional cookies.</li>
<li><strong>_clsk</strong> — Microsoft Clarity session identifier. Expires at end of session. Only set if you accept functional cookies.</li>
</ul>
<p class="mt-3 font-semibold text-sm">Payment (checkout only)</p>
@@ -115,7 +119,7 @@
<section>
<h2 class="text-lg mb-2">8. International Transfers</h2>
<p>Resend processes data in the USA. Transfers are protected by Standard Contractual Clauses (SCCs) approved by the European Commission. Paddle operates under UK GDPR with an adequacy finding. Umami runs on our own EU-based infrastructure — no data leaves the EU.</p>
<p>Resend and Microsoft Clarity process data in the USA. Transfers are protected by Standard Contractual Clauses (SCCs) approved by the European Commission. Paddle operates under UK GDPR with an adequacy finding. Umami runs on our own EU-based infrastructure — no data leaves the EU.</p>
</section>
<section>

View File

@@ -0,0 +1,60 @@
"""IndexNow push notifications — instant URL submission to Bing, Yandex, Seznam, Naver.
Fire-and-forget: logs errors but never raises. Content publishing must not fail
because IndexNow is down.
"""
import logging
import httpx
logger = logging.getLogger(__name__)
_INDEXNOW_ENDPOINT = "https://api.indexnow.org/IndexNow"
_BATCH_LIMIT = 10_000 # IndexNow max URLs per request
async def notify_urls(
urls: list[str], host: str, key: str, timeout_seconds: int = 10
) -> bool:
"""POST changed URLs to IndexNow. Returns True on success, False on failure.
Skips silently if key is empty (dev environments).
Batches into chunks of 10,000 URLs per IndexNow spec.
"""
assert 1 <= timeout_seconds <= 60, "timeout_seconds must be 1-60"
if not key:
return False
if not urls:
return True
key_location = f"https://{host}/{key}.txt"
try:
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
for offset in range(0, len(urls), _BATCH_LIMIT):
batch = urls[offset : offset + _BATCH_LIMIT]
response = await client.post(
_INDEXNOW_ENDPOINT,
json={
"host": host,
"key": key,
"keyLocation": key_location,
"urlList": batch,
},
)
# IndexNow returns 200 or 202 on success
if response.status_code not in (200, 202):
logger.warning(
"IndexNow returned %d for %d URLs: %s",
response.status_code,
len(batch),
response.text[:200],
)
return False
logger.info("IndexNow accepted %d URLs", len(batch))
return True
except Exception:
logger.warning("IndexNow notification failed", exc_info=True)
return False

View File

@@ -1,10 +1,14 @@
"""Sitemap generation with in-memory TTL cache and hreflang alternates."""
import logging
import time
from urllib.parse import urlparse
from quart import Response
from .core import fetch_all
from .core import config, fetch_all
logger = logging.getLogger(__name__)
# Process-local cache — valid for the current single-Hypercorn-worker deployment
# (Dockerfile: `--workers 1`). If worker count increases, replace with a
@@ -26,9 +30,10 @@ STATIC_PATHS = [
"/imprint",
"/suppliers",
"/markets",
"/market-score",
"/padelnomics-score",
"/planner/",
"/directory/",
"/opportunity-map",
]
@@ -65,16 +70,16 @@ async def _generate_sitemap_xml(base_url: str) -> str:
for lang in LANGS:
entries.append(_url_entry(f"{base}/{lang}{path}", alternates))
# Billing pricing — no lang prefix, no hreflang
entries.append(_url_entry(f"{base}/billing/pricing", []))
# Published articles — both lang variants with accurate lastmod.
# Exclude noindex articles (thin data) to keep sitemap signal-dense.
# GROUP BY url_path: articles table has one row per language per url_path,
# but the for-lang loop already creates both lang entries per path.
articles = await fetch_all(
"""SELECT url_path, COALESCE(updated_at, published_at) AS lastmod
"""SELECT url_path, MAX(COALESCE(updated_at, published_at)) AS lastmod
FROM articles
WHERE status = 'published' AND noindex = 0 AND published_at <= datetime('now')
ORDER BY published_at DESC
GROUP BY url_path
ORDER BY MAX(published_at) DESC
LIMIT 25000"""
)
for article in articles:
@@ -127,3 +132,22 @@ async def sitemap_response(base_url: str) -> Response:
content_type="application/xml",
headers={"Cache-Control": f"public, max-age={CACHE_TTL_SECONDS}"},
)
async def notify_indexnow(paths: list[str]) -> None:
"""Ping IndexNow with full URLs for the given paths (both lang variants).
Paths should be lang-agnostic (e.g. "/blog/my-article"). Each path gets
expanded to all supported language variants.
"""
if not config.INDEXNOW_KEY or not paths:
return
base = config.BASE_URL.rstrip("/")
urls = []
for path in paths:
for lang in LANGS:
urls.append(f"{base}/{lang}{path}")
host = urlparse(config.BASE_URL).hostname or "padelnomics.io"
from .seo._indexnow import notify_urls
await notify_urls(urls, host=host, key=config.INDEXNOW_KEY)

View File

@@ -151,6 +151,14 @@
function dismiss(value) {
document.cookie = COOKIE_NAME + '=' + value
+ ';path=/;max-age=' + MAX_AGE + ';SameSite=Lax';
// Bootstrap Clarity immediately on functional consent (no reload needed)
if (value.indexOf('functional') !== -1 && window.clarity === undefined) {
(function(c,l,a,r,i,t,y){
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
})(window, document, "clarity", "script", "{{ config.CLARITY_PROJECT_ID }}");
}
banner.classList.remove('cb-enter');
banner.classList.add('cb-exit');
setTimeout(function () { banner.style.display = 'none'; }, 280);

View File

@@ -18,6 +18,20 @@
<!-- Umami Analytics -->
<script defer src="https://umami.padelnomics.io/Z.js" data-website-id="4474414b-58d6-4c6e-89a1-df5ea1f49d70"{% if ab_tag %} data-tag="{{ ab_tag }}"{% endif %}></script>
<!-- Microsoft Clarity (consent-gated) -->
{% if config.CLARITY_PROJECT_ID %}
<script type="text/javascript">
(function(){
if (!/cookie_consent=[^;]*functional/.test(document.cookie)) return;
(function(c,l,a,r,i,t,y){
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
})(window, document, "clarity", "script", "{{ config.CLARITY_PROJECT_ID }}");
})();
</script>
{% endif %}
<!-- Paddle.js (only on checkout pages via block override) -->
{% block paddle %}{% endblock %}
@@ -29,15 +43,16 @@
<link rel="alternate" hreflang="de" href="{{ config.BASE_URL }}/de{{ path_suffix }}">
<link rel="alternate" hreflang="x-default" href="{{ config.BASE_URL }}/en{{ path_suffix }}">
{% endif %}
<meta name="twitter:card" content="summary_large_image">
<script>window.__GEO = {country: "{{ user_country }}", city: "{{ user_city }}"};</script>
{% block head %}
<meta property="og:title" content="{{ config.APP_NAME }}">
<meta property="og:description" content="">
<meta property="og:type" content="website">
<meta property="og:url" content="{{ config.BASE_URL }}{{ request.path }}">
<meta property="og:image" content="{{ url_for('static', filename='images/logo.png', _external=True) }}">
<meta name="twitter:card" content="summary_large_image">
<script>window.__GEO = {country: "{{ user_country }}", city: "{{ user_city }}"};</script>
{% block head %}{% endblock %}
{% endblock %}
</head>
<body>
<nav class="nav-bar" id="main-nav">