- admin/routes.py: add LIMIT 500 to scenarios() — was unbounded, could return
arbitrarily large result sets and exhaust memory
- analytics.py: wrap asyncio.to_thread(DuckDB) in asyncio.wait_for with
_QUERY_TIMEOUT_SECONDS=30 so a slow scan cannot permanently starve the
asyncio thread pool
- core.py: replace resend.default_http_client with RequestsClient(timeout=10)
so all Resend API calls are capped at 10 s (default was 30 s)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Every bare `except Exception: pass` or `except Exception: return sentinel`
now logs via logger.exception() or logger.warning() so errors surface in
the application log instead of disappearing silently.
Changes per file:
- admin/routes.py: add logger; log in _inject_admin_sidebar_data(),
email_detail() Resend enrichment, audiences() contact count loop,
audience_contacts() Resend fetch
- core.py: log in _get_or_create_resend_audience(), capture_waitlist_email()
DB insert, and capture_waitlist_email() Resend contact sync (warning level
since that path is documented as non-critical)
- analytics.py: log DuckDB query failures before returning []
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- dev_run.sh: add -u flag so log output is not buffered (real-time visibility)
- analytics.py: use explicit cursor() with try/finally close instead of
calling execute() directly on the connection (thread-safe cursor lifecycle)
- .sops.yaml: add second age public key for local dev decryption access
- content/__init__.py: whitespace-only formatting fix
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Splits the single market score into two branded scores backed by a new
global data pipeline covering all GeoNames locations (pop ≥1K):
Data pipeline:
- GeoNames expanded: cities1000 (~140K locations) vs old cities15000
(~24K). Added lat/lon/admin1/admin2. Feature codes include PPLA3/4/5.
- Tennis court Overpass extractor (extract-overpass-tennis → stg_tennis_courts)
- foundation.dim_locations: new conformed dim seeded from GeoNames,
enriched with nearest_padel_court_km (ST_Distance_Sphere), padel venue
count within 5km, tennis courts within 25km
- DuckDB spatial extension enabled (extensions: [spatial] in config.yaml)
- GEONAMES_USERNAME + CENSUS_API_KEY added to .env.dev.sops + .env.prod.sops
Scoring models:
- city_market_profile.sql (Marktreife-Score): adds x0.85 saturation
discount when venues_per_100k > 8
- location_opportunity_profile.sql (Marktpotenzial-Score): new model,
no filter on padel_venue_count, rewards supply gaps + catchment gaps
Methodology page:
- market_score.html: Two Scores intro, 5 Marktpotenzial component cards,
score bands for both scores, FAQ 5-7, padelnomics wordmark spans on h2s
- en.json + de.json: 30+ new keys, native German (no calques), TM on chips
Docs: CHANGELOG, data-sources-inventory, SQLMesh CLAUDE.md, PROJECT.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
hx-trigger bug:
"from:find input" in hx-trigger attaches the event listener to the
first <input> found in the form — which is the hidden CSRF token input.
Typing in the visible search field never fires the listener on that
element. Result: only Enter (form submit) triggered HTMX.
Fix: drop "from:find input" so the listener is on the form itself,
where input/change events from all children bubble naturally.
Spinner visibility bug:
.search-spinner { opacity: 0 } relied on our compiled output.css.
HTMX ships its own built-in CSS for .htmx-indicator (opacity:0 →
opacity:1 on htmx-request). Using class="htmx-indicator search-spinner"
delegates hide/show to HTMX's own stylesheet with no dependency on
whether output.css has been rebuilt. Our .search-spinner only handles
positioning and the spin animation.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- 4 section h2 headings now render "padelnomics" in Bricolage Grotesque
bold (same styled span as h1), matching the existing "padelnomics
Market Score" wordmark pattern
- i18n h2 keys now contain only the suffix (e.g. "Marktreife-Score:
What It Measures") since "padelnomics" is hardcoded in template
- Chip labels (primary score identification) get ™ suffix in both EN + DE
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Score names always appear as "padelnomics Marktreife-Score" and
"padelnomics Marktpotenzial-Score" in headings, chips, intro paragraphs,
and FAQ questions/answers — in both EN and DE locales.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Scenarios:
- Convert from plain GET form to HTMX live search (scenario_results
route already existed, just needed wiring)
- Replace Filter submit button with JS-reset Clear button
- Update is_generating banner to match article_results.html style
Users:
- Add /admin/users/results HTMX partial route
- Extract user table into partials/user_results.html with HTMX pagination
- Convert search form to live-search (input delay:300ms)
Loading indicator (all 6 forms):
- Add hx-indicator pointing to a small arc spinner SVG
- Spinner fades in while the debounce + request is in flight
- CSS .search-spinner class in input.css (opacity 0 → 1 on htmx-request,
spin-icon animation only runs when visible)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The search/country/venue-type inputs used class="input" which has no
definition in input.css — falls back to the browser's default focus
outline. Replaced with form-input to get the consistent focus ring
(ring-2 / ring-electric / border-electric) used everywhere else in admin.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Spinner:
- article_results.html: replace hidden polling div with a visible
animated spinner banner; CSS spin keyframe added to input.css
Batch commits:
- generate_articles() now commits every 200 articles instead of
holding one giant transaction; articles appear in the admin UI
progressively without waiting for the full run
Performance (pre-compiled Jinja templates):
- Create one Environment + compile url/title/meta/body templates once
before the loop instead of calling _render_pattern() per iteration;
eliminates ~4 × N Environment() constructions and re-parses of the
same template strings (N = articles, typically 500+)
- Reuse url_tmpl for hreflang alt-lang rendering
Scenario override passthrough:
- Pass just-computed scenario data directly to bake_scenario_cards()
via scenario_overrides, avoiding a DB SELECT that reads an uncommitted
row from a potentially separate connection
Timing instrumentation:
- Accumulate time spent in calc / render / bake phases per run
- Log totals at completion: "done — 500 total | calc=1.2s render=4.3s bake=0.1s"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace SELECT-then-INSERT/UPDATE pairs in generate_articles() with
INSERT ... ON CONFLICT DO UPDATE statements, and wrap the entire loop in
a single transaction context manager. Eliminates ~1,500 individual SQLite
commits for a 500-article run (one commit per row replaced by one total).
Also fix _get_article_stats() returning None for live/scheduled/draft counts
when the articles table is empty: wrap SUM expressions in COALESCE(..., 0)
so they always return integers regardless of row count.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
articles.country stores "CH"/"DE"/etc., not English names. Update
get_country_name() to try the input as an uppercase code first, falling
back to the reverse-name lookup for any English-name values.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Systematic review of de.json: fix unnatural calques from English, inconsistent
register (Du/Sie mixing), awkward phrasing, and machine-translation artifacts.
Market Score and product names intentionally kept in English as brand names.
Du (capitalized) maintained consistently as product voice throughout.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace plain "Ø Market Score" / "Market Score" / "Avg Market Score" labels with
the branded padelnomics wordmark (Bricolage Grotesque bold). Add color-coded value:
green (≥65), amber (40–64), red (<40). Applied to country-overview.md.jinja (DE+EN)
and city-cost-de.md.jinja (DE+EN). Articles need Rebuild All to regenerate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace **Bold question?** / Answer markdown pattern with HTML <details>/<summary>
in all three article templates (city-pricing, city-cost-de, country-overview),
both DE and EN sections. Add .article-body details CSS for styled accordion look.
Articles need Rebuild All to regenerate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move COUNTRY_LABELS to i18n.py (shared). Add get_country_name(country_str, lang)
that maps English DB values (e.g. "Germany") to localised names via existing
dir_country_* translation keys. Register as Jinja filter country_name.
Apply to market_results.html country badge.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
hypercorn sets its own level on child loggers directly, so silencing
the parent 'hypercorn' logger alone isn't sufficient.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Zero print() calls remain in the app and worker (scripts use
basicConfig for clean CLI output). Setup_logging() in core.py
reads LOG_LEVEL env var and configures the root logger once.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
# Conflicts:
# web/src/padelnomics/core.py
# web/src/padelnomics/worker.py
- migrations/migrate.py: module logger, basicConfig in __main__
- scripts/seed_dev_data.py: module logger, convert all 19 prints
- scripts/seed_content.py: module logger, convert all 13 prints
- scripts/refresh_from_daas.py: module logger, convert all 11 prints
- scripts/setup_paddle.py: module logger, convert all 20 prints
All scripts use basicConfig(level=INFO, format='%(levelname)-8s %(message)s')
in their __main__ blocks for clean CLI output without timestamps.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add module logger (padelnomics.worker) and scheduler_logger
(padelnomics.worker.scheduler)
- Call setup_logging() at start of run_worker() and run_scheduler()
- Convert all 26 print() calls — drop manual [WORKER]/[SCHEDULER] prefixes
- Magic link + quote verification debug prints → logger.debug() (only
shown when LOG_LEVEL=DEBUG)
- Errors with exception context use logger.error() with %s formatting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add logging import and setup_logging() that reads LOG_LEVEL env var
(defaults DEBUG in dev, INFO in prod), sets format with timestamp +
level + logger name, silences hypercorn/asyncio noise
- Add module-level logger to core.py
- Convert 3 [EMAIL] print() calls to logger.info / logger.error
- Call setup_logging() from app.py at import time
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces 94 occurrences of deprecated datetime.utcnow() and
datetime.utcfromtimestamp() across 22 files with utcnow()/utcnow_iso()
helpers. Zero DeprecationWarnings remain. All 1201 tests pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Show API errors and network failures in a red inline div below the
export form instead of browser alert() dialogs. Error div is hidden
on each new submit attempt so stale messages don't linger.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
utcnow_iso() now produces 'YYYY-MM-DD HH:MM:SS' (space separator) matching
SQLite's datetime('now') so lexicographic comparisons like
'published_at <= datetime(now)' work correctly.
Also add `id DESC` tiebreaker to get_ledger() ORDER BY to preserve
insertion order when multiple credits are added within the same second.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Also fixes test_supplier_webhooks.py fromisoformat() comparisons:
expires (naive, from DB) now compared against datetime.now(UTC).replace(tzinfo=None)
to avoid mixing naive/aware datetimes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Migrates 15 source files from the deprecated datetime.utcnow() API.
Uses utcnow() for in-memory math and utcnow_iso() (strftime format)
for SQLite TEXT column writes to preserve lexicographic sort order.
Also fixes datetime.utcfromtimestamp() in seo/_bing.py.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add _is_generating() helper — queries tasks table for pending generate_articles tasks
- Pass is_generating to article_results partial (both full page and HTMX route)
- article_results.html: render invisible hx-trigger="every 3s" div when generating;
polling stops naturally once generation completes and div is absent
- Add /admin/scenarios/results HTMX partial route with same is_generating logic
- Extract scenario table into admin/partials/scenario_results.html partial
- scenarios.html: wrap table in #scenario-results div, include partial
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Subtask 5/6: Wrap first "padelnomics Market Score" per language
section in anchor to /{language}/market-score. Updated templates:
- city-cost-de.md.jinja (DE intro + EN intro)
- city-pricing.md.jinja (DE comparison + EN comparison)
- country-overview.md.jinja (DE intro + EN intro)
Creates hub-and-spoke internal linking from hundreds of city
articles to the methodology page.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace deprecated datetime.utcnow() with datetime.now(UTC).
- utcnow() -> datetime: for in-memory datetime math
- utcnow_iso() -> str: strftime format preserving existing SQLite TEXT format
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Subtask 4/6: Add /market-score to STATIC_PATHS for sitemap
generation (both lang variants + hreflang). Add footer link
in Product column between Markets and For Suppliers.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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>