Commit Graph

352 Commits

Author SHA1 Message Date
Deeman
d15787caeb fix(extract): recheck window 90→60 min — matches hourly schedule and min slot duration
With hourly rechecks and 60-min minimum slots, a 90-min window causes each
slot to be queried twice. 60-min window = each slot caught exactly once in
the recheck immediately before it starts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 09:33:20 +01:00
Deeman
eca21dd147 chore(secrets): update PROXY_URLS in dev sops (tiered proxy config)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 09:31:56 +01:00
Deeman
5867c611f8 feat(admin): marketplace dashboard + HTMX lead management improvements
Admin marketplace (/admin/marketplace):
- Lead funnel cards: total / verified-new / unlocked / won / conversion rate
- Credit economy: issued / consumed / outstanding / 30-day burn
- Supplier engagement: active count / avg unlocks / response rate
- Feature flag toggles (lead_unlock, supplier_signup) with next= redirect
- Live activity stream (HTMX partial): last 50 lead / unlock / credit events

Admin leads list (/admin/leads):
- Summary cards: total / new+unverified / hot pipeline credits / forward rate
- Search filter (name, email, company) with HTMX live update
- Period pills: Today / 7d / 30d / All
- get_leads() now returns (rows, total_count); get_lead_stats() includes
  _total, _new_unverified, _hot_pipeline, _forward_rate

Admin lead detail (/admin/leads/<id>):
- Inline HTMX status change returning updated status badge partial
- Inline HTMX forward form returning updated forward history partial
  (replaces full-page reload on every status/forward action)
- Forward history table shows supplier, status, credit_cost, sent_at

Quote form extended with optional fields:
- build_context, glass_type, lighting_type, location_status,
  financing_status, services_needed, additional_info
  (captured in lead detail view but not required for heat scoring)

Sidebar nav: "Marketplace" tab added between Leads and Suppliers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 09:31:44 +01:00
Deeman
7af612504b feat(marketplace): lead matching notifications + weekly digest + CTA tracking
- notify_matching_suppliers task: on lead verification, finds growth/pro
  suppliers whose service_area matches the lead country and sends an
  instant alert email (LIMIT 20 suppliers per lead)
- send_weekly_lead_digest task: every Monday 08:00 UTC, sends paid
  suppliers a table of new matching leads from the past 7 days they
  haven't seen yet (LIMIT 5 per supplier)
- One-click CTA token: forward emails now include a "Mark as contacted"
  footer link; clicking sets forward status to 'contacted' immediately
- cta_token stored on lead_forwards after email send
- Supplier lead_respond endpoint: HTMX status update for forwarded leads
  (sent / viewed / contacted / quoted / won / lost / no_response)
- Supplier lead_cta_contacted endpoint: handles one-click email CTA,
  redirects to dashboard leads tab
- leads/routes.py: enqueue notify_matching_suppliers on quote verification

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 09:31:23 +01:00
Deeman
c84a5ffdd1 feat(db): migration 0022 — add response tracking to lead_forwards
Adds status_updated_at, supplier_note, and cta_token columns to the
lead_forwards table. cta_token gets a unique partial index for fast
one-click email CTA lookups.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 09:31:14 +01:00
Deeman
d5947af8d4 merge: maximum performance extraction (parallel pages + crash-safe partial JSONL)
# Conflicts:
#	.env.dev.sops
#	.env.prod.sops
#	extract/padelnomics_extract/src/padelnomics_extract/playtomic_tenants.py
2026-02-24 22:36:34 +01:00
Deeman
1ef22770aa docs: update CHANGELOG for extraction performance improvements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 22:31:19 +01:00
Deeman
9f010d8c0c perf(extract): parallel page fetching in tenants, drop EXTRACT_WORKERS env var
- playtomic_tenants.py: batch_size = len(proxy_urls) pages fired in parallel per
  batch; each page gets its own session + proxy; sorted(results) ensures
  deterministic done-detection; falls back to serial + THROTTLE_SECONDS when no
  proxies. Expected speedup: ~2.5 min → ~15 s with 10 proxies.
- .env.dev.sops, .env.prod.sops: remove EXTRACT_WORKERS (now derived from
  PROXY_URLS length)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 22:30:28 +01:00
