chore: tests, changelog, project docs (Phase G)
- Rename test_market_score.py → test_padelnomics_score.py - Test 301 redirects from old /market-score URL - Update i18n parity allowlist (remove mscore_*, add pnscore brand terms) - Update CHANGELOG.md with single-score simplification - Update PROJECT.md: mark single-score done, fix location_profiles refs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **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).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Non-Latin city names on map** — GeoNames entries with CJK/Cyrillic/Arabic characters (e.g. "Seelow" showing Japanese) now filtered in `stg_population_geonames` via Latin-only regex.
|
||||||
|
- **Score range safety** — `location_profiles` clamps both scores to 0-100 via `LEAST/GREATEST`.
|
||||||
|
- **Pipeline cast fix** — `venue_pricing_benchmarks.sql` defensively casts `snapshot_date` VARCHAR to DATE.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **Dual-ring map markers** — map markers now encode two scores visually: inner core = primary score, outer ring = secondary score. Markets hub and country overview show Market Score (core) + Opportunity Score (ring). Opportunity map shows Opportunity Score (core) + Market Score (ring). City venue maps unchanged (navy dots). Color scale upgraded from 3-tier (green/amber/red) to 5-tier (deep green ≥80, teal ≥60, amber ≥40, orange-red ≥20, red <20) with distinct luminance at each tier for colorblind safety. Markers < 18px fall back to single-layer (no ring). Muted markers (cities without articles) show dashed ring outline. Highlighted markers (user's geo city) get blue outer glow. Opportunity map markers with score ≥75 pulse gently to highlight top investment targets. Tooltip lines now have inline color dots matching marker layers. All map scripts share a single `map-markers.js` module (`PNMarkers.scoreColor` + `PNMarkers.makeIcon`), replacing 3 duplicated implementations.
|
- **Dual-ring map markers** — map markers now encode two scores visually: inner core = primary score, outer ring = secondary score. Markets hub and country overview show Market Score (core) + Opportunity Score (ring). Opportunity map shows Opportunity Score (core) + Market Score (ring). City venue maps unchanged (navy dots). Color scale upgraded from 3-tier (green/amber/red) to 5-tier (deep green ≥80, teal ≥60, amber ≥40, orange-red ≥20, red <20) with distinct luminance at each tier for colorblind safety. Markers < 18px fall back to single-layer (no ring). Muted markers (cities without articles) show dashed ring outline. Highlighted markers (user's geo city) get blue outer glow. Opportunity map markers with score ≥75 pulse gently to highlight top investment targets. Tooltip lines now have inline color dots matching marker layers. All map scripts share a single `map-markers.js` module (`PNMarkers.scoreColor` + `PNMarkers.makeIcon`), replacing 3 duplicated implementations.
|
||||||
|
|
||||||
|
|||||||
@@ -159,6 +159,7 @@
|
|||||||
- [x] Feedback widget (HTMX POST, rate-limited)
|
- [x] Feedback widget (HTMX POST, rate-limited)
|
||||||
- [x] Interactive ROI calculator widget on landing page (JS sliders, no server call)
|
- [x] Interactive ROI calculator widget on landing page (JS sliders, no server call)
|
||||||
- [x] **CRO overhaul — homepage + supplier landing pages** — JTBD-driven copy rewrite (feature → outcome framing), proof strip, struggling-moments sections, "Why Padelnomics" comparison, rewritten FAQ, conditional supplier stats, data-backed proof points, tier-specific CTAs (EN + DE)
|
- [x] **CRO overhaul — homepage + supplier landing pages** — JTBD-driven copy rewrite (feature → outcome framing), proof strip, struggling-moments sections, "Why Padelnomics" comparison, rewritten FAQ, conditional supplier stats, data-backed proof points, tier-specific CTAs (EN + DE)
|
||||||
|
- [x] **Single-score simplification** — consolidated Market Score + Opportunity Score into one public "Padelnomics Score" (`opportunity_score`). Single-color map markers, unified methodology page at `/padelnomics-score`, i18n'd map tooltips, updated pSEO templates + business plan. Non-Latin city name filter in pipeline.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -180,12 +181,12 @@
|
|||||||
| Submit sitemap to Google Search Console | Set up Google Search Console + Bing Webmaster Tools (SEO hub ready — just add env vars) |
|
| Submit sitemap to Google Search Console | Set up Google Search Console + Bing Webmaster Tools (SEO hub ready — just add env vars) |
|
||||||
| Verify Litestream R2 backup running on prod | |
|
| Verify Litestream R2 backup running on prod | |
|
||||||
|
|
||||||
### Gemeinde-level pSEO (follow-up from dual score work)
|
### Gemeinde-level pSEO (follow-up from single-score simplification)
|
||||||
|
|
||||||
| 🛠 Tech |
|
| 🛠 Tech |
|
||||||
|--------|
|
|--------|
|
||||||
| Gemeinde-level pSEO article template — consumes `location_opportunity_profile` data, targets "Padel in [Ort]" + "Padel bauen in [Ort]" queries (zero SERP competition confirmed) |
|
| Gemeinde-level pSEO article template — consumes `location_profiles` data, targets "Padel in [Ort]" + "Padel bauen in [Ort]" queries (zero SERP competition confirmed) |
|
||||||
| "Top 50 underserved locations" ranking page — high-value SEO content, fully programmatic from `location_opportunity_profile` ORDER BY opportunity_score DESC |
|
| "Top 50 underserved locations" ranking page — high-value SEO content, fully programmatic from `location_profiles` ORDER BY opportunity_score DESC |
|
||||||
|
|
||||||
### Week 1–2 — First Revenue
|
### Week 1–2 — First Revenue
|
||||||
|
|
||||||
|
|||||||
@@ -57,10 +57,10 @@ _IDENTICAL_VALUE_ALLOWLIST = {
|
|||||||
# Business plan — Indoor/Outdoor same in DE, financial abbreviations
|
# Business plan — Indoor/Outdoor same in DE, financial abbreviations
|
||||||
"bp_indoor", "bp_outdoor",
|
"bp_indoor", "bp_outdoor",
|
||||||
"bp_lbl_ebitda", "bp_lbl_irr", "bp_lbl_moic", "bp_lbl_opex",
|
"bp_lbl_ebitda", "bp_lbl_irr", "bp_lbl_moic", "bp_lbl_opex",
|
||||||
# Market Score — branded term kept in English in DE
|
# Padelnomics Score — branded term kept in English in DE
|
||||||
"footer_market_score",
|
"footer_padelnomics_score", "bp_lbl_padelnomics_score",
|
||||||
# Market Score chip labels — branded product names, same in DE
|
# Map tooltip keys — some are identical in both languages
|
||||||
"mscore_reife_chip", "mscore_potenzial_chip",
|
"map_score_label", "map_indoor", "map_outdoor",
|
||||||
# Brand name "Padelnomics" — same in DE
|
# Brand name "Padelnomics" — same in DE
|
||||||
"landing_vs_col_us",
|
"landing_vs_col_us",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,36 @@
|
|||||||
"""Tests for the Market Score methodology page."""
|
"""Tests for the Padelnomics Score methodology page."""
|
||||||
|
|
||||||
|
|
||||||
async def test_en_returns_200(client):
|
async def test_en_returns_200(client):
|
||||||
resp = await client.get("/en/market-score")
|
resp = await client.get("/en/padelnomics-score")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
text = await resp.get_data(as_text=True)
|
text = await resp.get_data(as_text=True)
|
||||||
assert "Market Score" in text
|
assert "Padelnomics Score" in text
|
||||||
assert "padelnomics" in text
|
assert "padelnomics" in text
|
||||||
|
|
||||||
|
|
||||||
async def test_de_returns_200(client):
|
async def test_de_returns_200(client):
|
||||||
resp = await client.get("/de/market-score")
|
resp = await client.get("/de/padelnomics-score")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
text = await resp.get_data(as_text=True)
|
text = await resp.get_data(as_text=True)
|
||||||
assert "Market Score" in text
|
assert "Padelnomics Score" in text
|
||||||
assert "padelnomics" in text
|
assert "padelnomics" in text
|
||||||
|
|
||||||
|
|
||||||
async def test_legacy_redirect(client):
|
async def test_old_market_score_redirects(client):
|
||||||
resp = await client.get("/market-score")
|
resp = await client.get("/en/market-score")
|
||||||
assert resp.status_code == 301
|
assert resp.status_code == 301
|
||||||
assert resp.headers["Location"].endswith("/en/market-score")
|
assert "/padelnomics-score" in resp.headers["Location"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_de_old_market_score_redirects(client):
|
||||||
|
resp = await client.get("/de/market-score")
|
||||||
|
assert resp.status_code == 301
|
||||||
|
assert "/padelnomics-score" in resp.headers["Location"]
|
||||||
|
|
||||||
|
|
||||||
async def test_contains_jsonld(client):
|
async def test_contains_jsonld(client):
|
||||||
resp = await client.get("/en/market-score")
|
resp = await client.get("/en/padelnomics-score")
|
||||||
text = await resp.get_data(as_text=True)
|
text = await resp.get_data(as_text=True)
|
||||||
assert '"@type": "WebPage"' in text
|
assert '"@type": "WebPage"' in text
|
||||||
assert '"@type": "FAQPage"' in text
|
assert '"@type": "FAQPage"' in text
|
||||||
@@ -32,28 +38,27 @@ async def test_contains_jsonld(client):
|
|||||||
|
|
||||||
|
|
||||||
async def test_contains_faq_section(client):
|
async def test_contains_faq_section(client):
|
||||||
resp = await client.get("/en/market-score")
|
resp = await client.get("/en/padelnomics-score")
|
||||||
text = await resp.get_data(as_text=True)
|
text = await resp.get_data(as_text=True)
|
||||||
assert "Frequently Asked Questions" in text
|
assert "Frequently Asked Questions" in text
|
||||||
assert "<details" in text
|
assert "<details" in text
|
||||||
|
|
||||||
|
|
||||||
async def test_de_contains_faq_section(client):
|
async def test_de_contains_faq_section(client):
|
||||||
resp = await client.get("/de/market-score")
|
resp = await client.get("/de/padelnomics-score")
|
||||||
text = await resp.get_data(as_text=True)
|
text = await resp.get_data(as_text=True)
|
||||||
assert "Häufig gestellte Fragen" in text
|
assert "Häufige Fragen" in text
|
||||||
|
|
||||||
|
|
||||||
async def test_contains_og_tags(client):
|
async def test_contains_og_tags(client):
|
||||||
resp = await client.get("/en/market-score")
|
resp = await client.get("/en/padelnomics-score")
|
||||||
text = await resp.get_data(as_text=True)
|
text = await resp.get_data(as_text=True)
|
||||||
assert 'og:title' in text
|
assert 'og:title' in text
|
||||||
assert 'og:description' in text
|
assert 'og:description' in text
|
||||||
|
|
||||||
|
|
||||||
async def test_footer_has_market_score_link(client):
|
async def test_footer_has_padelnomics_score_link(client):
|
||||||
resp = await client.get("/en/market-score")
|
resp = await client.get("/en/padelnomics-score")
|
||||||
text = await resp.get_data(as_text=True)
|
text = await resp.get_data(as_text=True)
|
||||||
assert "/en/market-score" in text
|
assert "/padelnomics-score" in text
|
||||||
# Footer should link to market score page
|
assert "Padelnomics Score" in text
|
||||||
assert "Market Score" in text
|
|
||||||
Reference in New Issue
Block a user