Commit Graph

123 Commits

Author SHA1 Message Date
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
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
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
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
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
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
Deeman
d1b0e89261 fix(i18n): country_name filter now handles 2-letter ISO codes from DB
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>
2026-02-24 16:16:45 +01:00
Deeman
1e0aa6002a fix(i18n): improve German translation quality across 94 keys
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>
2026-02-24 14:24:21 +01:00
Deeman
64728f5995 feat(content): rebrand stats-strip Market Score with padelnomics wordmark + color coding
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>
2026-02-24 14:17:03 +01:00
Deeman
3a147b78b6 feat(content): convert article FAQ sections to collapsible details/summary
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>
2026-02-24 14:16:12 +01:00
Deeman
2a1e8a781b feat(i18n): add country name translations for article country badges
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>
2026-02-24 14:12:51 +01:00
Deeman
1c21adc6a7 fix(logging): silence hypercorn.error and hypercorn.access child loggers
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>
2026-02-24 11:27:00 +01:00
Deeman
fae3bbdb43 fix(logging): silence aiosqlite DEBUG noise in setup_logging()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 11:19:59 +01:00
Deeman
00ad0f7e63 feat(logging): replace all print() with Python logging module
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
2026-02-24 11:10:02 +01:00
Deeman
77ca817925 feat(logging): convert scripts and migrations from print() to logging
- 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>
2026-02-24 11:00:07 +01:00
Deeman
ac4ad3179d feat(logging): convert planner/routes.py nurture warning to logging
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 10:52:16 +01:00
Deeman
39181ba452 feat(logging): convert worker.py print() to logging
- 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>
2026-02-24 10:51:37 +01:00
Deeman
b4b486e908 feat(logging): add setup_logging() to core.py, wire into app startup
- 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>
2026-02-24 10:50:10 +01:00
Deeman
f0c041a00c merge: fix datetime.utcnow() deprecation warnings across all files
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>
2026-02-24 10:36:26 +01:00
Deeman
d9de9e4cda fix(planner): replace alert() error popups with inline error banner
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>
2026-02-24 10:36:01 +01:00
Deeman
c5176d7d17 fix(admin): center confirm dialog with fixed position + transform
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 10:34:38 +01:00
Deeman
bd79617851 feat(admin): replace browser confirm() dialogs with native <dialog> modal
- Add styled <dialog id="confirm-dialog"> to base_admin.html — frosted
  backdrop, rounded card, Cancel / Confirm buttons
- Add confirmAction(message, form) JS helper — clones OK button to
  avoid listener accumulation, calls form.submit() on confirm
- Replace all 5 onclick="return confirm()" across templates with
  type="button" + confirmAction(..., this.closest('form'))
  · articles.html — Rebuild All
  · template_detail.html — Regenerate
  · generate_form.html — Generate Articles
  · scenario_results.html — Delete scenario
  · audience_contacts.html — Remove contact

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 10:33:38 +01:00
Deeman
e33b28025e fix: use SQLite-compatible space format in utcnow_iso(), fix credits ordering
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>
2026-02-24 10:30:18 +01:00
Deeman
6bd92c69ce fix(admin): use task_name column (not task_type) in _is_generating query
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 10:24:53 +01:00
Deeman
a05c230ce3 fix(tests): replace datetime.utcnow() with utcnow_iso() in test files
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>
2026-02-24 10:24:16 +01:00
Deeman
ff170d94ff merge: add padelnomics Market Score methodology page 2026-02-24 10:23:03 +01:00
Deeman
5644a1ebf8 fix: replace datetime.utcnow() with utcnow()/utcnow_iso() across all source files
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>
2026-02-24 10:22:42 +01:00
Deeman
b7485902e6 test: add Market Score methodology page tests
Subtask 6/6: 8 tests covering EN/DE 200 status, legacy 301 redirect,
JSON-LD schema types, FAQ sections, OG tags, footer link.
Add footer_market_score to i18n parity allowlist (branded term).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:15:36 +01:00
Deeman
4033e13e05 feat(admin): live-poll Articles and Scenarios tabs during generation
- 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>
2026-02-24 10:14:21 +01:00
Deeman
815edf3cef feat(seo): link first Market Score mention to methodology page
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>
2026-02-24 10:13:40 +01:00
Deeman
f76d2889e5 fix(core): add utcnow()/utcnow_iso() helpers, migrate core.py usages
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>
2026-02-24 10:12:50 +01:00
Deeman
2a038e48be feat(seo): add Market Score to sitemap and footer
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>
2026-02-24 10:12:29 +01:00
Deeman
39fe025e5b feat(template): create Market Score methodology page
Subtask 3/6: Standalone informational page extending base.html.
Sections: hero, what it measures (4-card grid), score bands,
data sources, limitations, CTAs, FAQ with details/summary.
JSON-LD: WebPage + BreadcrumbList + FAQPage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:12:03 +01:00
Deeman
33aa705ef9 feat(routes): add /market-score route + legacy redirect
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>
2026-02-24 10:11:07 +01:00
Deeman
c2bf82917a feat(i18n): add Market Score methodology page keys (EN + DE)
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>
2026-02-24 10:10:39 +01:00
Deeman
3d99b8c375 fix(cms): change articles unique constraint to (url_path, language)
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>
2026-02-24 09:53:58 +01:00
Deeman
a35036807e merge: lazy-import duckdb to fix CI hang + mount analytics.duckdb in prod 2026-02-24 04:27:06 +01:00