Deeman
6116445b56 perf(extract): auto-detect workers from proxies, skip throttle on success, crash-safe partial JSONL
- proxy.py: delete unused make_sticky_selector()
- utils.py: add load_partial_results() + flush_partial_batch() for crash-resumable extraction
- playtomic_availability.py:
  - drop MAX_WORKERS / EXTRACT_WORKERS — worker_count = len(proxy_urls) or 1
  - skip time.sleep(THROTTLE_SECONDS) on success when proxy_url is set; keep sleeps for 429/5xx
  - replace cursor-based resumption with .partial.jsonl sidecar (flush every 50 records)
  - _fetch_venues_parallel accepts on_result callback for incremental partial-file flushing
  - mirror auto-detect worker count in extract_recheck()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 22:21:05 +01:00
Deeman
79d1b0e672 feat(extract): tiered proxy with circuit breaker + proxy provider research
- playtomic_tenants.py: simplify proxy cycler call (cycler() instead of
  cycler["next_proxy"]()) — matches refactored proxy API
- docs/proxy-provider-inventory.md: proxy provider comparison table for
  Playtomic scraping (~14k req/day, residential IPs, pay-per-GB)
- .env.*.sops: updated encrypted secrets (re-encrypted)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 22:15:11 +01:00
Deeman
19dd9843af fix(dev): scope granian --reload-paths to web/src to stop DB WAL triggering reloads
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 21:49:14 +01:00
Deeman
77d4c02db3 chore: run dev server with granian --reload for dev/prod parity
Replaces `python -m padelnomics.app` (Quart's built-in Hypercorn-based
dev runner) with granian directly. Adds granian[reload] extra which
pulls in watchfiles for file-change detection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 21:42:30 +01:00
Deeman
c95d66982b fix(logging): restore hypercorn logger silencing (still used by Quart dev server)
Quart depends on Hypercorn and uses it in app.run() → run_task().
Removing the silencing caused hypercorn.error noise in dev logs.
Keep both granian and hypercorn logger config.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 21:40:23 +01:00
Deeman
fda7da7d59 chore: replace hypercorn with granian (Rust ASGI server)
Granian is ~3-5x faster than Hypercorn in benchmarks. No code changes
needed — Quart is standard ASGI so any ASGI server works.

- web/pyproject.toml: hypercorn → granian>=1.6.0 (installed: 2.7.1)
- Dockerfile CMD: hypercorn → granian --interface asgi
- core.py setup_logging(): silence granian loggers instead of hypercorn's

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 21:26:19 +01:00
Deeman
aa7a8bad99 test: sync i18n tests to current translation values
- wiz_summary_label DE: "Aktuelle Werte" → "Aktuelle Zusammenfassung"
- add mscore_reife_chip + mscore_potenzial_chip to identical-value allowlist (branded product names)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 21:24:37 +01:00
Deeman
78ffbc313f feat(extract): parallel DAG scheduler + proxy rotation for tenants
- all.py: replace sequential loop with graphlib.TopologicalSorter + ThreadPoolExecutor
  - EXTRACTORS dict declares (func, [deps]) — self-documenting dependency graph
  - 8 extractors run in parallel immediately; availability starts as soon as
    tenants finishes (not after all others complete)
  - max_workers=len(EXTRACTORS) — all I/O-bound, no CPU contention
- playtomic_tenants.py: add proxy rotation via make_round_robin_cycler
  - no throttle when PROXY_URLS set (IP rotation removes per-IP rate concern)
  - keeps 2s throttle for direct runs
- _shared.py: add optional proxy_url param to run_extractor()
  - any extractor can opt in to proxy support via the shared session
- overpass_tennis.py: fix query timeout (out body → out center, timeout 180 → 300)
  - out center returns centroids only, not full geometry — fits within server limits
- playtomic_availability.py: fix CIRCUIT_BREAKER_THRESHOLD empty string crash
  - int(os.environ.get(..., "10")) → int(os.environ.get(...) or "10")

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 21:17:00 +01:00
Deeman
e8fb8f51f7 feat(pseo): add pSEO Engine admin tab
Operational dashboard at /admin/pseo for the programmatic SEO system:
content gap detection, data freshness signals, article health checks
(hreflang orphans, missing build files, broken scenario refs), and
live generation job monitoring with HTMX progress bars.

