Commit Graph

371 Commits

Author SHA1 Message Date
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
Deeman
ebfdc84a94 feat(transform): add dim_locations + dual market scoring models
dim_locations (foundation):
- Seeded from stg_population_geonames (all locations, not venue-dependent)
- Grain: (country_code, geoname_id)
- Enriched with: padel venues within 5km, nearest court distance (ST_Distance_Sphere),
  tennis courts within 25km, country income
- Covers zero-court Gemeinden for opportunity scoring

location_opportunity_profile (serving) — Padelnomics Marktpotenzial-Score:
- Answers "Where should I build?" — no padel_venue_count filter
- Formula: population (25) + income (20) + supply gap inverted (30) +
           catchment gap (15) + tennis culture (10) = 100pts
- Sorted by opportunity_score DESC

city_market_profile (serving) — Padelnomics Marktreife-Score:
- Add saturation discount (×0.85 when venues_per_100k > 8)
- Update header comment to reference Marktreife-Score branding
- Kept WHERE padel_venue_count > 0 (established markets only)
- column name market_score unchanged (avoids downstream breakage)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 16:28: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
c109488d9d feat(extract): expand GeoNames to cities1000 + add tennis court extractor
GeoNames:
- cities15000 → cities1000 (~140K global locations, pop ≥ 1K)
- Add lat/lon, admin1_code, admin2_code to output (needed for dim_locations)
- Expand feature codes to include PPLA3/4/5 (Gemeinden, cantons, etc.)
- Remove MIN_POPULATION=50K floor — cities1000 already pre-filters to ≥1K
- Update assertions for new scale (~100K+ expected)

Tennis courts:
- New overpass_tennis.py extractor (sport=tennis, 180s Overpass timeout)
- Registered as extract-overpass-tennis, added to EXTRACTORS list
- New stg_tennis_courts.sql staging model (grain: osm_id)

stg_population_geonames: add lat, lon, admin1_code, admin2_code columns

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 16:15:20 +01:00
Deeman
edf1e30444 feat(data): add spatial extension + extraction API keys
- Add GEONAMES_USERNAME=padelnomics and CENSUS_API_KEY to .env.dev.sops and .env.prod.sops
- Enable DuckDB spatial extension in SQLMesh config.yaml (ST_Distance_Sphere for distance calcs + future map features)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 16:05:46 +01:00
Deeman
dc10eeae29 merge: tiered proxy with circuit breaker for Playtomic extractor 2026-02-24 16:02:43 +01:00
Deeman
0b472e1a32 feat(extract): tiered proxy with circuit breaker for Playtomic availability
Adds a two-tier proxy system for the Playtomic availability extractor:
- Primary tier (PROXY_URLS): datacenter proxies, cheap and fast
- Fallback tier (PROXY_URLS_FALLBACK): residential rotating gateway, reliable

Circuit breaker opens after CIRCUIT_BREAKER_THRESHOLD (default: 10) consecutive
failures, permanently switching to the fallback tier for the rest of the run.
No auto-recovery — avoids flapping. If circuit opens with no fallback configured,
logs an error and writes partial results rather than continuing on a dead proxy pool.

Parallel mode submits futures in PARALLEL_BATCH_SIZE=100 batches so the circuit
breaker can stop new submissions after it opens.

New env vars added to .env.dev.sops (blank defaults):
  PROXY_URLS_FALLBACK          — residential/rotating gateway URL
  CIRCUIT_BREAKER_THRESHOLD    — consecutive failures before switching (default 10)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 16:01:50 +01:00
Deeman
cb08e04b0f merge: content improvements — country translations, FAQ collapsibles, Market Score branding, DE i18n fixes
- feat(i18n): add country name translations for article country badges
- feat(content): convert article FAQ sections to collapsible details/summary
- feat(content): rebrand stats-strip Market Score with padelnomics wordmark + color coding
- fix(i18n): improve German translation quality across 94 keys

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:26:33 +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
d42c4790b4 chore: update CHANGELOG for datetime deprecation fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 10:30:31 +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
abc8be12c3 docs: update CHANGELOG and PROJECT.md with Market Score page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:16:15 +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