- stg_regional_income: expanded NUTS-1+2 (LENGTH IN 3,4), nuts_code rename, nuts_level - stg_nuts2_boundaries: new — ST_Read GISCO GeoJSON, bbox columns for spatial pre-filter - stg_income_usa: new — Census ACS state-level income staging model - dim_locations: spatial join replaces admin1_to_nuts1 VALUES CTE; us_income CTE with PPS normalisation (income/80610×30000); income cascade: NUTS-2→NUTS-1→US state→country - init_landing_seeds: compress=False for ST_Read files; gisco GeoJSON + census income seeds - CHANGELOG + PROJECT.md updated Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
107 KiB
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog.
[Unreleased]
Added
-
Phase 2a — NUTS-1 regional income differentiation (
opportunity_score): Munich and Berlin no longer share the same income figure as Chemnitz.eurostat.py: addednama_10r_2hhincdataset config (NUTS-2 cube with NUTS-1 entries); filter params now appended to API URL so the server pre-filters the large cube before download (also makesilc_di03requests smaller).stg_regional_income.sql: new staging model — readsnama_10r_2hhinc.json.gz, filters to 3-char NUTS-1 codes, normalisesEL→GR/UK→GB. Grain:(nuts1_code, ref_year).dim_locations.sql:admin1_to_nuts1VALUES CTE (16 German Bundesländer mapping GeoNamesadmin1_code→ NUTS-1) +regional_incomeCTE (latest year per region). Final SELECT:COALESCE(regional_income_pps, country_income_pps) AS median_income_pps— all downstream consumers (location_opportunity_profile,opportunity_score) work unchanged.init_landing_seeds.py: seed entry foreurostat/1970/01/nama_10r_2hhinc.json.gz.- Verified income spread: Bayern (DE2) ~29K PPS > Hamburg (DE6) ~27K > Berlin (DE3) ~24K > Sachsen (DED) ~19K PPS. Non-mapped countries (ES, FR, IT) continue with country-level fallback.
-
Phase 2b — EU NUTS-2 spatial join + US state income (
dim_locations): all EU-27 + EFTA + UK locations now resolve to their NUTS-2 region automatically via a spatial join; US locations now use Census ACS state-level income instead of a flat country fallback.stg_regional_income.sql: expanded from NUTS-1 only (LENGTH = 3) to NUTS-1 + NUTS-2 (LENGTH IN (3,4)); column renamednuts1_code → nuts_code; addednuts_levelderived column (1 or 2).scripts/download_gisco_nuts.py: new one-time download script for NUTS-2 boundary GeoJSON from Eurostat GISCO (NUTS_RG_20M_2021_4326_LEVL_2.geojson, ~5 MB, NUTS revision 2021). Saves uncompressed —ST_Readcannot read.gz.stg_nuts2_boundaries.sql: new staging model — reads GeoJSON viaST_Read; extractsnuts2_code,country_code,geometry, and pre-computed bbox columns (bbox_lat_min/max,bbox_lon_min/max) for spatial pre-filter; normalisesEL→GR/UK→GB. Grain:nuts2_code.census_usa_income.py: new extractor — fetchesB19013_001E(median household income) at state level from Census ACS 5-year; saves tocensus_usa/{year}/{month}/acs5_state_income.json.gz; registered inall.pyandpyproject.toml.stg_income_usa.sql: new staging model for US state income. Grain:(state_fips, ref_year). Income kept in nominal USD — PPS conversion happens indim_locations.dim_locations.sql: replacedadmin1_to_nuts1VALUES CTE (16 DE rows) with full spatial join:nuts2_match(bbox pre-filter +ST_Contains) →nuts2_income/nuts1_income(latest year per level) →regional_income(COALESCE NUTS-2 → NUTS-1). Addedus_state_fips(51-row VALUES CTE, admin1 abbreviation → FIPS) +us_income(PPS normalisation:state_income / 80610.0 × 30000.0). Final income cascade: EU NUTS-2 → EU NUTS-1 → US state → country-level. Germany now resolves to 38 Regierungsbezirke; Spain, France, Italy, Netherlands etc. all get NUTS-2 differentiation automatically.init_landing_seeds.py:create_seedextended withcompress=Falsefor files consumed byST_Read(cannot read.gz); addedcensus_usa/1970/01/acs5_state_income.json.gzseed and uncompressedgisco/1970/01/nuts2_boundaries.geojsonempty-FeatureCollection seed.
Changed
- Opportunity Score v2 — income ceiling fix (
location_opportunity_profile.sql): income PPS normalisation changed from/200.0(caused LEAST(1.0, 115)=1.0 for ALL countries — no differentiation) to/35000.0with country-spread-matched ceiling. Default for missing data changed from 100 to 15000 (developing-market assumption). Country scores now reflect real PPS spread: LU 20.0, SE 14.3, DE 13.2, ES 10.7, GB 10.5 pts (was 20.0 everywhere). - dim_cities population coverage 70.5% → 98.5% — added GeoNames spatial fallback CTE that finds the nearest GeoNames location within ~15 km when string name matching fails (~29% of cities). Fixes localization mismatches (Milano≠Milan, Wien≠Vienna, München≠Munich): Wien 0→1,691,468; Milano 0→1,371,498. Population cascade now: Eurostat EU > US Census > ONS UK > GeoNames string > GeoNames spatial > 0.
Added
-
overpass_tennis workflow added to
infra/supervisor/workflows.toml— tennis courts extraction was only in the combinedall.pyextractor; now scheduled monthly by the supervisor so it runs automatically in production. -
Market Score v3 (Marktreife-Score recalibration) — fixes ranking inversion where early-stage markets (Germany 1/100k) outscored mature markets (Spain 36/100k):
- Formula rewrite (
city_market_profile.sql): supply development now 40 pts (log-scaled density LN(d+1)/LN(21) × count gate min(1,count/5)); demand evidence 25 pts (occupancy or 40% density proxy); population reduced to 15 pts (context); income to 10 pts (context); data quality to 10 pts; saturation discount removed - Count gate eliminates small-town inflation: a single venue in a 5k-resident town can no longer outscore Berlin (was 92.7 → now 43.9 for Bernau bei Berlin)
- LN ceiling at 20/100k (was linear 4/100k) gives meaningful differentiation from 0 to 20: Málaga 70.1, Barcelona 67.4, Madrid 66.9, Amsterdam 58.4, Berlin 42.2, London 44.1
- Template thresholds updated across all 3 pSEO templates (city-cost-de, country-overview, city-pricing): color coding green ≥55 (was ≥65) / amber ≥35 (was ≥40); intro/FAQ tiers strong ≥55 (was ≥70) / mid ≥35 (was ≥45); white-space signal interplay market_score < 40 (was < 50)
- Formula rewrite (
-
Opportunity Score supply gap ceiling raised 4→8/100k (
location_opportunity_profile.sql) — gentler gradient for partially-served markets; accounts for ~87% data undercount vs FIP real-world totals. Documents discovered formula behaviour: DuckDBLEAST(1.0, NULL)=1.0means NULL catchment already yields full 15 pts; income PPS saturates for all EU countries; tennis courts data currently empty (formula correct, data pending)
Added
-
Opportunity Score integration — second scoring dimension (
Marktpotenzial) now visible in city and country articles:- SQL chain:
dim_citiesnow carriesgeoname_id(from the existing GeoNames LEFT JOIN); threaded throughcity_market_profile→pseo_city_costs_dewhich LEFT JOINslocation_opportunity_profileon(country_code, geoname_id);pseo_country_overviewgainsavg_opportunity_score,top_opportunity_score,top_opportunity_slugs,top_opportunity_names - 71.4% match rate — 3,350 of 4,693 cities matched to a GeoNames
geoname_id; unmatched cities gracefully show no Opportunity Score - City articles (
city-cost-de.md.jinja) —{% if opportunity_score %}guard adds: 5th stats-strip item with green/amber/red color coding (≥65/≥40/<40), contextual intro sentence explaining the score interplay, table row in Market Overview, score explainer FAQ (DE + EN) - Country overview articles (
country-overview.md.jinja) — adds:avg_opportunity_scoreas 5th stats-strip item, opportunity interplay paragraph in market landscape section, "Top Locations by Investment Potential" table (distinct from top Market Score cities), score explainer FAQ (DE + EN) - CSS: stats-strip changed from
repeat(4, 1fr)torepeat(auto-fit, minmax(140px, 1fr))— supports 4-item country and 5-item city strips without layout breakage
- SQL chain:
-
Pipeline Console admin section — full operational visibility into the data engineering pipeline at
/admin/pipeline/:- Overview tab — extraction status grid (one card per workflow with status dot, schedule, last-run timestamp, error preview), serving table row counts from
_serving_meta.json, landing zone file stats (per-source file count + total size) - Extractions tab — filterable, paginated run history table from
.state.sqlite(extractor + status dropdowns, HTMX live filter); stale "running" row detection (amber highlight) with "Mark Failed" button; "Run All Extractors" button enqueuesrun_extractiontask - Catalog tab — accordion list of serving tables with row count badges; click-to-expand lazy-loads column schema + 10-row sample data per table
- Query editor tab — dark-themed SQL textarea (
Commit Mono, navy background, electric blue focus glow); schema sidebar (collapsible table/column list with types); Tab-key indent and Cmd/Ctrl+Enter submit; results table with sticky headers + row count + elapsed time; query security (read-only DuckDB, blocklist regex, 10k char limit, 1000 row cap, 10s timeout) analytics.execute_user_query()— new function returning(columns, rows, error, elapsed_ms)for admin query editorworker.run_extractiontask — background handler shells out touv run extractfrom repo root (2h timeout)- 29 new tests covering all routes, data access helpers, security checks, and
execute_user_query()
- Overview tab — extraction status grid (one card per workflow with status dot, schedule, last-run timestamp, error preview), serving table row counts from
-
Outreach follow-up scheduling + activity timeline — extends the outreach pipeline (migration 0024):
- Migration 0025 — adds
follow_up_at TEXT DEFAULT NULLtosuppliersandnoindex INTEGER NOT NULL DEFAULT 0toarticles - Follow-up date picker (
POST /admin/outreach/<id>/follow-up) — HTMX date input on each outreach row; sets/clearsfollow_up_at; returns updated row via outerHTML swap - Follow-up due banner on
/admin/outreach— amber alert banner shows count of overdue follow-ups with "Show them" link (?follow_up=duefilter) ?follow_up=due/?follow_up=setfilters inget_outreach_suppliers()— querystring params passed through dashboard and results partialget_follow_up_due_count()query function counts suppliers withfollow_up_at <= date('now')- Activity timeline on
/admin/suppliers/<id>— merges sent outreach emails (email_log WHERE email_type='outreach') and received emails (inbound_emails) matched bycontact_email; sorted by date descending; max 50 entries; empty state shown when no history - 29 new tests (follow-up CRUD, due count, due filter, timeline with sent+received, timeline empty state)
- Migration 0025 — adds
-
pSEO article noindex — prevents thin-data articles from diluting crawl budget and index quality:
NOINDEX_THRESHOLDSdict incontent/__init__.py— per-template lambda:city-pricing(venue_count < 3),city-cost-de(data_confidence < 1.0),country-overview(total_venues < 5)generate_articles()upsert now evaluates the threshold and storesnoindex = 1for articles that fail it; existing articles are updated on re-generation<meta name="robots" content="noindex, follow">injected inarticle_detail.htmlhead block whenarticle.noindexis truthy- Sitemap exclusion —
sitemap.pyarticles query addsAND noindex = 0; thin-data articles excluded fromsitemap.xml - pSEO dashboard noindex card — 4th summary card shows count of noindex articles (amber highlight when > 0)
- Article row noindex badge — amber pill badge on
partials/article_row.htmlwhena.noindex - 20 new tests (threshold unit tests per template, sitemap exclusion, article detail robots meta tag)
-
Outreach pipeline — cold B2B supplier outreach isolated from transactional emails:
- Separate sending domain (
hello.padelnomics.io) — added"outreach"key toEMAIL_ADDRESSES; reputation isolated fromnotifications.padelnomics.iomagic-link/lead-forward traffic (manual DNS step: add domain in Resend dashboard) - Migration 0024 — 4 new columns on
suppliers:outreach_status,outreach_notes,last_contacted_at,outreach_sequence_step;NULLstatus = not in pipeline (no backfill needed for existing suppliers) - Admin outreach pipeline tab (
/admin/outreach) — 6 pipeline cards (prospect → contacted → replied → signed_up → declined → not_interested) with click-to-filter; HTMX-powered supplier table with inline status dropdown + note editing; sidebar link added - HTMX endpoints —
POST /admin/outreach/<id>/statusreturns updated row;POST /admin/outreach/<id>/notereturns truncated note text - Bulk add-to-pipeline — checkbox column on
/admin/suppliers, "Add to Outreach Pipeline" form action →POST /admin/outreach/add-prospects; skips suppliers already in pipeline - CSV import (
GET/POST /admin/outreach/import) — uploads CSV (name,contact_emailrequired;country_code,category,websiteoptional); creates new supplier rows asprospect; auto-generates slug; deduplicates bycontact_email; capped at 500 rows - Compose integration —
GET /admin/emails/composenow accepts?from_key=outreach&email_type=outreach&supplier_id=<id>query params; pre-selects outreach from-address and unchecks HTML wrap (plain text best practice for cold email); on successful send withemail_type=outreach+supplier_id, auto-updates supplier:prospect→contacted,last_contacted_at=now,outreach_sequence_step+1 - Supplier detail outreach card — shown when supplier is in the outreach pipeline; displays status, step, last contact date, notes, and "Send Outreach Email" compose link
- 44 new tests in
web/tests/test_outreach.py
- Separate sending domain (
-
Email template system — all 11 transactional emails migrated from inline f-string HTML in
worker.pyto Jinja2 templates:- Standalone renderer (
email_templates.py) —render_email_template()uses a module-leveljinja2.Environmentwithautoescape=True, works outside Quart request context (worker process);tformatfilter mirrors the one inapp.py _base.html— branded shell (dark header, 3px blue accent, white card body, footer with tagline + copyright); replaces the old_email_wrap()helper_macros.html— reusable Jinja2 macros:email_button,heat_badge,heat_badge_sm,section_heading,info_box- 11 email templates:
magic_link,quote_verification,welcome,waitlist_supplier,waitlist_general,lead_matched,lead_forward,lead_match_notify,weekly_digest,business_plan,admin_compose EMAIL_TEMPLATE_REGISTRY— dict mapping slug →{template, label, description, email_type, sample_data}with realistic sample data callables for each template- Admin email gallery (
/admin/emails/gallery) — card grid of all email types; preview page with EN/DE language toggle renders each template in a sandboxed iframe (srcdoc); "View in sent log →" cross-link; gallery link added to admin sidebar - Compose live preview — two-column compose layout: form on the left, HTMX-powered preview iframe on the right;
hx-trigger="input delay:500ms"on the textarea;POST /admin/emails/compose/previewendpoint supports plain body or branded wrapper viawrapcheckbox - 50 new tests covering all template renders (EN + DE), registry structure, gallery routes (access control, list, preview, lang fallback), and compose preview endpoint
- Standalone renderer (
-
JSONL streaming landing format — extractors now write one JSON object per line (
.jsonl.gz) instead of a single large blob, eliminating in-memory accumulation andmaximum_object_sizeworkarounds:playtomic_tenants.py→tenants.jsonl.gz(one tenant per line; dedup still happens in memory before write)playtomic_availability.py→availability_{date}.jsonl.gz(morning) +availability_{date}_recheck_{HH}.jsonl.gz(recheck); one venue per line withdate/captured_at_utc/recheck_hourinjectedgeonames.py→cities_global.jsonl.gz(one city per line; eliminates 30 MB blob and itsmaximum_object_sizeworkaround)compress_jsonl_atomic(jsonl_path, dest_path)utility added toutils.py— streams compression in 1 MB chunks, atomic.tmprename, deletes source
-
Regional Overpass splitting for tennis courts — replaces single global query (150K+ elements, timed out) with 10 regional bbox queries (~10-40K elements each, 150s server / 180s client):
- Regions: europe_west, europe_central, europe_east, north_america, south_america, asia_east, asia_west, oceania, africa, asia_north
- Per-region retry (2 attempts, 30s cooldown) + 5s inter-region polite delay
- Crash recovery via
working.jsonlaccumulation — already-written element IDs skipped on restart; completed regions produce 0 new elements on re-query - Output:
courts.jsonl.gz(one OSM element per line)
-
scripts/init_landing_seeds.py— creates minimal.jsonl.gzand.json.gzseed files in1970/01/so SQLMesh staging models can run before real extraction data arrives; idempotent
Changed
- All modified staging SQL models use UNION ALL transition CTEs — both JSONL (new) and blob (old) formats are readable simultaneously; old
.json.gzfiles in the landing zone continue working until they rotate out naturally:stg_playtomic_venues,stg_playtomic_resources,stg_playtomic_opening_hours— JSONL top-level columns (noUNNEST(tenants))stg_playtomic_availability— JSONL morning + recheck files; blob morning + recheck kept for transitionstg_population_geonames— JSONL city rows (noUNNEST(rows), nomaximum_object_size)stg_tennis_courts— JSONL elements withCOALESCE(lat, center.lat)for way/relation centre coords; blob UNNEST kept for old files
Removed
-
_email_wrap()and_email_button()helper functions removed fromworker.py— replaced by templates -
Marketplace admin dashboard (
/admin/marketplace) — single-screen health view for the two-sided market:- Lead funnel — total / verified-new (ready to unlock) / unlocked / won / conversion rate
- Credit economy — total credits issued, consumed (lead unlocks), outstanding balance across all paid suppliers, 30-day burn rate
- Supplier engagement — active paid supplier count, avg lead unlocks per supplier, forward response rate
- Feature flag toggles —
lead_unlockandsupplier_signupflags togglable inline; sidebar nav entry added - Live activity stream (HTMX partial) — last 50 events across leads, unlocks, and credit ledger in a single feed
-
Lead matching notifications (
notify_matching_suppliersworker task) — on quote verification, finds growth/pro suppliers whoseservice_areaincludes the lead's country and sends an instant alert email; bounded to 20 suppliers per lead -
Weekly lead digest (
send_weekly_lead_digestworker task) — every Monday at 08:00 UTC, sends paid suppliers a summary table of new matching leads from the past 7 days they haven't unlocked yet (max 5 rows per email) -
One-click CTA token — lead-forward emails now include a "Mark as contacted" footer link backed by a unique
cta_token; clicking it sets the forward status tocontactedand redirects to the supplier dashboard; token stored onlead_forwardsafter send -
Supplier
lead_respondendpoint — HTMX status update for forwarded leads:sent / viewed / contacted / quoted / won / lost / no_response -
Supplier
lead_cta_contactedendpoint (/suppliers/leads/cta/<token>) — one-click email handler; idempotent (only advances fromsent→contacted) -
Migration 0022 — adds
status_updated_at,supplier_note,cta_tokentolead_forwards; unique partial index oncta_token -
Admin leads list improvements — summary cards (total / new+unverified / hot pipeline credits / forward rate); text search across name, email, company; period filter pills (Today / 7d / 30d / All);
get_leads()now returns(rows, total_count)and supportssearch+daysparams -
Admin lead detail — HTMX inline actions — status change returns an updated status badge partial; forward-to-supplier form returns an updated forward history table; no full-page reload
-
Quote form extended — captures
build_context,glass_type,lighting_type,location_status,financing_status,services_needed,additional_info; displayed in lead detail view -
pSEO Engine admin tab (
/admin/pseo) — operational visibility for the programmatic SEO system:- Content gap detection — queries DuckDB serving tables vs SQLite articles to find rows with no matching article per language; per-template HTMX-loaded gap list
- Data freshness signals — compares
_serving_meta.jsonexport timestamp vsMAX(updated_at)in articles; per-template status: 🟢 Fresh / 🟡 Stale / 🟣 No articles / ⚫ No data - Article health checks (HTMX partial) — hreflang orphans (EN exists, DE missing), missing HTML build files, broken
[scenario:slug]references in article markdown - Generation job monitoring — live progress bars polling every 2s while jobs run; stops polling on completion; error drilldown via
<details>; dedicated/admin/pseo/jobslist page _serving_meta.json— written byexport_serving.pyafter atomic rename; recordsexported_at_utcand per-table row counts; drives freshness signals in pSEO Engine dashboard- Progress tracking columns on
taskstable (migration 0021):progress_current,progress_total,error_log;generate_articles()writes progress every 50 articles and on completion - 45 new tests covering all health functions + pSEO routes (access control, rendering, gap detection, generate-gaps POST, job status HTMX polling)
-
Dual market score system — split the single market score into two branded scores:
- padelnomics Marktreife-Score™ (market maturity): existing score, refined — only for cities
with ≥1 padel venue. Adds ×0.85 saturation discount when
venues_per_100k > 8. - padelnomics Marktpotenzial-Score™ (investment opportunity): new score covering ALL GeoNames locations globally (pop ≥1K), including zero-court locations. Rewards supply gaps, underserved catchment areas, and racket sport culture via inverted venue density signal.
- padelnomics Marktreife-Score™ (market maturity): existing score, refined — only for cities
with ≥1 padel venue. Adds ×0.85 saturation discount when
-
Tennis court Overpass extractor —
extract-overpass-tennisdownloads all OSMsport=tennisnodes/ways/relations globally (~150K+ features). Lands atoverpass_tennis/{year}/{month}/courts.json.gz. Staged instg_tennis_courts. -
foundation.dim_locations— new conformed dimension seeded from GeoNames (all locations ≥1K pop), not from padel venues. Grain(country_code, geoname_id). Enriched with:nearest_padel_court_kmviaST_Distance_Sphere(DuckDB spatial extension)padel_venue_count/padel_venues_per_100k(venues within 5km)tennis_courts_within_25km(courts within 25km)
-
GeoNames expanded — extractor switched from
cities15000(50K+ filter, ~24K rows) tocities1000(~140K locations, pop ≥1K). Addedlat,lon,admin1_code,admin2_codeto output. Expanded feature codes to includePPLA3/4/5(Gemeinden/cantons). -
DuckDB spatial extension —
extensions: [spatial]added toconfig.yaml. EnablesST_Distance_Spherefor great-circle distance and future map features (bounding box queries, geometry columns). -
SOPS secrets —
GEONAMES_USERNAME=padelnomicsandCENSUS_API_KEYadded to both.env.dev.sopsand.env.prod.sops. -
Crash-safe partial JSONL —
utils.load_partial_results()andflush_partial_batch()provide a generic opt-in mechanism for incremental progress flushing during long extractions. Any extractor processing items one-by-one can flush every N records and resume from a.partial.jsonlsidecar file after a crash. -
Methodology page updated —
/en/market-scorenow documents both scores with: Two Scores intro section, component cards for each score (4 Marktreife + 5 Marktpotenzial), score band interpretations, expanded FAQ (7 entries). Section headings use the padelnomics wordmark span (Bricolage Grotesque). Bilingual EN + DE (native-quality German, no calques). -
Market Score methodology page — standalone page at
/{lang}/market-scoreexplaining the padelnomics Market Score (Zillow Zestimate-style). Reveals four input categories (demographics, economic strength, demand evidence, data completeness) and score band interpretations without exposing weights or formulas. Full JSON-LD (WebPage + FAQPage + BreadcrumbList), OG tags, and bilingual content (EN professional, DE Du-form). Added to sitemap and footer. First "padelnomics Market Score" mention in each article template now links to the methodology page (hub-and-spoke internal linking).
Changed
EXTRACT_WORKERSenv var removed — worker count is now derived fromPROXY_URLSlength (one worker per proxy). No proxies → single-threaded. No manual tuning needed.- Playtomic tenants extractor — parallel batch page fetching when proxies are configured. Each page in a batch fires concurrently using its own session + proxy. Expected speedup: ~2.5 min → ~15 s with 10 Webshare datacenter proxies.
- Playtomic availability extractor — three performance changes:
- No per-request
time.sleep()on success when a proxy is active (throttle only when running direct). Retry/backoff sleeps for 429 and 5xx responses are unchanged. - Worker count auto-detected from proxy count (drops
EXTRACT_WORKERS). - True crash resumption via
.partial.jsonlsidecar: progress flushed every 50 venues, resume skips already-fetched venues and merges prior results into the final file.
- No per-request
- Lead-Back Guarantee — suppliers can claim credits back for non-responding leads
with one click after 3 business days. Route
POST /suppliers/leads/<id>/guarantee-claim,refund_lead_guarantee()in credits.py, "Lead didn't respond" button on unlocked lead cards (visible 3–30 days after unlock). Migration 0020 addsguarantee_claimed_atandguarantee_contact_methodcolumns tolead_forwards. - Supplier page CRO restructure —
/supplierspage reordered to lead with value before pricing (Why Padelnomics → Lead-Back Guarantee → lead preview → social proof → pricing). All CTAs changed from "See Plans & Pricing" to "Get Started Free". - Static ROI line — one-sentence ROI callout near pricing grounded in
research/padel-hall-economics.mddata (4-court project = €30K+ contractor profit). - Credits-only callout — below pricing grid: "Not ready for a subscription? Buy a credit pack and unlock leads one at a time."
Fixed
-
datetime.utcnow()deprecation warnings — replaced all 94 occurrences across 22 files (source + tests) withutcnow()/utcnow_iso()helpers fromcore.py.utcnow_iso()producesYYYY-MM-DD HH:MM:SS(space separator) matching SQLite'sdatetime('now')format so lexicographic SQL comparisons stay correct.datetime.utcfromtimestamp()inseo/_bing.pyalso replaced withdatetime.fromtimestamp(ts, tz=UTC). Zero deprecation warnings remain. -
Credit ledger ordering —
get_ledger()now usesORDER BY created_at DESC, id DESCto preserve insertion order when multiple credits are added within the same second. -
Double language prefix in article URLs — articles were served at
/en/en/markets/italy(double prefix) becausegenerate_articles()storedurl_pathwith the lang prefix baked in, but the blueprint is already mounted at/<lang>. Nowurl_pathis stored without the prefix; canonical URLs, breadcrumbs, sitemap, and admin links all generate correct single-prefix URLs. -
/marketsremoved from RESERVED_PREFIXES — pSEO articles live under/markets/and the explicit/marketsroute takes priority over the catch-all, so the reservation was blocking article generation. -
country-overviewschema_type — changed from[Article]to[Article, FAQPage]to enable FAQ rich results for existing FAQ content.
Added
- Bilingual pSEO templates (DE + EN) — all 3 article templates
(
city-cost-de,city-pricing,country-overview) now generate proper German prose via{% if language == "de" %}conditionals. German text uses informal "Du/Dein", natural business German (not calque translation), and localized labels/units (€/Std, Hauptzeit/Nebenzeit, etc.). - Expanded English pSEO content — all 3 templates expanded from ~400–900 words to ~1300–1500 words each. Added: Market Context/Landscape sections, analytical commentary after scenario markers, cross-template links (cost ↔ pricing ↔ country), planner links in FAQ answers, second CTA at bottom of each article, 2 additional FAQ questions per template.
- Scenario cross-reference —
city-pricingtemplate now embeds[scenario:city-cost-de-{{ city_key }}:operating]to show operating cost data from the investment analysis template. - CMS admin improvement — articles list now has HTMX filter bar (search,
status, template, language), pagination (50/page), and stats strip
(total/live/scheduled/draft counts). Article actions (publish/unpublish,
delete) are inline HTMX operations — no full page reload. "View" link opens
live articles on the public site. Article generation and rebuild-all now
enqueue to the background worker instead of blocking the HTTP request.
Markdown source is written to disk during generation so the edit form shows
content. Sitemap cache is invalidated when articles are published, deleted,
or created. Fixed broken "Scheduled"/"Published" status display (was always
showing "Scheduled") and stale
template_data_idcolumn reference.
Changed
- Visual test overhaul — consolidated 3 separate Playwright server processes
(ports 5111/5112/5113) into 1 session-scoped fixture in
conftest.py; 77 tests pass in ~59 seconds (was ~3× slower with 3 independent servers). Fixedinit_dbmock bypass (must patchpadelnomics.app.init_db, notcore.init_db, sincefrom .core import init_dbcreates a local binding). ForcesRESEND_API_KEY=""andWAITLIST_MODE=falsein subprocess so visual tests never send real emails or render waitlist pages. Added sections J–N: pricing, checkout, supplier signup, supplier dashboard, business plan export.
Added
- SOPS + age encrypted secrets —
.env.dev.sopsand.env.prod.sopsreplace.env.exampleand GitLab CI/CD variables; age keypair for encryption/decryption;deploy.shauto-decrypts on server;infra/setup_server.shinstalls sops + age and generates server keypair; Makefile targets:secrets-decrypt-dev,secrets-decrypt-prod,secrets-edit-dev,secrets-edit-prod
Removed
-
.env.example— replaced by.env.dev.sops(decrypt withmake secrets-decrypt-dev) -
GitLab CI heredoc that wrote
.envvia SSH — deploy.sh now handles decryption -
Dead
ADMIN_PASSWORDCI variable reference -
Deprecated
WAITLIST_MODEfrom env files (replaced by DB-backed feature flags) -
Python supervisor (
src/padelnomics/supervisor.py) — replacessupervisor.sh; readsinfra/supervisor/workflows.toml(module, schedule, entry, depends_on, proxy_mode); runs due workflows in topological waves (parallel within each wave); croniter-basedis_due()check; systemd service updated to useuv run python -
workflows.tomlworkflow registry — 5 extractors registered: overpass, eurostat, playtomic_tenants, playtomic_availability, playtomic_prices; cron presets: hourly/daily/weekly/monthly;playtomic_availabilitydepends onplaytomic_tenants -
proxy.pyproxy rotation (extract/padelnomics_extract/proxy.py) — readsPROXY_URLSenv var;make_round_robin_cycler()for thread-safe round-robin;make_sticky_selector()for consistent per-tenant proxy assignment (hash-based) -
DB-backed feature flags —
feature_flagstable (migration 0019);is_flag_enabled(name, default)helper;feature_gate(flag, template)decorator replacesWAITLIST_MODE/waitlist_gate; 5 flags seeded:markets(on),payments,planner_export,supplier_signup,lead_unlock(all off) -
Admin feature flags UI —
/admin/flagslists all flags with toggle;POST /admin/flags/toggleflips enabled bit; requires admin role; flash message on unknown flag -
lead_unlockgate —unlock_leadroute returns HTTP 403 whenlead_unlockflag is disabled -
SEO/GEO admin hub — syncs search performance data from Google Search Console (service account auth), Bing Webmaster Tools (API key), and Umami (bearer token) into 3 new SQLite tables (
seo_search_metrics,seo_analytics_metrics,seo_sync_log); daily background sync via worker scheduler at 6am UTC; admin dashboard at/admin/seowith three HTMX tab views: search performance (top queries, top pages, country/device breakdown), full funnel (impressions → clicks → pageviews → visitors → planner users → leads), and per-article scorecard with attention flags (low CTR, no clicks); manual "Sync Now" button; 12-month data retention with automatic cleanup; all data sources optional (skip silently if not configured) -
Landing zone backup to R2 — append-only landing files (
data/landing/*.json.gz) synced to Cloudflare R2 every 30 minutes via systemd timer + rclone; extraction state DB (.state.sqlite) continuously replicated via Litestream (second DB entry in existing config); auto-restore on container startup for bothapp.dband.state.sqlite;infra/restore_landing.shscript for disaster recovery of landing files;infra/landing-backup/systemd service + timer units; rclone installation added toinfra/setup_server.sh; reuses existing R2 bucket and credentials (no new env vars) -
Admin Email Hub (
/admin/emails) — full email management dashboard with: sent log (filterable by type/event/search, HTMX partial updates), email detail with Resend API enrichment for HTML preview, inbound inbox with unread badges and inline reply, compose form with branded template wrapping, and Resend audience management with contact list/remove -
Email delivery tracking —
email_logtable records every outgoing email with resend_id; Resend webhook handler (/webhooks/resend) updates delivery events (delivered, bounced, opened, clicked, complained) in real-time;inbound_emailstable stores received messages with full body -
send_email() returns resend_id — changed return type from
booltostr | None(backward-compatible: truthy string works like True); all 9 worker handlers now passemail_type=for per-type filtering in the log -
Playtomic full data extraction — expanded venue bounding boxes from 4 regions (ES, UK, DE, FR) to 23 globally (Italy, Portugal, NL, BE, AT, CH, Nordics, Mexico, Argentina, Middle East, USA); PAGE_SIZE increased from 20 to 100; availability extractor throttle reduced from 2s to 1s for ~4.5h runtime at 16K venues
-
Playtomic pricing & occupancy pipeline — 4 new staging models:
stg_playtomic_resources(per-court: indoor/outdoor, surface type, size),stg_playtomic_opening_hours(per-day: open/close times, hours_open),stg_playtomic_availability(per-slot: 60-min bookable windows with real prices);stg_playtomic_venuesrewritten to extract all metadata (opening_hours, resources, VAT rate, currency, timezone, booking settings) -
Venue capacity & daily availability fact tables —
fct_venue_capacityderives total bookable court-hours from court_count × opening_hours;fct_daily_availabilitycalculates occupancy rate (1 - available/capacity), booked hours, revenue estimate, and pricing stats (median/peak/offpeak) per venue per day -
Venue pricing benchmarks —
venue_pricing_benchmarks.sqlaggregates last-30-day venue metrics to city/country level: median hourly rate, peak/offpeak rates, P25/P75, occupancy rate, estimated daily revenue, court count -
Real data planner defaults —
planner_defaults.sqlrewritten with 3-tier cascade: city-level Playtomic data → country median → hardcoded fallback; replaces income-factor estimation with actual market pricing; includesdata_sourceanddata_confidenceprovenance columns -
Eurostat income integration (
stg_income.sql) — staging model readsilc_di03(median equivalised net income in PPS) from landing zone; grain(country_code, ref_year) -
Income columns in dim_cities and city_market_profile —
median_income_ppsandincome_yearpassed through from staging to serving layer -
Transactional email i18n — all 8 email types now translated via locale files;
_t()helper inworker.pylooks upemail_*keys fromen.json/de.json;_email_wrap()acceptslangparameter for<html lang>tag and translated footer; ~70 new translation keys (EN + DE); all task payloads now carrylangfrom request context at enqueue time; payloads withoutlanggracefully default to English -
Email design & copy upgrade — redesigned
_email_wrap(): replaced monogram header with lowercase wordmark matching website, added 3px blue accent border, preheader text support (hidden preview in email clients), HR separators between heading and body;_email_button()now full-width block for mobile tap targets; rewrote copy for all 9 emails with improved subject lines, urgency cues, quick-start links in welcome email, styled project recap cards in quote verification, heat badges on lead forward emails, "what happens next" section in lead matched notifications, and secondary CTAs; ~30 new/updated translation keys in both EN and DE
Changed
- Resend audiences restructured — replaced dynamic
waitlist-{blueprint}audience naming (up to 4 audiences) with 3 named audiences fitting free plan limit:suppliers(supplier signups),leads(planner/quote users),newsletter(auth/content/public catch-all); new_audience_for_blueprint()mapping function incore.py - dim_venues enhanced — now includes court_count, indoor/outdoor split, timezone, VAT rate, and default currency from Playtomic venue metadata
- city_market_profile enhanced — includes median hourly rate, occupancy rate, daily revenue estimate, and price currency from venue pricing benchmarks
- Planner API route — col_map updated to match new planner_defaults columns
(
rate_peak,rate_off_peak,avg_utilisation_pct,courts_typical); adds_dataSourceand_currencymetadata keys
Changed
- pSEO CMS: SSG architecture — templates now live in git as
.md.jinjafiles with YAML frontmatter (slug, data_table, url_pattern, etc.) instead of SQLitearticle_templatestable; data comes directly from DuckDB serving tables instead of intermediarytemplate_datatable; admin template views are read-only (edit in git, preview/generate in admin) - pSEO CMS: SEO pipeline — article generation bakes canonical URLs, hreflang links (EN + DE),
JSON-LD structured data (Article, FAQPage, BreadcrumbList), and Open Graph tags into each
article's
seo_headcolumn at generation time; articles stored withtemplate_slug,language, anddate_modifiedcolumns for regeneration and freshness tracking
Removed
article_templatesandtemplate_dataSQLite tables (migration 0018) — replaced by git template files + direct DuckDB reads;template_data_idFK removed fromarticlesandpublished_scenariostables- Admin template CRUD routes (create/edit/delete) and CSV upload — replaced by read-only views with generate/regenerate/preview actions
template_form.htmlandtemplate_data.htmladmin templates
Changed
- Extraction: one file per source — replaced monolithic
execute.pywith per-source modules (overpass.py,eurostat.py,playtomic_tenants.py,playtomic_availability.py); each module has its own CLI entry point (extract-overpass,extract-eurostat, etc.); shared boilerplate extracted to_shared.pywithrun_extractor()wrapper that handles SQLite state tracking, logging, and session management - Transform: 4-layer → 3-layer — removed
raw/layer; staging models now read landing zone JSON files directly viaread_json()with@LANDING_DIRvariable; model schemas renamed frompadelnomics.*to per-layer namespaces (staging.*,foundation.*,serving.*) - Two-DuckDB architecture — web app now reads from
SERVING_DUCKDB_PATH(analytics.duckdb) instead ofDUCKDB_PATH(lakehouse.duckdb);export_serving.pyatomically swaps serving tables after each transform run - Supervisor: added daily sleep interval between pipeline runs
Added
- Sitemap: hreflang alternates + caching — extracted sitemap generation to
sitemap.py; each URL entry now includesxhtml:linkhreflang alternates (en, de, x-default) for correct international SEO signaling; supplier detail pages now listed in both EN and DE (were EN-only); removed misleading "today" lastmod from static pages; added 1-hour in-memory TTL cache withCache-Control: public, max-age=3600response header - Playtomic availability extractor (
playtomic_availability.py) — daily next-day booking slot snapshots for occupancy rate estimation and pricing benchmarking; reads tenant IDs from latesttenants.json.gz, queries/v1/availabilityper venue with 2s throttle, resumable via cursor, bounded at 10K venues per run - Template sync: copier update v0.9.0 → v0.10.0 —
export_serving.pymodule,@padelnomics_glob()macro,setup_server.sh, supervisor export_serving step
Fixed
- Eurostat JSON-stat parsing — API returns 4-7 dimension sparse dictionaries (583K values) that caused DuckDB OOM; extractor now pre-processes JSON-stat into flat records with configurable dimension filters per dataset
- Playtomic venue lat/lon — staging model used wrong JSON path (
address.coordinate_latvs actualaddress.coordinate.lat) - dim_cities CTE — unused
eurostat_labelsCTE causedcity_slug_rawcolumn not found error
Removed
extract/.../execute.py— replaced by per-source modulesmodels/raw/directory — raw layer eliminated; staging reads landing files directly
Added
-
Template sync: copier update from
29ac25b→v0.9.0(29 template commits).claude/CLAUDE.md: project-specific Claude Code instructions (skills, commands, architecture).claude/coding_philosophy.md: engineering principles guideextract/padelnomics_extract/README.md: extraction patterns & state tracking docsextract/padelnomics_extract/src/padelnomics_extract/utils.py: SQLite state tracking (open_state_db,start_run,end_run,get_last_cursor) + file I/O helpers (landing_path,content_hash,write_gzip_atomic)transform/sqlmesh_padelnomics/README.md: 4-layer SQLMesh architecture guide- Per-layer model READMEs (raw, staging, foundation, serving)
infra/supervisor/: systemd service + supervisor script for pipeline orchestration
-
Copier answers file now includes
enable_daas,enable_cms,enable_directory,enable_i18ntoggles (prevents accidental deletion on future copier updates) -
Expanded programmatic SEO city coverage from 18 to 40 cities (+22 cities across ES, FR, IT, NL, AT, CH, SE, PT, BE, AE, AU, IE) — generates 80 articles (40 cities × EN + DE)
-
scripts/refresh_from_daas.py: syncs template_data rows from DuckDBplanner_defaultsserving table; supports--dry-runand--generateflags; graceful no-op when DuckDB unavailable
Added
analytics.py: DuckDB read-only reader (open_analytics_db,close_analytics_db,fetch_analytics) registered in app lifecycle (startup/shutdown)GET /planner/api/market-data?city_slug=<slug>: returns per-city planner defaults from DuckDBplanner_defaultsserving table; falls back to{}when analytics DB unavailable
Added
transform/sqlmesh_padelnomicsworkspace member: SQLMesh 4-layer model pipeline over DuckDB- Raw:
raw_overpass_courts,raw_playtomic_tenants,raw_eurostat_population - Staging:
stg_padel_courts,stg_playtomic_venues,stg_population - Foundation:
dim_venues(OSM + Playtomic deduped),dim_cities(with Eurostat population) - Serving:
city_market_profile(market score OBT),planner_defaults(per-city calculator pre-fill)
- Raw:
extract/padelnomics_extractworkspace member: Overpass API (padel courts via OSM), Eurostat city demographics (urb_cpop1,ilc_di03), and Playtomic unauthenticated tenant search extractors- Landing zone structure at
data/landing/with per-source subdirectories:overpass/,eurostat/,playtomic/ .env.exampleentries forDUCKDB_PATHandLANDING_DIR- content:
scripts/seed_content.py— seeds two article templates (EN + DE) and 18 cities × 2 language rows into the database; run withuv run python -m padelnomics.scripts.seed_content --generateto produce 36 pre-built SEO articles covering Germany (8 cities), USA (6 cities), and UK (4 cities); each city has realistic per-market overrides for rates, rent, utilities, permits, and court configuration so the financial model produces genuinely unique output per article - content: EN template (
city-padel-cost-en) at/padel-cost/{{ city_slug }}and DE template (city-padel-cost-de) at/padel-kosten/{{ city_slug }}with Jinja2 Markdown bodies embedding[scenario:slug:section]cards for summary, CAPEX, operating, cashflow, and returns
Fixed
- content:
bake_scenario_cards()now accepts alangparameter and passes it to scenario partial templates; previouslylangwas alwaysundefined, causing all cards to render with English labels even for German articles - admin:
_generate_from_template()extractslanguagefrom data row and passes it tocalc()andbake_scenario_cards()so German scenario cards use translated CAPEX/OPEX item names - admin:
_generate_from_template()now derivesarticle_slugas{template_slug}-{city_slug}instead of barecity_slug; bare slugs caused UNIQUE constraint collisions when multiple templates generated articles for the same city - admin:
_rebuild_article()passeslangfrom data row (or"en"for manual articles) tobake_scenario_cards()so rebuilt articles render correct language labels - content: removed unused
gimport fromcontent/routes.py
Changed
- planner: full HTMX refactor — replaced 847-line SPA
planner.jswith server-rendered Jinja2 tab partials; planner now useshx-post /planner/calculate+ form state; all tab content (CAPEX, Operating, Cash Flow, Returns, Metrics) rendered server-side; Chart.js data embedded as<script type="application/json">tags, re-initialized onhtmx:afterSettle; newplanner.jsis ~200 lines (chart init, slider sync, toggle management, wizard nav, scenario save/load) - planner/i18n: merged
_PLANNER_TRANSLATIONS(~200 keys × 2 languages) into_TRANSLATIONS; deletedget_planner_translations()andwindow.__PADELNOMICS_LOCALE__; all planner strings now via standard{{ t.key }}Jinja2 template variables; adding a new language = one section in_TRANSLATIONS - planner/routes:
/planner/calculateendpoint now returns HTML partial (HTMX) instead of JSON; addedform_to_state()for form serialization,augment_d()for chart data + sensitivity table computation,COUNTRY_PRESETSdict;index()passes full calc result to template on initial load - app: added 5 Jinja2 template filters —
fmt_currency,fmt_k,fmt_pct,fmt_x,fmt_n— replacing equivalent JS formatting functions - copy: switch all German UI copy from formal "Sie/Ihr" to informal "Du/Dein" — covers i18n.py (~60 keys), planner wizard step titles/subtitles, export waitlist page, quote wizard steps, quote submitted/verify pages, directory supplier detail, directory results partial, supplier signup step 4, supplier waitlist confirmed page
- copy: replace "Platz-Anbieter" with "Anbieter" in CTAs; "Anlage" → "Padel-Platz" in planner wizard step 1 title/subtitle and planner translations (wiz_venue, sl_budget_target); "Anlageplanung" → "Padelplatz-Planung" in service checklist
- copy: update directory H1 to SEO multi-term "Padelplatz-Hersteller, Platzbauer & Anbieter"; subheading now mentions Hersteller, Platzbauer, schlüsselfertige Lösungen
- copy: fix
mkt_no_resultsandmkt_search_placeholder— replaced incorrect "Artikel" with "Märkte" in German and English (markets page shows market pages like Miami, not articles)
Added
- i18n: translate
base.html,_cookie_banner.html— "Manage Cookies", "About" footer links, feedback placeholder via{{ t.key }}; cookie banner heading/categories/descriptions/buttons; JS toggle text injected viatojsonso "Manage"/"Close" states are also translated;public/routes.pyfeedback flash messages useget_translations(g.lang)keys - i18n: expand
i18n.pywith ~300 UI template keys, ~200 planner JS locale strings (_PLANNER_TRANSLATIONS), ~35 CAPEX/OPEX item name translations (_CALC_ITEM_NAMES), plusget_planner_translations()andget_calc_item_names()functions
Fixed
- i18n:
planner.htmlused{% if lang %}...{% block %}nesting which Jinja2 forbids — restructured to{% block title %}{% if lang == 'de' %}...{% endif %}{% endblock %} - ruff: unsorted import in
planner/routes.py(newget_planner_translationsimport) — auto-fixed withruff --fix
Added
-
i18n: localize planner — inject
window.__PADELNOMICS_LOCALE__from server viaget_planner_translations(lang), addconst L / tr()helpers inplanner.js, replace all hardcoded English strings in TABS, WIZ_STEPS, allbuildInputs()/rebuildCapexInputs()/rebuildOpexInputs()slider labels,renderWith(),renderCapex(),renderOperating(),renderCashflow(),renderReturns(),renderMetrics(),renderSeasonChart(),resetToDefaults(),saveScenario(),renderWizNav(), andrenderWizPreview()withtr('key', 'fallback')calls -
i18n: localize
planner.html— addwindow.__PADELNOMICS_LOCALE__script injection, translate wizard step titles/subtitles, toggle labels, chart/section headers, CTA sidebar and inline CTA, signup bar, scenario controls, metrics section headers, and page title/meta via{% if lang == 'de' %}and{{ t.key }}/{{ planner_t.key }} -
i18n: localize all export templates —
export.html,export_success.html,export_generating.html,export_waitlist.html— all strings via{{ t.key }}, feature lists via{% if lang == 'de' %}conditionals -
i18n: localize
partials/scenario_list.html— drawer title, default badge, Load/Delete buttons, updated label, empty state message via{{ t.scenario_* }} -
calculator: add
lang: str = "en"parameter tocalc(), importget_calc_item_names, replace allci()/oi()hardcoded English names withnames["key"]lookups, trackrent_amountas local variable to replace name-based loop lookup for rentRatio -
routes: pass
langandplanner_ttoplanner.htmlrender context; passlang=langtocalc()in both index and/calculateendpoints -
i18n: translate directory and leads templates —
directory.html,supplier_detail.html,partials/results.html,partials/enquiry_result.html,quote_request.html,quote_step_1–9.html,quote_submitted.html,quote_verify_sent.html— short strings via{{ t.key }}, long paragraphs and context-sensitive text via{% if lang == 'de' %}conditionals, title/meta tags conditional per language -
i18n: translate supplier signup flow (
signup.html,signup_step_1–4.html,signup_success.html), waitlist pages (waitlist.html,waitlist_confirmed.html), content templates (markets.html,article_detail.html,market_results.html), and all scenario partials (scenario_summary,scenario_capex,scenario_cashflow,scenario_operating,scenario_returns) — step labels via{{ t.key }}, all other strings via{% if lang == 'de' %}conditionals -
i18n: translate
landing.html,features.html, andabout.htmlto German — all short strings via{{ t.key }}, long paragraphs/FAQ answers via{% if lang == 'de' %}conditionals, JSON-LD structured data wrapped per language, title/meta blocks conditional
Changed
- leads/routes.py: replace hardcoded
QUOTE_STEPSlist with_get_quote_steps(lang)function — step titles now use i18n keys so the progress bar shows translated step names; all public-facingflash()calls now useget_translations(g.lang)keys instead of hardcoded English strings
Fixed
- Nav: hamburger button was trapped inside
.nav-links--right; parentdisplay: noneon mobile hid it too — moved hamburger to be first child of.nav-inner; sign-in button added as always-visible mobile slot (.nav-auth-mobile) on the right; mobile grid is nowauto 1fr auto(hamburger | logo | sign-in) - Nav: align
.nav-innerwidth withcontainer-page— changedmax-widthfrom 80rem to 72rem (--container-6xl) and matched responsive padding (1rem/1.5rem/2remat mobile / sm / lg) so nav edges are flush with page content and footer - Planner: "Get Supplier Quotes" button now navigates to the correct lang-prefixed URL (
/en/leads/quoteetc.) — hardcoded/leads/quotecaused a 404 on prod; URL is now injected from the server viawindow.__PADELNOMICS_QUOTE_URL__
Added
- Nav: hamburger menu on screens < 900px — clicking opens a full-width mobile panel with all nav links; overlay click and Escape key close it
- Nav: mobile panel groups links under "Plan", "Explore", and "Account" section headers
Changed
- Nav: widen container from 72rem (1152px) to 80rem (1280px) — matches Zillow's nav container width, more breathing room for nav items on large monitors
- Nav: collapse breakpoint raised from 768px to 899px — nav links no longer hide until the screen is actually too narrow
- Nav: remove redundant inline
style="display:grid;grid-template-columns:1fr auto 1fr"on.nav-inner(already in CSS)
Fixed
- i18n: improve German nav labels — "Verzeichnis" → "Anbieterverzeichnis", "Planer" → "Kostenrechner"
- CI: add missing env vars to
.envheredoc —WAITLIST_MODE,LEADS_EMAIL,UMAMI_API_URL; make Paddle vars optional (:-) so they don't break deploys when unset
Changed
- Legal pages: replaced home address with virtual office address (c/o COCENTER, Koppoldstr. 1, 86551 Aichach) in all four legal templates (
imprint_de,imprint_en,privacy_de,privacy_en,terms_de)
Fixed
- Litestream: remove local-path replica — v0.5.8 dropped multi-replica support (
"multiple replicas on a single database are no longer supported"), keeping only the R2 replica - Litestream: extend retention from 7 days to 1 year (
8760h) — WAL frames are tiny, R2 storage cost is negligible - Deploy: gate deployment on litestream health (
kill -0 1, retries 6×5s after 15s start period) so broken backups fail the deploy loudly - Deploy: write nginx router config before starting containers so the router health check (
nginx -t) passes on first deploy or after volume wipe - Deploy: pre-migration DB backup added to
deploy.sh; on health-check failure the DB is restored to pre-migration state (prevents old slot from running against new schema) - Migrations: removed all
conn.commit()andexecutescript()calls fromup()functions in 0000, 0011, 0012, 0013, 0014, 0015 — restores batch-atomicity guarantee (executescriptissued implicit COMMITs, breaking rollback on failure) - Visual tests: server now builds schema via
migrate()instead of the deletedschema.sql; all 12 Playwright tests pass - Visual tests: updated assertions to match current landing page (text logo replacing img,
.roi-calcreplacing.teaser-calc,hero-dark/cta-cardallowed as intentional dark sections, card count ≥ 6, i18n-prefixed logo href, h3 brightness threshold relaxed to 150) - CSS: removed dead
.nav-logo { line-height: 0 }rule (was for image logo, collapsed text logo to zero height); removed dead.nav-logo imgrule - Ruff: fixed 49 lint errors across
src/andtests/(unused imports, unused variables, unsorted imports, bare f-strings, ambiguous variable namel)
Added
- Litestream: R2 replication config with env-var placeholders (
LITESTREAM_R2_BUCKETetc.) — fill in GitLab CI variables to enable off-host backup; handles new server, deleted volume, or disaster recovery via auto-restore on container startup .env.example: Litestream R2 vars documented
Added
- i18n URL prefixes: all public-facing blueprints (
public,planner,directory,content,leads,suppliers) now live under/<lang>/(e.g./en/,/de/); internal blueprints (auth,dashboard,admin,billing) unchanged; root/detects language from cookie / Accept-Language header and 301-redirects; legacy URLs (/terms,/privacy, etc.) redirect to/en/equivalents - German legal pages: full DSGVO-compliant
Datenschutzerklärung(/de/privacy),AGB(/de/terms), andImpressum(/de/imprint) per § 5 DDG — populated with Hendrik Dreesmann's details, Kleinunternehmer § 19 UStG, Oldenburg address - Rewritten English legal pages (
/en/terms,/en/privacy,/en/imprint) with expanded GDPR sections, correct controller identity, proper data-processing details (Umami self-hosted, Paddle, Resend with SCCs), and German-law jurisdiction - Language toggle (
EN | DE) in footer; hreflangen,de, andx-defaulttags in<head>on all lang-prefixed pages langcookie (1-year, SameSite=Lax) persisted on first visit;langandt(translation dict) injected into every template contexti18n.py: flat translation dicts for ~20 nav/footer keys inenandde;LANG_BLUEPRINTSandSUPPORTED_LANGSconstantssitemap.xmlandrobots.txtmoved to app-level root routes (not under/<lang>); sitemap now includes both language variants of every SEO page- Cookie consent banner: fixed bottom bar with "Accept all" and "Manage preferences" (toggle for functional/A/B cookies); consent stored in
cookie_consentcookie for 1 year; "Manage Cookies" link added to footer Legal section
Changed
- Defer Paddle.js loading to only the 3 pages that use checkout (export, supplier signup, supplier dashboard) — removed from global
base.htmlhead; all other pages no longer receive Paddle's third-party cookies - Gate A/B test cookie (
ab_*) on functional cookie consent: variant is still picked per-request for rendering, but the cookie is only persisted when the visitor has accepted functional cookies - Privacy policy section 6 (Cookies): full disclosure of all cookie categories (essential, functional, payment); fix "Plausible" → "Umami" in service providers list
Changed
- Auto-create Resend audiences per blueprint:
capture_waitlist_email()now derives the audience name fromrequest.blueprints[0](e.g.,waitlist-auth,waitlist-suppliers) and lazily creates audiences via the Resend API on first use, caching IDs in a newresend_audiencestable; removesRESEND_AUDIENCE_WAITLISTenv var — onlyRESEND_API_KEYneeded
Added
- Waitlist mode (lean startup smoke test):
WAITLIST_MODEconfig flag intercepts/auth/signup,/suppliers/signup, and/planner/exportto capture emails or show "coming soon" messaging before Paddle billing is ready; confirmation emails sent via Resend; optionalRESEND_AUDIENCE_WAITLISTfor bulk launch blast; flip flag tofalseand all flows revert to normal
Changed
- Redesign padel racket SVG logo/favicon: filled dark silhouette with white punched-through holes (3×3 grid), proper throat tapering to handle at bottom, grip tape lines — replaces outline-only stroke approach; bump favicon cache to v4
Added
- Simple A/B testing with
@ab_testdecorator and Umamidata-tagintegration - SEO defaults in
base.html: canonical, og:url, og:type, og:image (logo fallback), og:title, og:description, twitter:card — every page gets these automatically, child templates override as needed robots.txtroute: disallows/admin/,/auth/,/dashboard/,/billing/,/directory/results; includes Sitemap reference- Meta descriptions and OG tags on all public pages: about, terms, privacy, pricing, features, suppliers, directory, supplier detail
og:imageon landing and features pages pointing to planner screenshot- JSON-LD Organization schema on homepage and supplier detail pages
- JSON-LD FAQPage schema on homepage (5 FAQ entries)
- JSON-LD Article schema on article detail pages
- Sitemap supplier slugs: all supplier detail pages now indexed
- Sitemap
<lastmod>on all entries (static pages: today, articles:COALESCE(updated_at, published_at), suppliers:created_at) rel="preconnect"for Google Fonts to reduce font-load latencyX-Robots-Tag: noindexheader on/directory/resultsHTMX partial
Fixed
- Render-blocking Paddle.js: added
deferattribute and wrappedPaddle.InitializeinDOMContentLoadedlistener - Render-blocking Chart.js on planner page: added
deferattribute - Broken
og:imageon planner page (og-planner.png→planner-screenshot.png) - Homepage meta description trimmed to under 155 characters
- Duplicate
og:urlandcanonicaltags removed from landing, markets, and article detail pages
Changed
- Rewrote supplier page hero: pain-first headline ("Stop Chasing Cold Leads"), differentiator copy, micro-proof under CTA
- Added "Problem With Finding Padel Clients Today" section with trade show / Google Ads / cold outreach pain points
- Tightened "How It Works" step titles: "Claim Your Listing", "Browse Pre-Qualified Leads", "Win Projects Faster"
- Added Basic tier (€39/mo / €29/mo yearly) to public pricing section on suppliers page
- Pricing section now shows 3-column grid (Basic / Growth / Pro) with CSS-only billing period toggle (monthly / yearly, no JS)
- Added "How We Compare" table: Padelnomics vs trade show / Google Ads / cold directory
- Upgraded social proof section: two testimonial cards with decorative quote mark, stat bar in subtitle
- Updated supplier FAQ: three tiers with correct prices (€39/€199/€499), two new entries (what makes leads different, pricing vs alternatives), removed stale €149/€399 figures
- Strengthened final CTA: "Your Next Client Is Already Building a Business Plan"
- Fixed stale prices in landing page FAQ: Basic €39/mo, Growth €199/mo, Pro €499/mo (was €149/€399)
Changed
- Redesigned directory cards: image-first 16:9 layout with cover photos, frosted category badge, CSS court/grid placeholders, and logo avatar straddling the media/body border
- 4-tier visual ladder: Free (62% opacity, grey placeholder, unverified chip) → Basic (full opacity, green placeholder, verified chip, description, "View Listing →") → Growth (green placeholder, Growth chip + stats, "Request Quote →") → Pro (green 2.5px top border, CSS court media, full stats, green hover glow)
- Pro card media defaults to CSS court visualization when no cover image; Growth/Basic default to dark-green grid placeholder
Added
- Cover image upload for suppliers in the dashboard listing form (saves to
static/uploads/covers/, 16:9 thumbnail preview) - Migration 0013:
cover_image TEXTcolumn on suppliers table
Added
- Basic subscription tier — verified directory listing with contact info, services checklist, social links, and enquiry form; no lead credits
- Monthly + yearly billing — all paid supplier tiers now offer yearly pricing with annual discount (Basic: €349/yr, Growth: €1,799/yr, Pro: €4,499/yr)
- Billing period toggle in supplier signup wizard — monthly/yearly pill switch, defaults to yearly; price cards update in real time via CSS sibling selectors
- Redesigned supplier detail page — navy hero section with court-grid CSS pattern, two-column body (main + 320px sidebar), contact card with avatar/role/social links, stats grid, services checklist, enquiry form for Basic+ listings, tier-adaptive CTA strip
- Supplier enquiry form on Basic+ listing pages — HTMX-powered inline form, rate-limited at 5 per email per 24 h, email relayed to supplier via worker task
- New DB columns on suppliers:
services_offered,contact_role,linkedin_url,instagram_url,youtube_url supplier_enquiriestable for tracking incoming enquiries from listing pages- Basic card variant in directory results — shows verified badge, logo, website, short description; sits between growth and free in sort order
- Dashboard access for Basic tier — overview + listing tabs; leads/boosts tabs hidden; upgrade CTA in sidebar
- Listing form: new fields — services offered (multi-select checkboxes), contact role, LinkedIn/Instagram/YouTube social links
- Admin: Basic tier support — tier dropdown updated, new fields in supplier form and detail view, enquiry count shown
Changed
- Supplier Growth monthly price adjusted to €199/mo (yearly: €150/mo billed at €1,799/yr)
- Supplier Pro monthly price adjusted to €499/mo (yearly: €375/mo billed at €4,499/yr)
- Directory sort order updated: pro → growth → basic → free
_supplier_requireddecorator now grants access to basic, growth, and pro tiers- New
_lead_tier_requireddecorator restricts lead feed, unlock, and dashboard leads to growth/pro only
Fixed
- Supplier detail page: locked quote CTA — "Request Quote" button is now visually disabled (greyed-out) for unverified/free-tier suppliers; clicking opens an inline popover explaining the limitation and linking to the general quote wizard instead
- Supplier signup Paddle checkout — form now intercepts submit, fetches
checkout config via JS, and opens
Paddle.Checkout.open()overlay instead of displaying raw JSON in the browser - Credit balance OOB updates — sidebar and lead feed header credits now update instantly via HTMX OOB swaps after unlocking a lead (no page refresh)
- Boosts page layout — capped main content column at 720px on wide screens;
credit card grid uses
auto-fillfor graceful responsive adaptation
Added
- Listing preview live update — form fields (name, tagline, description,
website) trigger HTMX
hx-getwith 500ms debounce to update the directory card preview in real time; new/dashboard/listing/previewendpoint and extracteddashboard_listing_preview.htmlpartial - Lead cards: full qualification data — unlocked cards now show decision process, prior supplier contact, financing help preference, with clear section headers (Project, Location & Timeline, Readiness, Contact) and human-readable enum labels; includes "View their plan" link to linked scenario
- Lead feed search bar — text input with 300ms debounced HTMX filtering on country, facility type, and additional info; mirrors directory search pattern
- Phone number mandatory in quote form — step 9 now requires phone with
HTML
requiredattribute and server-side validation - Supplier-aware dashboard redirect —
/dashboard/checks if user has a claimed supplier with paid tier and redirects to supplier dashboard - Inline SVG logo — replaced PNG logo with inline SVG padel racket icon + Bricolage Grotesque wordmark in navbar and footer
Changed
- Planner fonts — replaced Inter with DM Sans for body text, JetBrains Mono with Commit Mono for numeric values, added Bricolage Grotesque for planner header and wizard step titles; loaded Commit Mono via fontsource CDN
- Migration system: single source of truth — eliminated dual-maintenance
of
schema.sql+ versioned migrations; all databases (fresh and existing) now replay migrations in order starting from0000_initial_schema.py; removedschema.sql,_is_fresh_db(), and the fresh-DB fast-path that skipped migration execution;migrate()accepts an optionaldb_pathparameter for direct use in tests; test fixtures use cached migration replay instead of loadingschema.sqldirectly; removed fragile_old_schema_sql()test helper andTestMigration0001class; template repo updated to match (deleted0001_roles_and_billing_customers.py, projects own their migrations) - Design system: Bricolage Grotesque + DM Sans — replaced Inter with
Bricolage Grotesque (display headings) and DM Sans (body text); added
--font-displaytheme variable; headings use display font viafont-family: var(--font-display)in base layer; added--color-forest(#064E3B) to theme palette - Glass navbar — replaced opaque white navbar with semi-transparent
backdrop-filter: blur(14px)frosted glass effect - Landing page: dark hero — navy background with radial blue glow, white text, green badge on dark, white ROI calculator card with stronger shadow; hero section is now full-width outside the container
- Landing page: journey timeline — replaced 5 left-border cards with numbered step track (01-05) with connecting line, active/upcoming states; CSS grid 5-col desktop, stacks to horizontal layout on mobile
- Landing page: dark CTA card — replaced plain white CTA section with rounded navy card with noise texture and white inverted button
- Directory card tiers — pro cards get stronger green left border + subtle box-shadow glow and 48px logo; featured badges more prominent with box-shadow; free/unclaimed cards more visibly muted (lower opacity, lighter border)
- Supplier dashboard sidebar icons — added inline SVG icons (chart, inbox, building, rocket) to sidebar navigation links
- Supplier dashboard lead cards — added heat-color left borders
(red/amber/gray by heat score) on
.lf-card
Added
- Admin sidebar navigation — new
base_admin.htmltemplate with persistent sidebar (Overview, Leads, Suppliers, Users, Content, System sections); Heroicons inline SVGs for each nav item; active state via{% set admin_page %}in child templates; mobile: horizontal scroll nav; all 20 admin templates now extendbase_admin.html - Admin dashboard section labels — stat card groups labeled "Lead Funnel" and "Supplier Funnel" with color-coded left borders (blue for leads, green for suppliers)
Fixed
- Hardcoded Inter on supplier unlock button —
.lf-unlock-btnusedfont-family: 'Inter'; changed toinheritso it picks up DM Sans
Changed
- Admin auth: password → RBAC — replaced
ADMIN_PASSWORDenv var and session-based password login with role-based access control; admin access is now granted viaADMIN_EMAILSenv var (comma-separated); on login/dev-login, matching emails auto-receive theadminrole; removed/admin/loginand/admin/logoutroutes,admin_requireddecorator, andlogin.htmltemplate; all admin routes now use@role_required("admin")fromauth/routes.py - Billing: separated billing identity from subscriptions — new
billing_customerstable storesprovider_customer_id(was onsubscriptions.paddle_customer_id); subscriptions table renamedpaddle_subscription_id→provider_subscription_idand droppedUNIQUEconstraint onuser_id(allows multiple subscriptions per user);upsert_subscriptionnow finds existing rows byprovider_subscription_idinstead ofuser_id; webhook handler callsupsert_billing_customer()for all subscription events - Eager-loaded user context —
load_user()now JOINsbilling_customers,user_roles, and latest subscription in a single query; addsg.subscriptionandis_admintemplate context variable (replacessession.get('is_admin'))
Added
- RBAC decorators —
role_required(*roles),subscription_required(plans, allowed),grant_role(),revoke_role(),ensure_admin_role()inauth/routes.py user_rolestable — stores user-role pairs withUNIQUE(user_id, role)billing_customerstable — stores provider customer ID per userADMIN_EMAILSconfig — parsed from comma-separated env var incore.py- Migration 0011 — adds
user_rolesandbilling_customerstables, migratespaddle_customer_iddata, recreates subscriptions table withprovider_subscription_idcolumn and noUNIQUEonuser_id
Removed
ADMIN_PASSWORDenv var and password-based admin authentication/admin/loginand/admin/logoutroutesadmin/templates/admin/login.htmltemplateadmin_requireddecorator (replaced byrole_required("admin"))subscription_requiredfrombilling/routes.py(replaced by version inauth/routes.pythat reads fromg.subscription)
Fixed
- Webhook crash on null
custom_data— Paddle sends"custom_data": nullon lifecycle events (e.g.subscription.updated);.get("custom_data", {})returnsNonewhen the key exists with a null value, causingAttributeErroron the next.get()call; switched toor {}fallback; also guardedsubscription.activatedto skip whenuser_idis missing (was insertinguser_id=0causing FK violation), and applied sameor {}tocurrent_billing_period - Webhook signature verification uses SDK Verifier — replaced manual HMAC
implementation with
paddle_billing.Notifications.Verifiervia a lightweight request wrapper; same algorithm, fewer moving parts
Added
- Credit system test suite (
tests/test_credits.py— 24 tests) — coversget_balance,add_credits,spend_credits,compute_credit_cost,already_unlocked,unlock_lead,monthly_credit_refill,get_ledger; tests run against real in-memory SQLite with no mocking - Supplier webhook test suite (
tests/test_supplier_webhooks.py— 10 tests) — integration tests POSTing signed payloads to/billing/webhook/paddleand verifying DB state for credit pack purchases, sticky boosts, supplier subscription activation (growth/pro tiers, boost items, noop on missing supplier_id), and business plan PDF export
Removed
- Dead test
test_mobile_nav_no_overflowfromtest_visual.py— evaluated JS but never asserted the result; always passed regardless of overflow state
Fixed
- Supplier logo missing on public directory — directory cards and supplier
detail page only checked
logo_url(external URL); now checkslogo_filefirst (uploaded via dashboard), falling back tologo_url - Admin forward history links to wrong page — supplier name in lead forward
history linked to
/directory/(public index) instead of/admin/suppliers/<id>(admin detail page) - Dashboard lead country filter drops heat/timeline state — hidden inputs
used mismatched names (
heat_hidden/timeline_hiddenvsheat/timeline), silently resetting other filters when country changed - Directory pagination drops region filter — paginating after filtering by
region lost the
regionparameter; added to all pagination link templates boost_card_colormissing from webhook handler —BOOST_PRICE_KEYSomittedboost_card_color, so purchasing the card color boost via Paddle would silently fail to create thesupplier_boostsrecord; also removed staleboost_newsletterentry (replaced by card color boost)- Sticky boost DB operations not atomic —
boost_sticky_weekandboost_sticky_monthhandlers issued two bareexecute()calls (INSERT + UPDATE) without a transaction; wrapped indb_transaction()to prevent partial writes
Added
- Clickable admin list rows — entire row is clickable on leads, suppliers,
and users admin list pages (
data-href+ JS click handler that skips links/buttons/forms); pointer cursor and hover highlight via CSS - Supplier impersonation — "Impersonate Owner" button on admin supplier
detail page; reuses existing
admin.impersonateroute, shown only when the supplier has aclaimed_byuser - Seed data: supplier owner accounts — each claimed supplier now gets its own user + active subscription for realistic impersonation testing
Fixed
- Insufficient credits UX — unlock error now shows credit balance, cost, and a "Buy Credits" CTA linking to Boost & Upsells tab (was a collapsed red error message with no action)
- Supplier dashboard sidenav — active tab highlight now updates on click (was only correct on initial page load)
- Boost/credit buy buttons used wrong price IDs — dashboard boosts tab
passed internal keys (
boost_logo,credits_25) as PaddlepriceIdinstead of resolved Paddle price IDs; buttons now show "Not configured" when Paddle products aren't set up - Listing preview card stretched full width — constrained to 420px to match actual directory card proportions
- Impersonate redirects to supplier dashboard — impersonating a user who
owns a supplier now lands on
/suppliers/dashboardinstead of the generic demand-side dashboard
Added — Programmatic SEO: Content Generation Engine
- Database migration 0010 —
published_scenarios,article_templates,template_data,articlestables with FTS5 full-text search on articles; content-sync triggers for INSERT/UPDATE/DELETE - Article template system — parameterized content recipes with
input_schema(JSON field definitions),url_pattern,title_pattern,body_template(Markdown + Jinja2); supports multiple content types (onlycalculatorbuilt now) - Template data rows — per-city/region input data that feeds the generation pipeline; manual add or bulk CSV upload
- Bulk generation pipeline —
POST /admin/templates/<id>/generatecomputes financial scenarios from data rows viavalidate_state()+calc(), renders Markdown with baked scenario cards, writes static HTML todata/content/_build/, creates articles with staggered publish dates - Published scenarios — standalone financial widgets with pre-computed
calc_json; embeddable in articles via[scenario:slug]markers with section variants (:capex,:operating,:cashflow,:returns,:full) - Scenario widget templates — 6 partial templates (summary, CAPEX breakdown, revenue & OPEX, 5-year projection, returns & financing, full combined) with dark navy header bar, Commit Mono numbers, responsive metric grids, and "Try with your own numbers" CTA
- Static file rendering — articles rendered to
data/content/_build/as pre-baked HTML; DB stores metadata only, no body content in DB - Content blueprint — catch-all route serves published articles by
url_path; registered last to avoid path collisions - Markets hub (
/markets) — public search + country/region filter page for discovering articles; HTMX live filtering via FTS5 queries - Admin template CRUD — list, create, edit, delete article templates; view/add/upload/delete data rows; bulk generation form with start date and articles-per-day controls
- Admin scenario CRUD — list, create (with curated calculator form), edit + recalculate, delete, preview all widget sections
- Admin article management — list with status badges (draft/published/ scheduled), create manual articles (Markdown textarea), edit, delete, publish/unpublish toggle, rebuild single or all articles
- Rebuild system —
POST /admin/articles/<id>/rebuildand/admin/rebuild-allre-render article HTML from source (template+data or markdown file) with fresh scenariocalc_json - Article detail page — SEO meta tags (description, og:title, og:description, og:type=article, og:image, canonical), article typography styles, bottom CTA linking to planner
- Scenario widget CSS —
.scenario-widgetcomponent styles ininput.csswith responsive table scroll and metric grid collapse;.article-bodytypography for headings, paragraphs, lists, blockquotes, tables, code blocks slugify()utility — added tocore.pyfor URL-safe slug generationmistunedependency — Markdown → HTML rendering for articles- Sitemap —
/marketsand all published articles added to/sitemap.xml(only articles withpublished_at <= now) - Footer — "Markets" link added to Product column
- Admin dashboard — Templates, Scenarios, Articles quick-link buttons
- Path collision prevention —
RESERVED_PREFIXESvalidation rejects articleurl_pathvalues that conflict with existing routes
Added
- Dev setup script (
scripts/dev_setup.sh) — interactive bootstrap that checks prerequisites, installs deps, creates.envwith auto-generatedSECRET_KEY, runs migrations, seeds test data, optionally sets up Paddle sandbox products, and builds CSS - Dev run script (
scripts/dev_run.sh) — resets DB, runs migrations, seeds data, builds CSS, then starts app + worker + CSS watcher in parallel with colored/labeled output and clean Ctrl-C shutdown; when Paddle is configured and ngrok is installed, starts a tunnel and updates the Paddle webhook destination automatically for end-to-end checkout testing - Paddle webhook auto-setup —
setup_paddle.pynow creates a notification destination in Paddle with the 5 event types the webhook handler processes, and writesPADDLE_WEBHOOK_SECRETto.envautomatically - Resend test email docs — documented Resend test addresses
(
delivered@resend.dev,bounced@resend.dev, etc.) in.env.exampleand README for testing email flows without a verified domain
Fixed
- Webhook signature verification broken —
Verifier().verify()was called with raw bytes instead of a request object, causing all signed webhooks to fail with 400; replaced with manual HMAC verification matching Paddle'sts=<unix>;h1=<hmac>format; also added JSON parse error guard (400 instead of 500 on malformed payloads) - Billing tests stale after SDK migration — webhook tests used plain
HMAC instead of Paddle's
ts=...;h1=...signature format; checkout tests expected redirect instead of JSON overlay response; manage/cancel tests mocked httpx instead of Paddle SDK; removed stalePADDLE_PRICESconfig test (prices now in DB) - Quote wizard state loss —
_accumulatedhidden input used"attribute delimiters which broke ontojsonoutput containing literal"characters; switched all 8 step templates to single-quote delimiters (value='...') - Admin dashboard crash —
no such column: amountin credit ledger query; corrected todelta(the actual column name) - Admin supplier detail — credit ledger table referenced
entry.entry_typeandentry.amountinstead ofentry.event_typeandentry.delta - Nav bar semi-transparent — replaced
rgba(255,255,255,0.85)+backdrop-filter: blur()with opaque#ffffffbackground - Supplier pricing card buttons misaligned — added flexbox to
.pricing-cardwithflex-grow: 1on feature list andmargin-top: autoon buttons
Changed
- Newsletter boost removed — replaced with "Custom Card Color" boost
(€19/mo) across supplier pricing page,
setup_paddle.py, and supplier dashboard; directory cards with activecard_colorboost render a custom border color fromsupplier_boosts.metadataJSON - Removed "Zillow-style" comments from
base.htmlandinput.css
Added
- Migration 0009 — adds
metadata TEXTcolumn tosupplier_boostsfor card color boost configuration - Admin supplier/lead CRUD —
GET/POST /admin/suppliers/newandGET/POST /admin/leads/newroutes with form templates; "New Supplier" and "New Lead" buttons on admin list pages - Dev seed data script (
scripts/seed_dev_data.py) — creates 5 suppliers (mix of tiers), 10 leads (mix of heat scores), 1 dev user, credit ledger entries, and lead forwards for local testing - Playwright quote wizard tests (
tests/test_quote_wizard.py) — full 9-step flow, back-navigation data preservation, and validation error tests
Added — Phase 2: Scale the Marketplace — Supplier Dashboard + Business Plan PDF
- Paddle.js overlay checkout — migrated all checkout flows (billing,
supplier signup, business plan) from server-side Paddle transaction creation +
redirect to client-side
Paddle.Checkout.open()overlay;PADDLE_CLIENT_TOKENconfig; Paddle.js script inbase.htmlwith sandbox/production toggle - Paddle products in DB — new
paddle_productstable replaces 16PADDLE_PRICE_*env vars;get_paddle_price(key)andget_all_paddle_prices()async helpers incore.py;setup_paddle.pyrewritten to write product/price IDs directly to database - Migration 0008 —
paddle_products,business_plan_exports,feedbacktables;logo_fileandtaglinecolumns onsuppliers - Umami analytics — tracking script in
base.html; config varsUMAMI_API_URL,UMAMI_API_TOKEN,UMAMI_WEBSITE_ID; directory click tracking redirect routes (/<slug>/website,/<slug>/quote) - Supplier dashboard (
/suppliers/dashboard) — tab-based HTMX dashboard with sidebar nav (Overview, Lead Feed, My Listing, Boost & Upsells); each tab loads viahx-getwithhx-push-urlfor deep-linking- Overview tab — 4 KPI stat cards (profile views, leads unlocked, credit balance, directory rank), new leads alert banner, recent activity feed
- Lead feed tab — refactored
_get_lead_feed_data()shared function with bidder count; heat/country/timeline filter pills; region matching badges; "No other suppliers yet — be first!" messaging - My Listing tab — preview card + inline edit form (company info,
categories, service area, logo upload);
POST /suppliers/dashboard/listingsaves changes - Boost & Upsells tab — current plan, active boosts, available boosts
with
Paddle.Checkout.open()purchase buttons, credit packs grid, summary sidebar with visibility multiplier
- Business plan PDF export —
businessplan.pywith WeasyPrint PDF engine;plan.html+plan.cssA4 templates (executive summary, CAPEX, OPEX, revenue model, 5-year P&L, 12-month cash flow, sensitivity, key metrics); bilingual EN/DE;generate_business_planworker task - Business plan routes —
GET /planner/export(options page with scenario picker + Paddle checkout),POST /planner/export/checkout,GET /planner/export/success,GET /planner/export/<id>(download); export CTA in planner sidebar - Supplier landing page enhanced — live stats from DB (business plans created, avg project value, suppliers listed, monthly leads); real anonymized lead preview cards (fallback to example data); credit explainer (hot=35, warm=20, cool=8); "Most Popular" badge on Growth plan; expanded FAQ (8 questions including credits, countries, cancellation); social proof section
- Admin supplier management —
GET /admin/supplierswith tier/country/name filters (HTMX search),GET /admin/suppliers/<id>detail with profile info, credit balance + ledger, active boosts, lead forward history;POST /admin/suppliers/<id>/creditsmanual credit adjustment;POST /admin/suppliers/<id>/tiermanual tier change; supplier stats on admin dashboard (claimed, growth, pro, credits spent, leads forwarded) - Feedback widget — compact "Feedback" button in navbar opens HTMX popover
with textarea;
POST /feedbackrate-limited (5/hr per IP), inserts intofeedbacktable;GET /admin/feedbackpaginated admin view with user email- page URL
Added — Phase 1: Lead Operations + Builder Directory Monetization
- SDK migration — replaced raw httpx calls with official
paddle-python-sdkandresendSDKs for type safety, built-in webhook verification, and cleaner code;send_email()now acceptsfrom_addrparameter; all Paddle API calls use SDK client - EMAIL_ADDRESSES dict — hardcoded
transactional,leads, andnurturefrom-addresses sharing thenotification.padelnomics.ioResend domain - Paddle product setup script —
scripts/setup_paddle.pycreates all 14 products/prices programmatically via SDK; outputs.envsnippet for CI - Claude Code skills —
.claude/skills/paddle-integration/SKILL.mdand.claude/skills/resend-emails/SKILL.mdfor consistent SDK usage patterns - Migration 0007 —
credit_ledger,lead_forwards,supplier_booststables; 12 new columns onsuppliers(profile, credits);credit_costandunlock_countonlead_requests - Credit system (
credits.py) —get_balance,add_credits,spend_credits,unlock_lead,compute_credit_cost,monthly_credit_refill,get_ledger;InsufficientCreditsexception; heat-based pricing (hot=35, warm=20, cool=8 credits);refill_monthly_creditsworker task + scheduler - Admin lead management —
GET /admin/leadswith status/heat/country filters (HTMX search),GET /admin/leads/<id>detail with project brief + forward history,POST /admin/leads/<id>/statusupdate,POST /admin/leads/<id>/forwardmanual forward (no credit cost); lead funnel stats on admin dashboard (planner users → leads → verified → unlocked) - Lead forwarding emails —
send_lead_forward_emailworker task sends full project brief + contact details to supplier;send_lead_matched_notificationnotifies entrepreneur when a supplier unlocks their lead - Credit cost computed on submission —
credit_costset from heat score both on verified-user submission and on email verification - Supplier signup wizard (
/suppliers/signup) — 4-step HTMX wizard: plan selection (Growth €149/mo, Pro €399/mo), boost add-ons (logo, highlight, verified, newsletter), credit packs (25-250), account details + order summary; builds multi-item Paddle transaction;_accumulatedhidden JSON pattern - Supplier claim flow —
GET /suppliers/claim/<slug>verifies unclaimed and redirects to signup with pre-fill - Webhook handlers —
subscription.activatedwithsupplier_*plan creates supplier record with tier, credits, and boosts;transaction.completedhandles credit pack purchases and sticky boost purchases with expiry - Supplier profile page (
/directory/<slug>) — public profile with logo, verified badge, description, service categories as pills, service area, years in business, project count, website; "Request Quote" and "Claim This Listing" CTAs - Directory card links — all directory cards now link to supplier profile pages; paid-tier cards show "Request Quote" mini-CTA
- Supplier lead feed (
/suppliers/leads) — requires login + paid supplier tier; shows anonymized lead cards with heat badge, facility type, courts, country, timeline, budget range, credit cost, unlock count;POST /suppliers/leads/<id>/unlockspends credits, creates lead_forward, sends emails, returns full-details card via HTMX swap - Email nurture via Resend Audiences — on first scenario save, user is added to "Planner Users" audience (triggers 3-email automation); on quote submission, user is removed from audience (stops nurture)
- PADDLE_PRICES expanded — 13 new price keys for supplier plans, boosts,
credit packs;
PADDLE_ENVIRONMENTconfig for sandbox/production switching
Changed
- Supplier marketing page CTAs link to
/suppliers/signupinstead of mailto httpxremoved from direct dependencies (transitive via paddle SDK)
Added
- Double opt-in email verification for quote requests — guest quote submissions now require email verification before the lead goes live; verification click also creates a user account and logs them in automatically (GDPR-friendly consent trail)
GET /leads/verifyroute — validates token, activates lead (pending_verification→new, setsverified_at), logs user in, sends admin notification and welcome emailsend_quote_verificationworker task — branded verification email with project details and "Verify & Activate Quote" CTA button (DEBUG mode prints link to console)quote_verify_sent.htmltemplate — "Check your email" page shown after guest quote submission- Migration 0006 — adds
verified_at TEXTcolumn tolead_requests - 9 new tests in
TestQuoteVerificationclass covering the full verification flow, expired tokens, duplicate verification, and user creation
Changed
- Inline CTA full copy — mobile/narrow-screen inline quote CTA now matches sidebar: "Next Step" label, full title, description, 4 checkmark benefits, "Get Supplier Quotes" button, and "Takes ~2 minutes" hint
- Signup bar simplified — removed
×close button from guest signup bar; now a non-dismissable nudge (still only shown on results tabs via JS) - Investment tab narrower — CAPEX tab content constrained to 800px max-width so 3-column card grid, table, and chart don't stretch across full 1100px on wide screens
Changed
- Quote form → standalone 9-step HTMX wizard — extracted "Get Quotes" from
planner Step 5 into a standalone multi-step wizard at
/leads/quoteusing server-rendered HTMX partials; each step validates server-side and swaps viahx-post/hx-getwith OOB dot progress updates; accumulated state passed forward as hidden JSON field (no JS state management) - Planner reduced to 4 steps — removed embedded quote form (Step 5) from
planner wizard; Step 4 "Get Quotes →" now navigates to
/leads/quotewith pre-filled params (venue, courts, glass, lighting, country, budget) - Planner sidebar CTA — "Get Supplier Quotes" button now links to standalone quote wizard instead of scrolling to embedded Step 5; sidebar now visible on all tabs including assumptions (was results-only)
- Sticky wizard nav — planner preview bar (CAPEX/CF/IRR) and back/next buttons now stick to the bottom of the viewport so users don't have to scroll to navigate between steps
- Mobile quote CTA — inline "Get Quotes" card shown below main content on screens narrower than 1400px (where the fixed sidebar is hidden)
- Step 4 → "Show Results" — final planner wizard step now says "Show Results" instead of "Get Quotes" since quote flow is a separate standalone wizard
- Removed "2-5 suppliers" cap language — replaced specific supplier count promises with "matched suppliers" across landing page, supplier FAQ, planner sidebar, and quote form privacy box
Removed
- Inline quote form from planner (Step 5 HTML,
#wizSuccess, hidden inputs) populateWizAutoFill(),submitQuote(),COUNTRY_NAMESfrom planner.js__PADELNOMICS_QUOTE_URL__JS variable from planner template- Step 5 scoped CSS (~155 lines):
#wizQuoteForm,.wiz-autofill-summary,.wiz-input,.wiz-privacy-box,.consent-group,.wiz-success,.wiz-signup-nudge,.wiz-checkbox-label
Added
- Supplier tier system — Migration 0005 adds
tier(free/growth/pro),logo_url,is_verified,highlight,sticky_until,sticky_countrycolumns to suppliers table for paid listing support - HTMX live search — directory search input and filters update results
via
hx-getwith 300ms debounce; new/directory/resultsendpoint returns swappable partial - Directory card tiers — three-tier card design: Pro (green border, logo, verified badge, website), Growth (description, blue badge), Free (muted, unverified, "Is this your company?" CTA); sticky/featured suppliers pinned to top with blue border
- Supplier pricing page —
/suppliers/now shows Growth (€149/mo) and Pro (€399/mo) plan cards with feature lists, boost add-ons grid (Logo, Highlight, Verified Badge, Sticky Top, Newsletter Feature), updated FAQ - Mandatory form fields — country, timeline, and stakeholder_type now required on quote request form with server-side 422 validation
- Validation test —
test_quote_validation_rejects_missing_fieldsverifies server returns 422 JSON errors for missing mandatory fields
Changed
- Nav redesign — Zillow-style sticky nav with backdrop-blur: demand-side links (Planner, Directory) left, supply-side (For Suppliers, Help) after separator, Sign In right; removed "Get Started Free" button
- CTA text sweep — "Get Matched" → "Get Quotes" across planner, landing, and lead forms; removed all "Free" qualifiers from CTAs and badges
- ROI calculator fix — realistic cost model: €35K/court (was €25K), staff costs, €8/sqm rent (was €4); payback and ROI now based on total investment (was equity only); defaults: €40/hr rate, 35% utilization; shows ~3.9yr payback, ~26% ROI (was 0.1yr/1255%)
- Directory route refactor — shared
_build_directory_query()helper with tier-based SQL ordering (sticky → pro → growth → free → alphabetical)
Added
- Supplier directory — public searchable directory at
/directory/with 279 padel court suppliers across 31 countries; FTS5 full-text search, country and category filters, pagination, category-colored badges, unclaimed listing model - Supplier landing page —
/suppliers/marketing page for suppliers: hero, how-it-works steps, example lead preview, FAQ, "Claim Your Listing" CTAs - Migration 0004 — creates
supplierstable with FTS5 virtual table, content-sync triggers, and seeds 279 suppliers from PadelDirectory.md - Quick ROI calculator — landing page now features an interactive 3-slider calculator (courts, rate, utilization) showing investment, monthly cash flow, payback period, and annual ROI in real time
- Supplier matching section — landing page "Find the Right Suppliers" section with 3-step flow and link to directory
- FAQ accordion — landing page FAQ covering planner features, signup requirements, supplier matching, directory pricing, and projection accuracy
Changed
- Visual refresh — adopted React prototype color palette and aesthetic site-wide: royal blue primary (#1D4ED8), green (#16A34A), gold (#D97706); elevated cards with soft shadows, rounder corners (rounded-2xl cards, rounded-xl buttons/inputs), frosted-glass planner nav, highlighted CTA regions with blue-tinted backgrounds, pill-shaped toggle/filter controls, polished buttons with colored shadows, stronger hover lift on directory cards
- Landing hero redesigned — two-column layout with headline + CTAs on left and interactive Quick ROI calculator on right (matching React prototype); green badge pill, feature check bullets, "Open Full Planner" CTA inside calculator card; responsive single-column on mobile
- Landing page redesigned — replaced screenshot card with Quick ROI calculator; added supplier matching section, FAQ, and live supplier stats; CTAs renamed "Open the Planner — Free"; Build journey card updated with live supplier/country counts
- Navbar — Planner and Directory links now visible for all users (not just logged-in); footer updated with Directory and For Suppliers links
- Planner CTAs — removed sticky "Get Builder Quotes" footer bar; CAPEX and
Returns tab CTAs now navigate to wizard step 5 (integrated lead qualifier)
instead of redirecting to standalone
/leads/quoteform - Sitemap — added
/planner/,/directory/, and/suppliers/URLs
Changed
- Planner wizard — Assumptions tab reorganized into 5 guided steps (Your Venue → Pricing → Costs → Finance → Get Matched) with live preview bar and step navigation; reduces cognitive load from 60 sliders to ~6-15 per step
- Integrated lead qualifier — Step 5 "Get Matched" embeds the supplier quote form directly in the planner; auto-fills venue, courts, glass, lighting, country, budget from planner state; submits inline via fetch
- JSON quote endpoint —
POST /leads/quotenow acceptsapplication/jsonand returns{"ok": true, "heat": "..."}for inline planner submissions; standalone HTML form unchanged
Added — Phase 0 Round 2: Polish & Country-Specific Calculator
- Country-specific calculator —
countryselector (DE/ES/IT/FR/NL/SE/UK/Other) andpermitsComplianceCAPEX item for Indoor Rent and Outdoor scenarios; country presets auto-adjust permit costs - Permits & Compliance — new CAPEX line item for building permits, noise studies, and regulatory compliance (default €12K for Germany); excluded from Indoor Buy where Planning + Permits already covers this
- Quote form redesign — elevated white card on gradient background, green gradient CTA buttons, progress labels (Project/Details/Contact), privacy info box, mandatory consent checkbox
- Project phase (replaces location_status options) — 7-stage progression: still searching → location found → converting existing → lease signed → permit not filed → permit pending → permit granted; updated heat scoring
- Stakeholder type field — "You are..." selector (Entrepreneur, Tennis Club, Municipality, Developer, Operator, Architect) with
stakeholder_typeDB column (migration 0003) - Build context — added "Need Help Finding a Venue / Land" (
venue_search) option - Quote submitted page redesign — "You're matched!" flow with next-steps timeline, email confirmation box, and signup CTA for guests
- Migration 0003 — adds
stakeholder_type TEXTcolumn tolead_requests
Changed
- Landing page — replaced teaser calculator with planner screenshot in browser-frame card + "Start Planning — Free" CTA; all CTAs now point to
/planner/(no signup gate) - Heat score — updated
calculate_heat_score()for new project phase values (permit_granted+4,lease_signed/permit_pending+3,converting_existing/permit_not_filed+2,location_found+1) - Quote URL — planner now passes
countryparameter to quote form prefill - Admin email — includes stakeholder type and updated field labels
Added — Phase 0: Ungate & Validate
- Guest mode planner — removed auth gate from
/planner/and/planner/calculate; scenarios still require login - New calculator variables —
budgetTarget(budget vs CAPEX comparison),glassType(standard/panoramic, 1.4x multiplier),lightingType(LED standard/competition/natural, 1.5x/0x multipliers) - Pill select UI component — reusable
pillSelect()helper in planner.js with matching.pill-btnCSS for multi-option inputs - Budget indicator card — shows over/under budget with variance amount and percentage on the Investment tab
- 3-step "Get Builder Quotes" flow —
/leads/quotewith project specs, details, and contact steps; no login required - Lead heat scoring —
calculate_heat_score()rates leads as hot/warm/cool based on timeline, financing, location readiness, and budget signals - PDF export CTA — "Export Business Plan (PDF) — €99" wired to Paddle checkout (
business_planprice in PADDLE_PRICES) - SEO meta tags —
<meta>description, og:title, og:description, og:image on planner page - Migration 0002 — expands
lead_requestswith 17 new columns for quote qualification flow; makesuser_idnullable for guest leads - Phase 0 test suite (
tests/test_phase0.py) — 47 tests covering guest mode, glass/lighting/budget variables, heat scoring, quote submission, schema validation - Updated Hypothesis strategy in
test_calculator.pywithbudgetTarget,glassType,lightingType
Changed
- Planner CTA links now point to
/leads/quotewith pre-filled calculator state params (venue, courts, glass, lighting, budget) - Sticky footer bar updated: "Get Builder Quotes" + "Export Business Plan (PDF)" replace old supplier/financing links
Changed
- Landing page journey section: renamed "From Idea to Operating Hall" → "Your Journey", expanded from 4 cards to 5 (Explore → Plan → Finance → Build → Grow) with "Coming Soon" badges on unreleased stages
- Added
.grid-5CSS helper for 5-column grid layout
Changed
- Pico CSS → Tailwind CSS v4 — full design system migration across all templates (except planner, which keeps its own CSS)
- Standalone Tailwind CLI binary (no Node.js) with
make css-build/make css-watch - Court Tech brand theme: navy/charcoal/electric/accent color palette
- Component classes (
.btn,.card,.form-input,.table,.badge,.flash, etc.) ininput.cssfor consistent styling - Self-hosted Commit Mono font (replaces JetBrains Mono) for monospace data display
- Docker multi-stage build: CSS compiled in dedicated stage before Python build
- Standalone Tailwind CLI binary (no Node.js) with
- Landing page teaser calculator restyled with Tailwind utilities and brand colors
Removed
- Pico CSS CDN dependency
custom.css(replaced by Tailwindinput.csswith@layer components)- JetBrains Mono font (replaced by self-hosted Commit Mono)
- Basic tier is now free —
supplier_basicmonthly/yearly price → €0. No Paddle subscription required for Basic. Signup wizard shows "Free forever" instead of €39. - Card color boost — price corrected €19/mo → €59/mo (aligns with MARKETING.md).
- Business plan PDF — price raised €99 → €149 (KfW-ready document supporting €200K+ investment decision).
- Hero and final CTA — links changed from
#pricinganchor to direct signup URL. - Comparison table — €1,799/yr annotated with "(yearly plan)" for clarity.
- EN+DE translations —
sup_meta_descupdated (removed "from €39/mo"); all Basic-tier strings updated to reflect free tier; FAQ updated with guarantee mention. setup_paddle.py— Basic subscription products commented out (no longer needed);boost_card_color1900 → 5900 cents;business_plan9900 → 14900 cents.
Fixed
- Empty env vars (e.g.
SECRET_KEY=) now fall back to defaults instead of silently using""— fixes 500 on every request when.envhas blank values
Added
- Comprehensive migration test suite (
tests/test_migrations.py— 20 tests) covering fresh DB, existing DB, up-to-date DB, idempotent migration, version discovery,_is_fresh_db, migration 0001 correctness, and ordering - Expanded
migrate.pymodule docstring documenting the 8-step algorithm, protocol for adding migrations, and design decisions - Sequential migration system (
migrations/migrate.py) — tracks applied versions in_migrationstable, auto-detects fresh vs existing DBs, runs pending migrations in order migrations/versions/0001_rename_ls_to_paddle.py— first versioned migration (absorbed fromscripts/migrate_to_paddle.py)- Server-side financial calculator (
planner/calculator.py) — ported JScalc(),pmt(),calcIRR()to Python so the full financial model is no longer exposed in client-side JavaScript POST /planner/calculateendpoint for server-side computation- Pre-computed initial data (
window.__PADELNOMICS_INITIAL_D__) injected on page load for instant first render - Debounced API fetch pattern in
planner.jswithAbortControllerfor in-flight request cancellation - Computing indicator CSS (
.planner-app--computing) with subtle "computing..." text - Comprehensive test suite for calculator (
tests/test_calculator.py— 227 tests) covering all 4 venue/ownership combos, edge cases, and Hypothesis property-based fuzzing - Comprehensive billing test suite (371 tests total):
tests/conftest.py— shared fixtures (DB, app, clients, subscriptions, webhook helpers)tests/test_billing_helpers.py— unit tests for SQL helpers, feature/limit access, plan determination (60+ tests + parameterized + Hypothesis)tests/test_billing_webhooks.py— integration tests for LemonSqueezy webhooks (signature verification, all lifecycle events, Hypothesis fuzzing)tests/test_billing_routes.py— route tests (pricing, checkout, manage, cancel, resume, subscription_required decorator)- Added
hypothesis>=6.100.0andrespx>=0.22.0to dev dependencies for property-based testing and httpx mocking - Factored into Copier template — all billing tests now generate as
.jinjatemplates with provider-specific conditionals for Stripe, Paddle, and LemonSqueezy
- GitLab CI/CD pipeline (
.gitlab-ci.yml) — runs pytest + ruff on master/MRs, auto-deploys on master - Blue-green deployment with Docker Compose profiles (
docker-compose.prod.yml,deploy.sh)- nginx router on port 5000 proxies to active blue/green slot
- Zero-downtime: new slot health-checked before traffic switch
- Automatic rollback on failed health check
Removed
scripts/migrate_to_paddle.py— superseded byversions/0001_rename_ls_to_paddle.py
Changed
planner.jsno longer containscalc(),pmt(), orcalcIRR()functions — computation moved server-siderender()split intorender()(tab switching + schedule calc) andrenderWith(d)(DOM updates from data)- Tab switching now renders from
_lastDcache (instant, no API call) - Slider input triggers 200ms debounced server call instead of synchronous client-side calc