- _serving_meta.json written by export_serving.py after atomic DB swap
- content/health.py: pure async query functions for all health checks
- Migration 0021: progress_current/total/error_log on tasks table
- generate_articles() writes progress every 50 articles + on completion
- admin/pseo_routes.py: 6 routes, standalone blueprint
- 5 HTML templates + sidebar nav + fromjson Jinja filter
- 45 tests (all passing); 2 bugs caught and fixed during testing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

# Conflicts:
#	src/padelnomics/export_serving.py
2026-02-24 21:00:00 +01:00
Deeman
ec15012d00 test: update mock_fetch_analytics to handle COUNT(*) queries
count_template_data() uses fetch_analytics with a COUNT(*) query.
The pseo_env test fixture's mock returned TEST_ROWS for any unrecognized
query, causing a KeyError on rows[0]["cnt"]. Add a COUNT(*) branch that
returns [{cnt: len(TEST_ROWS)}].

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:55:38 +01:00
Deeman
a9b14b8f73 docs: update CHANGELOG + PROJECT.md for pSEO Engine
Records all Phase 1 deliverables: content gaps, data freshness,
health checks, generation job monitoring, 45 tests, bug fixes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 20:51:29 +01:00
Deeman
44c0dd0b8d refactor: minor TigerStyle cleanups
- export_serving.py: move `import re` to module level — was imported
  inside a loop body on every iteration
- sitemap.py: add comment documenting that the in-memory TTL cache is
  process-local (valid for single-worker deployment, Dockerfile --workers 1)
- playtomic_availability.py: use `or "10"` fallback for
  CIRCUIT_BREAKER_THRESHOLD env var to handle empty-string case

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:50:43 +01:00
Deeman
ee49862d91 test(pseo): add 45 tests for health checks + pSEO Engine admin routes
Covers content/health.py (get_template_stats, get_template_freshness,
get_content_gaps, check_hreflang_orphans, check_missing_build_files,
check_broken_scenario_refs, get_all_health_issues) and all 6 routes in
admin/pseo_routes.py (dashboard, health partial, gaps partial, generate
gaps, jobs list, job status polling).

Also fixes two bugs found while writing tests:
- check_hreflang_orphans: was grouping by url_path, but EN/DE articles
  have different paths. Now extracts natural key from slug pattern
  "{template_slug}-{lang}-{nk}" and groups by nk.
- pseo_job_status.html + pseo_jobs.html: | default('') | truncate() fails
  when completed_at is None (default() only handles undefined, not None).
  Fixed to (value or '') | truncate().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 20:50:03 +01:00
Deeman
83d148477d refactor: extract shared _query_scenarios() to remove duplication
scenarios() and scenario_results() both built the same WHERE clause and
ran the same filtered query. Extracted into _query_scenarios(search,
country, venue_type) -> (rows, total). Each handler is now ~10 lines
of param parsing + render_template.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:42:52 +01:00
Deeman
ad48f23cfc fix: add precondition assertions in extract pipeline
Assert landing_dir.is_dir() and year_month format (YYYY/MM) at the
entry point of each extract function — turning silent wrong-path bugs
into immediate AssertionError with a descriptive message.

Files changed:
- playtomic_availability.py: assert in _load_tenant_ids(), extract(),
  extract_recheck()
- eurostat.py: assert in extract()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:42:11 +01:00
Deeman
dd9ffd6d27 style: add units to variable names, name busy_timeout constant
- core.py: rename RATE_LIMIT_WINDOW → RATE_LIMIT_WINDOW_SECONDS (env var
  name RATE_LIMIT_WINDOW is unchanged — only the Python attribute)
- core.py: extract _BUSY_TIMEOUT_MS = 5000 local constant so the PRAGMA
  value is no longer a bare magic number
- worker.py: rename poll_interval → poll_interval_seconds

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 19:35:12 +01:00
Deeman
9107ba9bb8 perf: fix N+1 queries in templates(), handle_refill_monthly_credits()
templates() in admin:
- Replace per-template SELECT COUNT(*) articles queries with a single
  GROUP BY query before the loop — O(n) SQLite calls → O(1)
- Replace per-template SELECT * LIMIT 501 (for count) with a new
  count_template_data() that runs SELECT COUNT(*) — cheaper per call
- Add count_template_data() to content/__init__.py

