Compare commits

..

4 Commits

Author SHA1 Message Date
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
12 changed files with 108 additions and 61 deletions

View File

@@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased] ## [Unreleased]
### 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 ### 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 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). - **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).

View File

@@ -267,48 +267,6 @@ def run_export() -> None:
send_alert(f"[export] {err}") 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: def current_deployed_tag() -> str | None:
"""Return the highest-version tag pointing at HEAD, or 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") run_shell("uv sync --all-packages")
# Apply any model changes (FULL→INCREMENTAL, new models, etc.) before re-exec # 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") 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; # 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. # systemd sees it as the same PID and does not restart the unit.
logger.info("Deploy complete — re-execing to load new code") logger.info("Deploy complete — re-execing to load new code")
@@ -408,14 +375,6 @@ def tick() -> None:
# Export serving tables # Export serving tables
run_export() 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: finally:
conn.close() conn.close()

View File

@@ -401,7 +401,7 @@ def create_app() -> Quart:
@app.route("/market-score") @app.route("/market-score")
async def legacy_market_score(): async def legacy_market_score():
return redirect("/en/market-score", 301) return redirect("/en/padelnomics-score", 301)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Blueprint registration # Blueprint registration

View File

@@ -6,6 +6,28 @@
<meta name="description" content="{{ t.dir_page_meta_desc | tformat(count=total_suppliers, countries=total_countries) }}"> <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: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) }}"> <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> <style>
:root { :root {
--dir-green: #15803D; --dir-green: #15803D;

View File

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

View File

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

View File

@@ -1,6 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ t.export_success_title }} - {{ config.APP_NAME }}{% endblock %} {% block title %}{{ t.export_success_title }} - {{ config.APP_NAME }}{% endblock %}
{% block head %}
<meta name="description" content="{{ t.export_success_title }}">
{% endblock %}
{% block content %} {% block content %}
<main class="container-page" style="max-width:500px;margin:0 auto;padding:4rem 1rem;text-align:center"> <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> <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 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 %} {% block content %}
<main class="container-page py-12"> <main class="container-page py-12">
<div class="card max-w-md mx-auto mt-8 text-center"> <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) }}"> <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 }}"> <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 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 %} {% endblock %}
{% macro slider(name, label, min, max, step, value, tip='') %} {% 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:title" content="{{ t.features_title_prefix }} | {{ config.APP_NAME }}">
<meta property="og:description" content="{{ t.features_meta_desc }}"> <meta property="og:description" content="{{ t.features_meta_desc }}">
<meta property="og:image" content="{{ url_for('static', filename='images/planner-screenshot.png', _external=True) }}"> <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 %} {% endblock %}
{% block content %} {% block content %}

View File

@@ -26,9 +26,10 @@ STATIC_PATHS = [
"/imprint", "/imprint",
"/suppliers", "/suppliers",
"/markets", "/markets",
"/market-score", "/padelnomics-score",
"/planner/", "/planner/",
"/directory/", "/directory/",
"/opportunity-map",
] ]
@@ -65,16 +66,16 @@ async def _generate_sitemap_xml(base_url: str) -> str:
for lang in LANGS: for lang in LANGS:
entries.append(_url_entry(f"{base}/{lang}{path}", alternates)) 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. # Published articles — both lang variants with accurate lastmod.
# Exclude noindex articles (thin data) to keep sitemap signal-dense. # 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( 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 FROM articles
WHERE status = 'published' AND noindex = 0 AND published_at <= datetime('now') 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""" LIMIT 25000"""
) )
for article in articles: for article in articles:

View File

@@ -29,15 +29,16 @@
<link rel="alternate" hreflang="de" href="{{ config.BASE_URL }}/de{{ path_suffix }}"> <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 }}"> <link rel="alternate" hreflang="x-default" href="{{ config.BASE_URL }}/en{{ path_suffix }}">
{% endif %} {% 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:title" content="{{ config.APP_NAME }}">
<meta property="og:description" content=""> <meta property="og:description" content="">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:url" content="{{ config.BASE_URL }}{{ request.path }}"> <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 property="og:image" content="{{ url_for('static', filename='images/logo.png', _external=True) }}">
<meta name="twitter:card" content="summary_large_image"> {% endblock %}
<script>window.__GEO = {country: "{{ user_country }}", city: "{{ user_city }}"};</script>
{% block head %}{% endblock %}
</head> </head>
<body> <body>
<nav class="nav-bar" id="main-nav"> <nav class="nav-bar" id="main-nav">