diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8a94d53..018d0b4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
+### Fixed
+- **pSEO error details collapse** — clicking "Error" on a job row expanded the details, but they collapsed after ~2s because HTMX polling replaced the `
`. Jobs with errors now stop polling, keeping the `` element stable.
+- **UNIQUE constraint on article slug** — `ON CONFLICT(url_path, language)` upsert failed because a separate single-column `UNIQUE` on `slug` fired first. Migration 0030 drops the redundant `UNIQUE` from `slug` (keeps the index for lookups and the composite `UNIQUE(url_path, language)`).
+
### Changed
- **Opportunity Score v7 → v8** — better spread and discrimination across the full 0-100 range. Addressable market weight reduced (20→15 pts) with steeper sqrt curve (ceiling 1M, was LN/500K). Economic power reduced (15→10 pts). Supply deficit increased (40→50 pts) with market existence dampener: countries with zero padel venues get max 5 pts supply deficit (factor 0.1), scaling linearly to full credit at 50+ venues. NULL nearest-court distance now treated as 0 (assume nearby) instead of 0.5. Added `country_percentile` output column (PERCENT_RANK within country). Target: P5-P95 spread ≥40 pts (was 22), zero-venue countries avg <30.
- **Single-score simplification** — consolidated two public-facing scores (Market Score + Opportunity Score) into one **Padelnomics Score** (internally: `opportunity_score`). All maps, tooltips, article templates, and the methodology page now show a single score. Dual-ring markers reverted to single-color markers. `/market-score` route renamed to `/padelnomics-score` (old URL 301-redirects). All `mscore_*` i18n keys replaced with `pnscore_*`. Business plan queries `opportunity_score` from `location_profiles` (replaces legacy `city_market_overview` view). Map tooltip strings now i18n'd via `window.__MAP_T` (12 keys, EN + DE).
diff --git a/web/src/padelnomics/admin/templates/admin/pseo_job_status.html b/web/src/padelnomics/admin/templates/admin/pseo_job_status.html
index e55bd2b..544b06c 100644
--- a/web/src/padelnomics/admin/templates/admin/pseo_job_status.html
+++ b/web/src/padelnomics/admin/templates/admin/pseo_job_status.html
@@ -5,7 +5,7 @@
{% set pct = [((job.progress_current / job.progress_total) * 100) | int, 100] | min if job.progress_total else 0 %}
None:
+ # ── 1. Drop FTS triggers + virtual table ──────────────────────────────────
+ conn.execute("DROP TRIGGER IF EXISTS articles_ai")
+ conn.execute("DROP TRIGGER IF EXISTS articles_ad")
+ conn.execute("DROP TRIGGER IF EXISTS articles_au")
+ conn.execute("DROP TABLE IF EXISTS articles_fts")
+
+ # ── 2. Recreate articles without UNIQUE on slug ───────────────────────────
+ conn.execute("""
+ CREATE TABLE articles_new (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ url_path TEXT NOT NULL,
+ slug TEXT NOT NULL,
+ title TEXT NOT NULL,
+ meta_description TEXT,
+ country TEXT,
+ region TEXT,
+ og_image_url TEXT,
+ status TEXT NOT NULL DEFAULT 'draft',
+ published_at TEXT,
+ template_slug TEXT,
+ language TEXT NOT NULL DEFAULT 'en',
+ date_modified TEXT,
+ seo_head TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT,
+ group_key TEXT DEFAULT NULL,
+ noindex INTEGER NOT NULL DEFAULT 0,
+ article_type TEXT NOT NULL DEFAULT 'editorial',
+ UNIQUE(url_path, language)
+ )
+ """)
+ conn.execute("""
+ INSERT INTO articles_new
+ (id, url_path, slug, title, meta_description, country, region,
+ og_image_url, status, published_at, template_slug, language,
+ date_modified, seo_head, created_at, updated_at, group_key,
+ noindex, article_type)
+ SELECT id, url_path, slug, title, meta_description, country, region,
+ og_image_url, status, published_at, template_slug, language,
+ date_modified, seo_head, created_at, updated_at, group_key,
+ noindex, article_type
+ FROM articles
+ """)
+ conn.execute("DROP TABLE articles")
+ conn.execute("ALTER TABLE articles_new RENAME TO articles")
+
+ # ── 3. Recreate indexes ───────────────────────────────────────────────────
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_url_path ON articles(url_path)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_url_lang ON articles(url_path, language)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_slug ON articles(slug)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_status ON articles(status, published_at)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_article_type ON articles(article_type)")
+
+ # ── 4. Recreate FTS + triggers ────────────────────────────────────────────
+ conn.execute("""
+ CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
+ title, meta_description, country, region,
+ content='articles', content_rowid='id'
+ )
+ """)
+ conn.execute("""
+ CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN
+ INSERT INTO articles_fts(rowid, title, meta_description, country, region)
+ VALUES (new.id, new.title, new.meta_description, new.country, new.region);
+ END
+ """)
+ conn.execute("""
+ CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN
+ INSERT INTO articles_fts(articles_fts, rowid, title, meta_description, country, region)
+ VALUES ('delete', old.id, old.title, old.meta_description, old.country, old.region);
+ END
+ """)
+ conn.execute("""
+ CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN
+ INSERT INTO articles_fts(articles_fts, rowid, title, meta_description, country, region)
+ VALUES ('delete', old.id, old.title, old.meta_description, old.country, old.region);
+ INSERT INTO articles_fts(rowid, title, meta_description, country, region)
+ VALUES (new.id, new.title, new.meta_description, new.country, new.region);
+ END
+ """)