Replace the old CSV-upload-based CMS with an SSG architecture where
templates live in git as .md.jinja files with YAML frontmatter and
data comes directly from DuckDB serving tables. Only articles and
published_scenarios remain in SQLite for routing/state.
- Content module: discover, load, generate, preview functions
- Migration 0018: drop article_templates + template_data, recreate
articles + published_scenarios without FK references, add
template_slug/language/date_modified/seo_head columns
- Admin routes: read-only template views with generate/regenerate/preview
- SEO pipeline: canonical URLs, hreflang (EN+DE), JSON-LD (Article,
FAQPage, BreadcrumbList), Open Graph tags baked at generation time
- Example template: city-cost-de.md.jinja for German city market data
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move historical docs from docs/ and .claude/ to research/. Add superseded
notice to research/PLAN.md. Add CHANGELOG entries for previous fixes.
New: PROJECT.md (task tracker), docs/MARKETING.md (marketing strategy).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove raw/ layer — staging models now read landing JSON directly.
Rename all model schemas from padelnomics.* to staging.*/foundation.*/serving.*.
Web app queries updated to serving.planner_defaults via SERVING_DUCKDB_PATH.
Supervisor gets daily sleep interval between pipeline runs.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sync template from 29ac25b → v0.9.0 (29 template commits). Due to
template's _subdirectory migration, new files were manually rendered
rather than auto-merged by copier.
New files:
- .claude/CLAUDE.md + coding_philosophy.md (agent instructions)
- extract utils.py: SQLite state tracking for extraction runs
- extract/transform READMEs: architecture & pattern documentation
- infra/supervisor: systemd service + orchestration script
- Per-layer model READMEs (raw, staging, foundation, serving)
Also fixes copier-answers.yml (adds 4 feature toggles, removes stale
payment_provider key) and scopes CLAUDE.md gitignore to root only.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds sqlmesh_padelnomics UV workspace member at transform/sqlmesh_padelnomics/.
DuckDB gateway, LANDING_DIR variable, @daily cron on all models.
Raw layer (reads landing zone gzip JSON):
raw_overpass_courts — OSM padel court elements (nodes with lat/lon/tags)
raw_playtomic_tenants — Playtomic venue records (tenant_id, location, name)
raw_eurostat_population — Eurostat urb_cpop1 city population (unpivoted)
Staging layer (typed, deduped, country-resolved):
stg_padel_courts — OSM nodes only, ~100m bbox country approximation
stg_playtomic_venues — deduplicated Playtomic venues
stg_population — city population by year with integer types
Foundation layer:
dim_venues — deduped union of OSM + Playtomic (~100m grid)
dim_cities — Eurostat cities with population + venue counts
Serving layer (consumed by web app and SEO generation):
city_market_profile — OBT: market score, venue density, population per city
planner_defaults — per-city calculator pre-fill values with country median
fallbacks and competitive-pressure rate adjustments
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds padelnomics_extract UV workspace member at extract/padelnomics_extract/.
Implements three extractors in execute.py:
- extract_overpass(): global OverpassQL query for sport=padel OSM features
- extract_eurostat(): urb_cpop1 (city population) + ilc_di03 (NUTS2 income), etag-dedup
- extract_playtomic_tenants(): unauthenticated tenant search across 4 market bboxes,
paginated, deduplicated by tenant_id, throttled at 1 req/2s
Landing zone at LANDING_DIR (default data/landing) with per-source subdirectories.
Entry point: `extract` script calls extract_dataset() for all three in sequence.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add scripts/seed_content.py: inserts EN + DE article templates and
18 cities × 2 language data rows; run with --generate to produce 36
pre-built SEO articles (Germany 8, USA 6, UK 4 cities) each with
city-specific financial model overrides for unique content per article
- Fix bake_scenario_cards() to accept lang param and pass it to
scenario card partials; German articles now render German labels
- Fix _generate_from_template() to extract language from data row and
pass to calc() and bake_scenario_cards()
- Fix article_slug to use {template_slug}-{city_slug} preventing UNIQUE
collision when multiple templates generate articles for same city
- Fix _rebuild_article() to pass lang to bake_scenario_cards()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the 847-line client-side planner with an HTMX architecture:
- All tab content (CAPEX, Operating, Cash Flow, Returns, Metrics) rendered
server-side as Jinja2 partials; slider changes POST to /planner/calculate
which returns HTML; HTMX swaps into #tab-content
- Merge _PLANNER_TRANSLATIONS into _TRANSLATIONS; delete get_planner_translations()
and window.__PADELNOMICS_LOCALE__; all strings now {{ t.key }} in templates
- New form_to_state() and augment_d() helpers in routes.py; calculate endpoint
returns HTML instead of JSON; OOB swaps update header tag + wizard preview
- Add 5 Jinja2 filters: fmt_currency, fmt_k, fmt_pct, fmt_x, fmt_n
- Rewrite planner.js to ~200 lines: chart init on htmx:afterSettle, slider sync,
toggle management, wizard nav, scenario save/load, reset to defaults
- Add 7 new template partials: tab_capex, tab_operating, tab_cashflow,
tab_returns, tab_metrics, calculate_response, court_summary, wizard_preview
- Update test_phase0 to match new HTML-returning /calculate endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The "Get Supplier Quotes" CTA in planner.js used a hardcoded
/leads/quote path which 404s because the route is registered at
/<lang>/leads/quote. Inject the correct URL server-side via
window.__PADELNOMICS_QUOTE_URL__ using url_for, consistent with
the existing __PADELNOMICS_CALC_URL__ / __PADELNOMICS_SAVE_URL__ pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Widen nav container from 72rem to 80rem (1280px) — matches Zillow's
nav container width, more breathing room for items on large monitors
- Raise collapse breakpoint from 768px to 899px — links stay visible
until the screen is actually too narrow
- Add hamburger button (SVG 3-line icon) visible at < 900px
- Add mobile drop-down panel with all nav links grouped under Plan /
Explore / Account sections; overlay + Escape key close it
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
WAITLIST_MODE, LEADS_EMAIL, UMAMI_API_URL were set in GitLab CI but
never written to .env. Paddle vars made optional (:-) so deploys work
without them when in waitlist mode.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
pgrep may not be available in the litestream image. kill -0 1 checks
whether PID 1 (litestream, after exec) is alive — works in any container.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Re-enable deploy gate on litestream: pgrep-based healthcheck with 6
retries (30s window) after a 15s start period — broken backups now
fail the deploy loudly instead of silently succeeding.
Extend retention from 7d to 1yr (8760h): WAL frames are tiny for a
low-traffic app, R2 free tier covers years of storage.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
v0.5.8 dropped multi-replica support — remove the local path replica,
keeping only R2. Also disable litestream's healthcheck so deploy's
`up --wait` isn't gated on the backup service.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Router health check (nginx -t) fails when default.conf doesn't exist yet.
Move config write to before `up -d --wait` so nginx has a valid config
on first deploy or after a volume wipe. Router reload stays post-health-check.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace home address with c/o COCENTER, Koppoldstr. 1, 86551 Aichach
in imprint_de, imprint_en, privacy_de, privacy_en, and terms_de.
Jurisdiction clause ("Gerichtsstand Oldenburg") left untouched.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Migration atomicity:
- Remove conn.commit() and executescript() from all up() functions (0000,
0011, 0012, 0013, 0014, 0015); executescript() issued implicit COMMITs
which broke the batch-rollback guarantee of the migration runner
- Rewrite 0000 with individual conn.execute() calls (was a single
executescript block)
Deploy hardening:
- Add pre-migration DB backup step to deploy.sh: saves
app.db.pre-deploy-<timestamp> in the volume before every migration
- On health-check failure: restore the backup, then stop + exit
- On success: clean up old backups (keep last 3)
Litestream:
- Enable R2 as primary replica in litestream.yml (env-var placeholders)
- Add local /app/data/backups as secondary replica
- docker-compose: add auto-restore on empty volume (sh entrypoint runs
'litestream restore' before 'litestream replicate' if app.db missing)
- Add LITESTREAM_R2_* vars to .gitlab-ci.yml .env block and .env.example
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Ruff: auto-fixed 43 errors (unused imports, unsorted imports, bare
f-strings); manually fixed 6 remaining (unused vars, ambiguous `l`)
- Visual tests: server now builds schema via migrate() instead of the
deleted schema.sql; fixes ERR_CONNECTION_REFUSED on all tests
- Visual tests: updated assertions for current landing page (text logo
replacing img, .roi-calc replacing .teaser-calc, intentional dark
sections hero-dark/cta-card allowed, card count >=6, i18n-prefixed
logo href, h3 brightness threshold relaxed to 150)
- CSS: remove dead .nav-logo { line-height: 0 } (was for image logo,
collapsed text logo to zero height) and .nav-logo img rules
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All public-facing blueprints (public, planner, directory, content,
leads, suppliers) now serve under /<lang>/ (e.g. /en/, /de/). Internal
blueprints (auth, dashboard, admin, billing) are unchanged.
URL routing:
- Root / detects lang from cookie → Accept-Language → default 'en'
and 301-redirects to /<lang>/
- Quart url_value_preprocessor pops <lang> into g.lang; url_defaults
auto-injects it so existing url_for() calls need no changes
- Unsupported lang prefixes (e.g. /fr/) return 404
- Legacy bare URLs (/terms, /privacy, /imprint, /about, /features,
/suppliers) redirect 301 to /en/ equivalents
- robots.txt and sitemap.xml moved to app-level root; sitemap now
includes both en and de variants of every SEO page
- lang cookie persisted 1 year, SameSite=Lax
i18n:
- New i18n.py: SUPPORTED_LANGS, LANG_BLUEPRINTS, flat translation dicts
for ~20 nav/footer keys in en + de
- lang and t injected into every template context
Templates:
- base.html: <html lang="{{ lang }}">, hreflang tags (en/de/x-default)
on lang-prefixed pages, nav/footer strings translated via t.*, footer
language toggle EN | DE, SVG racket logo removed from footer
- 6 legal templates (terms/privacy/imprint × en/de) replacing old 3:
- English: GDPR sections with correct controller identity (Hendrik
Dreesmann, Zum Offizierskasino 1, 26127 Oldenburg), real sub-
processors (Umami self-hosted, Paddle, Resend with SCCs), German-
law jurisdiction
- German: DSGVO-konforme Datenschutzerklärung, AGB, Impressum per
§ 5 DDG; Kleinunternehmer § 19 UStG; LfD Niedersachsen reference
Tests updated to use /en/ prefixed routes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add cookie consent banner (_cookie_banner.html) — fixed bottom bar with
"Accept all" and "Manage preferences" (toggles for Essential/Functional);
consent stored in cookie_consent cookie (1 year); no-JS = only essential
cookies set (privacy-safe default)
- Add "Manage Cookies" link to footer Legal section to re-open the banner
- Extract Paddle.js init into _paddle.html partial; add {% block paddle %}
to base.html (empty by default); override on export, supplier signup, and
supplier dashboard pages — Paddle.js no longer loads on every page visit
- Gate ab_test() on functional cookie consent: variant picked per-request
always, but ab_* cookie only persisted when visitor has consented
- Update privacy policy section 6: full cookie disclosure (essential,
functional, payment categories + Umami cookieless note); fix "Plausible"
→ "Umami" in service providers list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes RESEND_AUDIENCE_WAITLIST env var. capture_waitlist_email() now
derives audience name from request.blueprints[0] (e.g. waitlist-auth,
waitlist-suppliers), lazily creates it via Resend API on first signup,
and caches the ID in a new resend_audiences table. Zero config beyond
RESEND_API_KEY — adding @waitlist_gate to any new blueprint auto-creates
its audience on first use.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Assigns visitors to experiment variants via cookie (30-day), sets g.ab_variant
and g.ab_tag, and conditionally adds data-tag to the Umami script tag so all
pageviews and events are automatically tagged in the Umami dashboard.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Eliminated dual-maintenance of schema.sql + versioned migrations.
All databases (fresh and existing) now replay migrations in order
starting from 0000_initial_schema.py. Removed _is_fresh_db() and
the fresh-DB fast-path that skipped migration execution.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Paddle sandbox sends lifecycle events (subscription.updated, etc.) with
"custom_data": null. The .get("custom_data", {}) default only applies
when the key is missing, not when the value is explicitly null, causing
AttributeError on the next .get() call. Also guarded subscription.activated
to skip when user_id is absent (was inserting user_id=0 → FK violation).
Replaced manual HMAC verification with paddle_billing.Notifications.Verifier
via a lightweight _WebhookRequest wrapper satisfying the SDK's Request Protocol.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
24 tests for credits.py (balance, spend, unlock, refill, ledger) and
10 integration tests for supplier webhook handlers (credit packs, sticky
boosts, subscription activation, business plan purchase). Removed
test_mobile_nav_no_overflow which never asserted its JS result.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Supplier dashboard: impersonate redirects to supplier dashboard when user
owns a supplier, sidenav active tab updates on click, listing preview
capped at 420px, insufficient-credits error shows balance and buy CTA,
boost buy buttons resolve Paddle price IDs server-side.
Dev tooling: dev_run.sh kills child processes on Ctrl-C (process
substitution fix), syncs Paddle products to DB on each run,
setup_paddle.py gains --sync mode and fixes Duration/Interval SDK change.
Seed data: each claimed supplier gets its own owner user and subscription.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Webhook handler called Verifier().verify() with raw bytes instead of a
request object, so signature verification always failed. Replaced with
manual HMAC check matching Paddle's ts=...;h1=... format. Updated tests
to produce correct signature format, mock the SDK instead of httpx for
manage/cancel routes, and expect JSON for overlay checkout.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds a complete content generation system for producing SEO articles
at scale with embedded financial scenario widgets. Includes DB schema
(published_scenarios, article_templates, template_data, articles with
FTS5), bulk generation pipeline with staggered publish dates, admin CRUD
for templates/scenarios/articles, public markets hub with HTMX filtering,
catch-all article serving from pre-rendered static HTML, sitemap
integration, and 94 pytest tests covering the full stack.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
setup_paddle.py creates a notification destination in Paddle and writes
the webhook secret + setting ID to .env. dev_run.sh resets the DB, seeds
data, and starts an ngrok tunnel to update the webhook URL automatically
for end-to-end checkout testing.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Quote wizard _accumulated hidden inputs used double-quote attribute delimiters
which broke on tojson output containing literal " characters — all step data was
lost by step 9. Admin dashboard crashed on credit_ledger queries referencing
wrong column names (amount/entry_type vs delta/event_type). Also: opaque nav bar,
pricing card button alignment, newsletter boost replaced with card color boost,
admin CRUD for suppliers/leads, dev seed data script, and playwright quote wizard
tests.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Migrate all checkouts to Paddle.js overlay (no redirect), move Paddle price
IDs from env vars to DB table, add 4-tab supplier dashboard (overview, leads,
listing, boosts), business plan PDF export with WeasyPrint, enhanced supplier
landing page with live stats, admin supplier management + feedback widget.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Guest quote submissions now require email verification before the lead
goes live. The verification click also creates a user account and logs
them in. Logged-in users submitting with their own email skip verification.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace Pico CSS CDN with Tailwind v4 standalone CLI (no Node.js).
Brand theme with navy/electric/accent palette, component classes,
self-hosted Commit Mono font. Docker multi-stage CSS build.
Logo links to dashboard when logged in.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Expand migrate.py docstring with algorithm, protocol, and design decisions
- Add 20-test suite for migration framework (test_migrations.py)
- Fix: empty env vars (SECRET_KEY=) now fall back to defaults via _env() helper
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>