Subtask 2/6: Route handler in public blueprint, 301 redirect
from /market-score → /en/market-score for bookmarks without
lang prefix.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Subtask 1/6: ~40 mscore_* keys per locale covering page title, meta,
section headings, category descriptions, score band interpretations,
data sources, limitations, CTAs, and 5 FAQ Q&A pairs.
DE content written as native German (Du-form), not translated from EN.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
url_path UNIQUE prevented multilingual generation — the second language
(e.g. EN after DE) always failed with UNIQUE constraint, leaving tasks in
a retry loop and only the first 1-2 articles visible.
Migration 0020 recreates the articles table with UNIQUE(url_path, language)
and adds a composite index. Adds idx_articles_url_lang for the new lookup
pattern used by article_page and generate_articles upsert.
Also adds search/country/venue_type filters to the admin Scenarios tab
and clarifies what "Published Scenarios" means in the subtitle.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DuckDB spawns non-daemon background threads on import. Since
analytics.py was imported at module level (transitively by the
test suite), these threads kept the pytest process alive after
all tests completed. Moving the import into open_analytics_db()
means duckdb is only loaded when actually needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- CI now creates v<pipeline_iid> tag after tests pass on master
- Supervisor fetches tags and only deploys when a newer tag is available;
skips if already on latest or no tags exist
- Fix test_seeds_markets_enabled: markets is seeded disabled (enabled=0),
test was asserting the wrong value
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three bugs causing 0 articles after generation:
1. Worker never called open_analytics_db() — fetch_analytics always
returned [] since _conn was None. Generation completed silently with
0 rows from DuckDB.
2. generate_articles upserted by url_path alone — after the URL prefix
fix, all languages share the same url_path (e.g. /markets/de/berlin).
The EN article was overwriting the DE article on every generation.
Fixed: deduplicate on (url_path, language).
3. article_page and _filter_articles didn't filter by language — with
shared url_paths a DE request could serve an EN article, and the
markets hub would show duplicate entries per city. Fixed: both now
filter by g.lang from the /<lang> blueprint prefix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Unescaped HTML attribute quotes inside YAML double-quoted scalars broke
YAML parsing, causing discover_templates() to silently skip both
templates and generate nothing.
Fixed by replacing \" with \" in the span style attributes within the
YAML frontmatter. The wordmark span is preserved; YAML now parses
correctly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Updated city-cost-de and country-overview templates
- All user-facing "Market Score" mentions now use full branded name
- "padelnomics" rendered in Bricolage Grotesque 800 wordmark style
- Stat strip labels kept short (space-constrained)
- SQL column names unchanged (market_score stays as-is)
- Also seeds markets feature flag as disabled (0) by default
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Subtask 5: Bilingual country-overview template with schema_type fix.
Schema fix:
- Changed schema_type from [Article] to [Article, FAQPage] — enables
FAQ rich results for existing FAQ content.
German variant:
- Full German prose: "Padel in {{ country_name_en }} — Marktüberblick"
- German stats-strip labels (Anlagen gesamt, Erfasste Städte, Ø Market Score,
Median Spitzenpreis)
- German market landscape analysis, pricing overview, FAQ (5 questions)
- German CTAs: "Zum Finanzplaner" throughout
- Peak/off-peak spread commentary for German readers
English expansion (~500 → ~1300 words):
- New "Market Landscape" section with market maturity analysis
(conditional on avg_market_score ranges)
- Expanded "Top Cities" with ranking methodology context
- Expanded "Pricing Overview" with peak/off-peak spread commentary
and utilization management implications
- Expanded "Build Your Business Plan" with more persuasive copy
- 2 new FAQ questions: "How fast is padel growing?" and "Which cities
have the best pricing data?"
- Planner links in FAQ answers + second CTA at bottom
- Language-prefixed internal links: /{{ language }}/markets/...
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Subtask 4: Bilingual city-pricing template with language conditionals.
German variant:
- Full German prose: "Padel-Court-Preise in {{ city_name }}"
- German stats-strip labels (Hauptzeit, Nebenzeit, Preisspanne, Auslastung)
- German pricing tables, comparison section, pricing drivers
- 5 German FAQ questions with planner links
- German CTAs throughout
English expansion (~400 → ~1500 words):
- New "Pricing Trends" section with time-of-day analysis
- Expanded city comparison with venue density benchmarks
- Expanded pricing drivers (facility quality, market maturity)
- Investment Outlook section with scenario cross-reference
[scenario:city-cost-de-{{ city_key }}:operating]
- 2 new FAQ questions (city comparison + price trends)
- Cross-links to city-cost-de and country-overview articles
- Second CTA at bottom + planner links in FAQ answers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Full German prose variant (natural Du/Dein, not literal translation)
- German stats-strip labels, table headers, FAQ, CTAs
- Expanded English content (~1500 words): analytical commentary after
scenario markers, Market Context section, venue density analysis
- Cross-link to city-pricing page in both languages
- Planner links in FAQ answers (both languages)
- Second CTA at bottom of each language block
- Language-conditional title_pattern and meta_description_pattern
Subtask 2+3 of pSEO template improvements.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
generate_articles() was storing url_path with lang prefix (/en/markets/...)
but the content blueprint is registered at /<lang>, producing double-prefix
URLs like /en/en/markets/italy. Fix: store url_path without prefix, build
full_url with prefix for SEO tags (canonical, OG, hreflang, breadcrumbs).
Also removes /markets from RESERVED_PREFIXES since article sub-paths under
/markets/ are valid pSEO content URLs, not blueprint routes.
Subtask 1 of pSEO template improvements.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- bake_scenario_cards() accepts scenario_overrides dict for preview mode
(bypasses DB lookup when no published_scenario exists)
- preview_article() builds in-memory scenario dict and passes it through
- Fix double-encoded & in locale strings (was &amp; in rendered HTML)
- Fix ruff import sort in _datetimeformat
- Fix migration 0019 minor issue
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Publish/Unpublish returns updated <tr> partial via HTMX
- Delete returns empty string to remove row without page reload
- Extract article_row.html partial (used by both results table and
individual HTMX responses)
- article_results.html now includes article_row.html via loop
Subtask 7 of CMS admin improvement.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- generate_articles() now writes body_md alongside body_html
to BUILD_DIR/{lang}/md/{slug}.md
- article_edit GET checks both manual and generated markdown paths
- Fix pre-existing ruff import sort in _datetimeformat
Subtask 5 of CMS admin improvement.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add invalidate_sitemap_cache() to sitemap.py and call it from
article_publish, article_delete, and article_new admin routes.
Subtask 6 of CMS admin improvement.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add _get_article_list() with filters: status, template, language, search
- Add _get_article_stats() for header stats strip (total/live/scheduled/draft)
- Add /articles/results HTMX partial endpoint
- Add filter bar: search input + status/template/language dropdowns
- Paginate at 50 articles per page
- Add "View" link to live articles (opens public URL in new tab)
- Remove URL column (redundant), add Language column
Subtasks 2+3 of CMS admin improvement.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Compute display_status (live/scheduled/draft) in SQL instead of broken
Jinja string comparison against undefined `now` variable
- Replace template_data_id (dropped in migration 0018) with template_slug
Subtask 1/8 of CMS admin improvement.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
DuckDB rows can have NULL columns (e.g. market_score, median_peak_rate).
Replace None with 0 in render context so numeric Jinja2 filters like
round() and int don't crash with "NoneType doesn't define __round__".
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add _datetimeformat Jinja2 filter to _render_pattern() — templates
use {{ 'now' | datetimeformat('%Y') }} but the filter was never
registered, causing "No filter named 'datetimeformat'" on preview.
- Move Preview button to first column in template detail data table
so it's visible without scrolling on wide tables.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Part B: Calculator improvements
Fix 1 — annualRevGrowth was dead code. Now applied as compound multiplier
from Year 2 onwards across all revenue streams (court, ancillary, membership,
F&B, coaching, retail).
Fix 2 — IRR initial outflow bug (HIGH). Was using -capex but NCFs are
post-debt-service (levered). Using capex as denominator while using levered
cash flows understates equity returns by the leverage ratio. Fix: use -equity
as outflow for equity IRR, add separate projectIrr (unlevered, uses -capex
with EBITDA flows).
Fix 3 — NPV at hurdle rate. Discounts equity NCFs and exit proceeds at
hurdleRate (default 12%). Reports npv and npvPositive. NPV > 0 iff equity
IRR > hurdle rate. Added hurdleRate slider (5–35%) to Exit settings.
Fix 4 — Remaining loan: replaces heuristic with correct amortization math
(PV of remaining payments: monthlyPayment × (1 - (1+r)^-(n-k)) / r).
Fix 5 — Exit EBITDA: uses terminal year EBITDA (holdYears - 1) instead of
hardcoded Year 3. exitValue now reflects actual exit year, not always Y3.
Fix 6 — MOIC: moic is now equity MOIC (total equity CFs / equity invested).
projectMoic is the project-level multiple. Waterfall updated to show both.
Fix 7 — Return decomposition / value bridge. Standard PE attribution:
EBITDA growth value (operational alpha) + debt paydown (financial leverage).
Displayed in tab_returns.html as an attribution table.
Fix 8 — OPEX growth rate. annualOpexGrowth (default 2%) inflates utilities,
staff, insurance from Year 2 onwards. Without this Y4-Y5 EBITDA was
systematically overstated. Added annualOpexGrowth slider to Exit settings.
Fix 9 — LTV and DSCR warnings. ltvWarning (>75%) and dscrWarning (<1.25x)
with inline warnings in tab_metrics.html.
Fix 10 — Interest-only period. interestOnlyMonths (0–24) reduces early NCF
drag. Added slider to Financing section.
Updated test: test_stab_ebitda_is_year3 → test_stab_ebitda_is_exit_year.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Previously the admin role was only granted during dev-login via
ensure_admin_role(), but dev_run.sh resets the DB on each start,
so the role was never present when first visiting /admin.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The multi-line python3 -c heredoc in the Makefile caused
"missing separator" errors since Make runs each recipe line
in a separate shell. Moved to web/scripts/init_landing_seeds.py.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Three deviations from the quart_saas_boilerplate methodology corrected:
1. Fix dim_cities LIKE join (data quality bug)
- Old: FROM eurostat_cities LEFT JOIN venue_counts LIKE '%country_code%'
→ cartesian product (2.6M rows vs ~5500 expected)
- New: FROM venue_cities (dim_venues) as primary table, Eurostat for
enrichment only. grain (country_code, city_slug).
- Also fixes REGEXP_REPLACE to LOWER() before regex so uppercase city
names aren't stripped to '-'
2. Rename fct_venue_capacity → dim_venue_capacity
- Static venue attributes with no time key are a dimension, not a fact
- No SQL logic changes; update fct_daily_availability reference
3. Add fct_availability_slot at event grain
- New: grain (snapshot_date, tenant_id, resource_id, slot_start_time)
- Recheck dedup logic moves here from fct_daily_availability
- fct_daily_availability now reads fct_availability_slot (cleaner DAG)
Downstream fixes:
- city_market_profile, planner_defaults grain → (country_code, city_slug)
- pseo_city_costs_de, pseo_city_pricing add city_key composite natural key
(country_slug || '-' || city_slug) to avoid URL collisions across countries
- planner_defaults join in pseo_city_costs_de uses both country_code + city_slug
- Templates updated: natural_key city_slug → city_key
Added transform/sqlmesh_padelnomics/CLAUDE.md documenting data modeling rules,
conformed dimension map, and source integration architecture.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add 18 new E2E tests from master: pricing, checkout, supplier signup,
supplier dashboard, and business plan export (sections J-N)
- Force WAITLIST_MODE=false in visual server subprocess — the root .env
sets WAITLIST_MODE=true, and since Config class attributes evaluate at
import time (before fork), the subprocess inherits the parent's value.
Patching both os.environ and core.config directly ensures feature pages
render instead of waitlist templates.
- All 77 visual tests now pass in ~59 seconds.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
stg_playtomic_availability:
- Add maximum_object_size = 134217728 (128 MB) to both read_json calls;
daily files exceed the 16 MB default as venue count grows
- Add seed recheck file (1970-01-01_recheck_00.json.gz, gitignored with data/)
to avoid READ_JSON IOException when no recheck files exist
pseo_city_costs_de + pseo_city_pricing:
- Add QUALIFY ROW_NUMBER() OVER (PARTITION BY city_slug ...) = 1 to
deduplicate rows caused by dim_cities' loose LIKE join; reduces
pseo_city_costs_de from 2.6M → 222 rows (one per unique city)
content/__init__.py:
- DuckDB lowercases all column names at rest ("ratePeak" → "ratepeak"),
so calc_overrides dict comprehension never matched DEFAULTS keys.
Fix: build case-insensitive reverse map {k.lower(): k} and normalise
row keys before lookup. Applied in both generate_articles() and
preview_article().
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Consolidate 3 duplicate server processes into 1 session-scoped
live_server fixture in conftest.py (port 5111, shared across all
visual test modules). Reduces startup overhead from ~3× to 1×.
- Fix init_db mock: patch padelnomics.app.init_db (where it's used)
instead of core.init_db (where it's defined). The before_serving
hook imported init_db locally — patching core alone didn't prevent
the real init_db from replacing the in-memory test DB.
- Keep patches active through app.run_task() so before_serving hooks
can't replace the test DB during the server's lifetime.
- Force RESEND_API_KEY="" in the visual test server subprocess to
prevent real email sends (dev mode: prints to stdout, returns "dev").
- Remove 4 screenshot-only no-op tests, replace with single
test_capture_screenshots that grabs all pages in one pass.
- Fix test_planner_tab_switching: remove nonexistent "metrics" tab.
- Delete ~200 lines of duplicated boilerplate from 3 test files.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
city-cost-de.md.jinja:
- Lead with market score hook instead of raw venue count
- Stats strip hero (venues, market score, peak rate, population)
- Better section headings ("What Does a Padel Investment Cost in X?")
- Mid-body planner CTA after financial cards
- Expanded FAQ (6 questions incl. ROI and country comparison)
- Footer cross-link to country overview page
- Fixed url_pattern to use country_slug directly
country-overview.md.jinja:
- Country hub page at /markets/{country_slug}
- Aggregates: total venues, cities, avg market score, pricing
- Top-5 cities table with internal links to city pages
- Hub-and-spoke internal linking architecture
city-pricing.md.jinja:
- Per-city pricing deep-dive at /markets/{country_slug}/{city_slug}/court-prices
- Stats strip: peak rate, off-peak, P25-P75 range, occupancy
- Pricing table + market context (above/below national median)
- Occupancy-driven pricing explanation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Grid layout (2-col mobile, 4-col sm+) with label/value/unit slots.
Baked into static HTML at article generation time — no JS needed.
output.css is git-ignored (rebuild with: bin/tailwindcss -i ... -o ...).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Pricing page (EN/DE, plan cards, no-auth access)
- Checkout success (auth required, renders for authed user)
- Supplier signup wizard (step 1, plan cards, DE variant, success page)
- Supplier dashboard (overview stats, boosts/credit packs, listing, leads tabs)
- Business plan export (auth required, form renders)
Also fixes:
- E2e server init_db mock scope — before_serving was calling real init_db
outside the patch context, overwriting the in-memory DB (fixes 3
pre-existing failures: markets_hub, markets_results, signup_page)
- Add _seed_billing_data() for supplier + feature flags in e2e server
- Mock RESEND_API_KEY="" in conftest + e2e server to prevent real emails
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Pre-existing test failures after merge: enqueue payloads now include
'lang' key, and audience names changed from 'waitlist-auth' to
'newsletter' and 'waitlist-suppliers' to 'suppliers'.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- test_supervisor.py: 28 tests covering load_workflows, resolve_schedule,
is_due, topological_waves, and proxy round-robin / sticky selection
- test_feature_flags.py: 31 tests covering migration 0019, is_flag_enabled,
feature_gate decorator, admin toggle routes, and full toggle e2e flows
- conftest.py: seed feature flags with production defaults (markets=1,
others=0) so all routes behave consistently in tests
- Fix is_flag_enabled bug: replace non-existent db.execute_fetchone()
with fetch_one() helper
- Update 4 test_waitlist / test_businessplan tests that relied on
WAITLIST_MODE patches — now enable the relevant DB flag instead
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add full email management at /admin/emails with:
- email_log table tracking all outgoing emails with resend_id + delivery events
- inbound_emails table for Resend webhook-received messages
- Resend webhook handler (/webhooks/resend) updating delivery status in real-time
- send_email() returns resend_id (str|None) instead of bool; all 9 worker
handlers pass email_type= for per-type filtering
- Admin UI: sent log with HTMX filters, email detail with API-enriched HTML
preview, inbox with unread badges + reply, compose with branded wrapping,
audience management with contact list/remove
- Sidebar Email section with unread badge via blueprint context processor
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace the old CSV-upload-based CMS with an SSG architecture where
templates live in git as .md.jinja files with YAML frontmatter and
data comes directly from DuckDB serving tables. Only articles and
published_scenarios remain in SQLite for routing/state.
- Content module: discover, load, generate, preview functions
- Migration 0018: drop article_templates + template_data, recreate
articles + published_scenarios without FK references, add
template_slug/language/date_modified/seo_head columns
- Admin routes: read-only template views with generate/regenerate/preview
- SEO pipeline: canonical URLs, hreflang (EN+DE), JSON-LD (Article,
FAQPage, BreadcrumbList), Open Graph tags baked at generation time
- Example template: city-cost-de.md.jinja for German city market data
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>