handle_refill_monthly_credits() in worker:
- Replace N×3 per-supplier queries (fetch supplier, insert ledger,
  update balance) with 2 bulk SQL statements:
  1. INSERT INTO credit_ledger SELECT ... for all eligible suppliers
  2. UPDATE suppliers SET credit_balance = credit_balance + monthly_credits
- Wrap in single transaction() for atomicity
- Log total suppliers updated at INFO level

audiences() in admin:
- Add LIMIT 20 guard + comment explaining why one API call per audience
  is unavoidable (no bulk contacts endpoint in Resend)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 19:34:15 +01:00
Deeman
a051f9350f feat(pseo): create pSEO Engine admin templates + sidebar nav
- base_admin.html: add pSEO section with "pSEO Engine" link
- pseo_dashboard.html: template stats, freshness badges, HTMX gaps panels,
  recent jobs table, health issues HTMX-loaded section
- pseo_health.html: HTMX partial — hreflang orphans, missing build files,
  broken scenario refs (collapsible details with drill-down tables)
- pseo_gaps.html: HTMX partial — missing rows per template with generate button
- pseo_jobs.html: full jobs list with live progress bars (HTMX polling)
- pseo_job_status.html: HTMX partial — polls every 2s while job is pending
- app.py: add `fromjson` Jinja2 filter for displaying task payloads

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 19:33:18 +01:00
Deeman
ef7fe6e079 perf: add missing indexes on lead_requests, suppliers, credit_ledger
New migration 0021 adds 7 indexes for columns used in WHERE clauses
across admin list routes and the worker refill handler:
- lead_requests(lead_type) — for all lead-type filters
- lead_requests(lead_type, status) — compound filter in lead queries
- lead_requests(lead_type, verified_at) — refill eligibility queries
- lead_requests(country) — country filter in lead results
- suppliers(tier) — tier filter in supplier admin list
- suppliers(claimed_by) — claimed/unclaimed filter
- credit_ledger(supplier_id) — SUM(delta) balance aggregation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 19:32:15 +01:00
Deeman
8a6fd61432 fix: bound unbounded operations — LIMIT on scenarios, timeouts on DuckDB and Resend
- 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>
2026-02-24 19:31:34 +01:00
Deeman
04f59e9015 feat(pseo): add pseo_routes.py blueprint + register in app
New blueprint at /admin/pseo with:
- GET /admin/pseo/          → dashboard (stats, freshness, recent jobs)
- GET /admin/pseo/health    → HTMX partial: health issue lists
- GET /admin/pseo/gaps/<slug>             → HTMX partial: content gaps
- POST /admin/pseo/gaps/<slug>/generate  → enqueue gap-fill job
- GET /admin/pseo/jobs                   → full jobs list
- GET /admin/pseo/jobs/<id>/status       → HTMX polled progress bar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 19:30:18 +01:00
Deeman
dc38972d68 fix: replace silent exception handlers with explicit error logging
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>
2026-02-24 19:29:59 +01:00
Deeman
3dc7a7fc02 fix: add unbuffered python output in dev runner, cursor pattern in analytics
- 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>
2026-02-24 19:29:51 +01:00
Deeman
9cc853d38e feat(pseo): add generation progress tracking to tasks table
- Migration 0021: add progress_current, progress_total columns to tasks
- generate_articles(): accept task_id param, write progress every 50
  articles and once at completion via db_execute()
- worker.py handle_generate_articles: inject _task_id from process_task(),
  pass to generate_articles() so the pSEO dashboard can poll live progress

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 19:28:07 +01:00
Deeman
567100076f feat(pseo): add content/health.py — gap detection, freshness, health checks
New module with pure async query functions for the pSEO Engine dashboard:
- get_template_stats() — article counts by status/language per template
- get_template_freshness() — compare _serving_meta.json vs last article gen
- get_content_gaps() — DuckDB rows with no matching article per language
- check_hreflang_orphans() — published articles missing a sibling language
- check_missing_build_files() — published articles with no HTML on disk
- check_broken_scenario_refs() — articles referencing non-existent scenarios
- get_all_health_issues() — runs all checks, returns counts + detail lists

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 18:21:34 +01:00
Deeman
97c3aafea8 feat(pseo): write _serving_meta.json after atomic serving DB swap
Records exported_at_utc timestamp and per-table row counts immediately
after export_serving.py completes. The pSEO Engine dashboard reads this
file to show data freshness without querying file mtimes.

