Feature 1 — group_key for static article admin grouping:
- Migration 0020: group_key TEXT column + index on articles table
- _sync_static_articles(): auto-upserts data/content/articles/*.md on
every /admin/articles load, reads cornerstone → group_key
- _get_article_list_grouped(): COALESCE(group_key, url_path) as group_id,
so EN/DE static cornerstones pair into one row (pSEO unchanged)
Feature 2 — Email-gated State of Padel report PDF:
- data/content/articles/state-of-padel-q1-2026-{en,de}.md → reports/
- New reports/ blueprint: GET/POST /<lang>/reports/<slug> (email gate),
GET /<lang>/reports/<slug>/download (PDF serve)
- Premium PDF: full-bleed navy cover, Padelnomics wordmark watermark at
3.5% opacity (position:fixed, every page), gold/teal accents, Georgia
headings, WeasyPrint CSS3 (no JS)
- make report-pdf target to build PDFs
- i18n EN + DE (26 keys each, native German via linguistic-mediation)
- /reports added to RESERVED_PREFIXES, data/content/reports/_build/ gitignored
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- report.css: full-bleed navy cover, Padelnomics logo watermark at 3.5%
opacity (position:fixed, repeats every page), gold/teal accents, Georgia
headings, running headers via CSS named strings, metric boxes, insight-box
- report.html: Jinja2 template with cover stats, TOC, body, disclaimer
- build_report_pdf.py: builds EN+DE PDFs from data/content/reports/*.md
(WeasyPrint, mistune, PyYAML; reads logo as file:// URI for watermark)
- Makefile: report-pdf target
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- _sync_static_articles(): auto-upserts data/content/articles/*.md into
DB on every /admin/articles load; reads cornerstone → group_key
- _get_article_list_grouped(): now groups by COALESCE(group_key, url_path)
so static EN/DE cornerstone articles pair into one row
- articles() route: calls _sync_static_articles() before listing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Raises supply gap ceiling from 4/100k to 8/100k in
location_opportunity_profile.sql. The original 4/100k hard cliff
truncated opportunity scores to 0 for any city with ≥4 courts/100k,
but our data undercounts ~87% of real courts (FIP: 17,300 Spanish
courts vs 2,239 in our DB). Raising to 8/100k gives a gentler gradient
and fairer partial credit when density data is incomplete.
Documents existing formula behaviour discovered during analysis:
- Income PPS: country-level constants (18k-37k range) saturate the
/200 ceiling — all EU countries get flat 20/20 pts until city-level
income data lands.
- Catchment NULL: DuckDB LEAST(1.0, NULL) = 1.0 (ignores nulls), so
NULL nearest_padel_court_km already yields full 15 pts. COALESCE
fallback is dead code but harmless.
- Tennis courts within 25km: dim_locations data is empty (all 0 rows)
— 10-court threshold is correct for when data arrives, contributes
0 pts everywhere for now.
Effective score impact: minimal (99% of locations have 0 courts/100k,
so supply gap was already at max). Only ~1,050 dense-court cities
see a score increase (from 0 gap pts to partial gap pts).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both DE + EN language variants. All additions wrapped in {% if avg_opportunity_score %}
guards for graceful degradation.
Changes per language:
- Stats strip: avg Opportunity Score as 5th item (with auto-fit CSS now supporting this)
- Market Landscape section: paragraph on opportunity interplay (high opp + low market =
first-mover signal; high both = proven demand + open sites)
- New section: "Top Locations by Investment Potential" — table of top_opportunity_names
(distinct from top Market Score cities)
- New FAQ: explains Market Score vs Opportunity Score difference (avg values used)
DE copy written with linguistic mediation — native investor register, Du-form.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Both DE + EN language variants. All additions wrapped in {% if opportunity_score %}
guards so cities without a GeoNames match degrade gracefully (score hidden).
Changes per language:
- Stats strip: Opportunity Score item after Market Score (same green/orange/red thresholds)
- Intro paragraph: contextual sentence with supply-gap / white-space interpretation
- Market Overview table: Opportunity Score row
- New FAQ: explains the difference between Market Score (maturity) and Opportunity Score
(investment potential / supply gap)
DE copy written with linguistic mediation — native investor register, Du-form,
avoids calque from English.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Change from repeat(4, 1fr) to repeat(auto-fit, minmax(140px, 1fr)) so the
stats strip accommodates both 4-item (country overview) and 5-item (city
articles with opportunity score) layouts without breaking smaller widths.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- dim_cities: add geoname_id to geonames_pop CTE and final SELECT
Creates FK between dim_cities (city-with-padel-venues) and dim_locations (all GeoNames),
enabling joins to location_opportunity_profile for the first time.
- city_market_profile: pass geoname_id through base CTE and final SELECT
- pseo_city_costs_de: LEFT JOIN location_opportunity_profile on (country_code, geoname_id),
add opportunity_score to output columns
- pseo_country_overview: add avg_opportunity_score, top_opportunity_score, top_opportunity_slugs,
top_opportunity_names aggregates
Cities with no GeoNames name match get opportunity_score = NULL; templates guard with
{% if opportunity_score %}.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Simpler, clearer two-level navigation:
- Sidebar: 9 flat section-level links (no toggling), active at section level
- Horizontal subnav: compact tab strip renders above content for sections
with multiple pages (Marketplace, Content, Email, System)
- Single-page sections (Dashboard, Suppliers, Billing, Analytics, Pipeline)
get no subnav — one click, you're there
- Sidebar active state uses active_section not admin_page, so any sub-page
correctly highlights its parent section
- Zero JS beyond the existing confirm dialog
- Unread badge remains on Email sidebar item + Inbox subnav tab
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Admin articles list:
- Group EN/DE language variants into a single row (grouped by url_path)
- Language chips (● EN/● DE) coloured by status: green=live, amber=scheduled, blue=draft
- Inline View ↗ (live only) and Edit buttons per variant — one-click access
- Filter by language switches back to flat single-row view
- Live HTMX polling of article counts while generation runs (every 3s, self-terminates)
- Table overflow fix: card gets overflow:hidden, table wrapped in overflow-x:auto scroll div
Bug fixes:
- X-Forwarded-Proto: pass $http_x_forwarded_proto through Nginx so Quart sees https
- pipeline_routes.py: fix relative import for analytics module (from .analytics → from ..analytics)
- Scheduled articles: redirect to parent path instead of 404 when not yet published
- city-cost-de: change priority_column from population to padel_venue_count
- Quote wizard step 4: make location_status required
- Article generation: use COUNT(*) instead of 501-sentinel hack for row counts
- Makefile: pin Tailwind v4.1.18, add dev/help targets, uv run python, .PHONY
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Read-only overview of all Paddle products with live metrics:
- Stats cards: active subscriptions, estimated MRR (yearly÷12),
active boosts, completed business plan exports
- Products grouped by category: Supplier Plans, Planner Plans,
Boosts (sub + one-time), Credit Packs, One-time Products
- Per-product: name, key, price, type badge, active count, Paddle IDs
- Empty-state message when paddle_products table is unpopulated
- PRODUCT_CATEGORIES constant in routes.py defines grouping + ordering
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces flat 20-link sidebar with collapsible section groups:
- Multi-item sections (Marketplace, Content, Email, System) are
collapsible with animated chevron; active section always expands
- Single-item sections (Dashboard, Suppliers, Billing, Analytics,
Pipeline) render as direct links — no toggle overhead
- pSEO merged into Content; Users moved into System; new Billing slot
- Unread badge surfaces on Email group header when collapsed
- localStorage persists per-section open/closed state (key: admin_sidebar_v1)
- Mobile: group headers hidden, all items shown in horizontal scroll
(preserves existing mobile behavior exactly)
- section_map Jinja dict derives active_section from existing admin_page
— no route changes needed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Articles now live at data/content/articles/{slug}.md — this is the path the
admin CMS reads from (admin/routes.py:1861) when rebuilding manual articles
via the publish pipeline.
Marketing assets moved to marketing/ at the project root.
All 14 article files (C2–C8 + C4 DE/EN) and 4 marketing files relocated from
scratch/ where they never belonged.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
C4 articles:
- scratch/articles/state-of-padel-q1-2026-de.md — German State of Padel Q1 2026
report (~2,500w); DE version front-loads Germany section; Wirtschaftsjournalismus
register; FIP + Playtomic/PwC + Padelnomics pipeline data embedded
- scratch/articles/state-of-padel-q1-2026-en.md — English adaptation (~2,500w);
Germany as case study; international audience framing
Marketing assets:
- scratch/marketing/founding-member-deal.md — founding member deal structure
(20 slots, €990/yr locked 3 years, Professional tier at Basic price, +rationale)
- scratch/marketing/supplier-outreach-emails.md — 3 email templates × DE + EN
(cold intro, founding member pitch, day-7 follow-up); Sie-register throughout
- scratch/marketing/linkedin-posts-launch-week.md — 5 DE launch-week posts
(<300w each, max 5 hashtags, company-page appropriate)
- scratch/marketing/linkedin-approach.md — company page setup guide + engagement
strategy (no personal exposure, supplier tagging, SEO backlink approach)
Data sources used: FIP WPR 2024/2025, Playtomic/PwC Global Padel Report 2025,
Padelnomics DuckDB pipeline (12,441 venues / 80 countries / 5,492 cities).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
878 → 4212 cities. Broadens coverage to match the granularity of
Eurostat and GeoNames data for smaller metro markets.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
85K text extraction of the gated 39MB PDF (already in ~/Downloads).
Notes PDF location in README. Removes landing page placeholder.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Moves brief into research/state-of-padel-q1-2026/ and adds source files:
- FIP 2024 PDF (8 MB)
- Extracted text from both FIP 2024 + 2025 PDFs
- README with download link for 2025 PDF (44 MB, not committed)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
FIP 2024 + 2025 report data: player population, courts, federations,
prize pools, broadcast stats, and data source scrapeability assessment.
Raw PDF text at /tmp/fip_2024_text.txt and /tmp/fip_2025_text.txt.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add Shift+Enter shortcut to execute query (alongside Cmd/Ctrl+Enter)
- Add ▶ preview button to schema sidebar tables: populates editor with
SELECT * FROM serving.<table> LIMIT 100 and auto-submits
- Update hint text to show "Shift+Enter to run"
- Overview tab: fall back to information_schema when _serving_meta.json
is absent instead of showing error message; row counts show "—"
- Dashboard stat cards: same fallback — query DuckDB for table count
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace single hardcoded Chrome 131 UA with:
- BOT_UA: honest padelnomics-bot UA for Overpass, Eurostat, GeoNames etc.
- _UA_POOL + ua_for_proxy(): deterministic browser UA per proxy URL so each
IP presents a consistent, distinct fingerprint across runs.
Public-API extractors (shared session, no proxy) now send BOT_UA.
Playtomic extractors (proxy-backed) each get a stable pool UA keyed on
their proxy URL hash.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds OVERPASS_MIRRORS list (overpass-api.de, kumi.systems, openstreetmap.ru)
and a post_overpass() helper in _shared.py that tries mirrors in order,
logging a warning on each failure and re-raising the last RequestException
if all mirrors fail. Both overpass.py and overpass_tennis.py now call
post_overpass() instead of hard-coding the primary URL.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- outreach_import(): contact_email was extracted + used for dedup but
missing from the INSERT — added it to the column list and values tuple
- test_import_creates_prospects: strengthen to assert contact_email is
actually persisted (regression test for the above bug)
- dev_run.sh: after server ready, open incognito/private browser window
at dev-login URL; tries google-chrome → chromium → firefox in order
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>