Also moves the inline `import re` to the top-level imports.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 17:30:57 +01:00
Deeman
3a169d9443 feat: dual market score system — Marktreife + Marktpotenzial scores
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>
2026-02-24 17:17:02 +01:00
Deeman
405efcfd19 docs: update docs and PROJECT.md for dual score pipeline
Task 8: documentation updates for the dual market score feature.

- CHANGELOG.md: comprehensive [Unreleased] entries for all additions
  (Marktpotenzial-Score, tennis courts, dim_locations, GeoNames expansion,
  DuckDB spatial, SOPS secrets, methodology page updates)
- docs/data-sources-inventory.md: add tennis courts Overpass row, update
  GeoNames entry (cities1000, username=padelnomics, higher score)
- transform/sqlmesh_padelnomics/CLAUDE.md: add dim_locations to conformed
  dimensions table, update source integration map with new pipeline branch,
  document ST_Distance_Sphere bounding-box pattern
- PROJECT.md: add dual score to In Progress, add Gemeinde pSEO + top-50
  ranking page to Next Up, add data backlog items (sports_centre, NUTS-3,
  opportunity map), add Decisions Log entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 17:12:22 +01:00
Deeman
165eaf48bf fix(admin): live search not firing on text input + spinner always visible
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>
2026-02-24 17:09:49 +01:00
Deeman
caec0c4410 feat(ui): apply wordmark span to score h2 headings, add TM to chips
- 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>
2026-02-24 17:08:20 +01:00
Deeman
6d52a122e5 fix(i18n): apply padelnomics wordmark consistently to score names
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>
2026-02-24 17:04:57 +01:00
Deeman
6d18c52983 merge: live search + loading indicators on all admin filter forms 2026-02-24 16:59:03 +01:00
Deeman
4731a91d02 feat(admin): live search with loading indicator on all admin filter forms
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>
2026-02-24 16:58:28 +01:00
Deeman
46e41db0f8 fix(i18n): polish German translations — remove English calques
Remove "Greenfield" (5×), fix "Venues" → "Anlagen", replace "belohnt"
(rewards) with idiomatic verb, fix "Einzugsgebiet-Lücke" → "Versorgungslücke",
"gemischte Signale" → "unklare Signallage", "Fokussiere" → "Konzentriere",
"Distanz" → "Entfernung", "Nachfragenachweise" → "Nachfragesignale".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 16:54:30 +01:00
Deeman
3d02d1654a merge: fix scenario filter focus styling 2026-02-24 16:49:49 +01:00
Deeman
1510cad697 fix(admin): use form-input class on scenario filter fields
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>
2026-02-24 16:49:25 +01:00
Deeman
3e2757e0a7 feat(ui): dual market score methodology page and translations
Task 7: update market_score.html + en/de translation keys for the
Marktreife-Score / Marktpotenzial-Score dual score system.

Changes:
- market_score.html: add Two Scores intro section (blue gradient card),
  Marktpotenzial-Score component cards (5 components), score bands for
  opportunity score, extend FAQ loop from 5 → 7 entries, add q6/q7
  to JSON-LD FAQPage structured data
- en.json: rename existing headings to Marktreife-Score prefix; add 30
  new mscore_dual_* / mscore_reife_* / mscore_potenzial_* / mscore_pot_*
  keys for dual score UI and FAQ q6/q7
- de.json: same 30 new keys in native German (linguistic mediation,
  not word-for-word translation); update renamed heading keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 16:48:52 +01:00
Deeman
4dcbb731b0 merge: spinner, batch commits, pre-compiled Jinja templates, timing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 16:44:13 +01:00
Deeman
af20c59ced feat(content): spinner, batch commits, pre-compiled templates, timing
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>
2026-02-24 16:44:02 +01:00
Deeman
d3db830c98 Merge branch 'master' into worktree-dual-market-score
# Conflicts:
#	.env.dev.sops
2026-02-24 16:38:25 +01:00
Deeman
f7a753d2d7 merge: content improvement tasks (FAQ, Market Score, DE translations, country names, DB perf)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 16:34:51 +01:00
Deeman
482b4f9fca perf(content): batch article generation in single transaction + upsert
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>
2026-02-24 16:34:16 +01:00