initial commit

This commit is contained in:
Deeman
2026-02-11 14:53:09 +01:00
commit c6ce001aae
55 changed files with 5745 additions and 0 deletions

10
.copier-answers.yml Normal file
View File

@@ -0,0 +1,10 @@
# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
_commit: 6a0c868
_src_path: /home/Deeman/Projects/materia_saas_boilerplate
author_email: ''
author_name: ''
base_url: https://padelnomics.io
description: Plan, finance, and build your padel business
include_paddle: false
project_name: Padelnomics
project_slug: padelnomics

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
CLAUDE.md

92
README.md Normal file
View File

@@ -0,0 +1,92 @@
# Forge - Minimal SaaS Boilerplate
A data-oriented, no-nonsense SaaS boilerplate following Casey Muratori's principles: solve the problem at hand, avoid premature abstraction, readable over "clean".
## Stack
- **Framework**: Quart (async Flask) + Pico CSS
- **Database**: SQLite + WAL mode + Litestream replication
- **Auth**: Magic link (passwordless)
- **Billing**: Stripe (+ optional Paddle)
- **Background jobs**: SQLite-based queue (no Redis)
- **Deployment**: Docker + Caddy + Hetzner/any VPS
## Usage
### Generate a new project
```bash
# Install copier
pip install copier
# Generate project
copier copy gh:yourusername/forge my-saas
# Or from local template
copier copy ./forge my-saas
```
### Answer the prompts
```
project_slug: my_saas
project_name: My SaaS
description: A subscription service for widgets
author_name: Your Name
author_email: you@example.com
base_url: https://my-saas.com
include_paddle: false
```
## After Generation
See the generated project's README for setup instructions.
## Philosophy
1. **Data-oriented**: Plain SQL, no ORM magic
2. **Flat structure**: Domain modules, not enterprise folders
3. **Concrete over abstract**: Write code first, extract patterns only when repeated 3+ times
4. **SQLite until proven otherwise**: Handles more than you think
5. **Server-rendered**: Pico CSS + minimal HTMX, no build step
6. **Measure don't assume**: Profile before optimizing
## Structure
```
padelnomics/
src/padelnomics/
app.py # Application factory
core.py # DB, config, email, shared utils
worker.py # Background task processor
auth/ # Domain: authentication
routes.py # Routes + queries + decorators
templates/
billing/ # Domain: subscriptions & payments
routes.py
templates/
dashboard/ # Domain: user dashboard
routes.py
templates/
public/ # Domain: marketing pages
routes.py
templates/
api/ # Domain: REST API
routes.py
templates/ # Shared templates
base.html
migrations/
schema.sql
migrate.py
```
## License
MIT

17
padelnomics/.dockerignore Normal file
View File

@@ -0,0 +1,17 @@
.venv/
__pycache__/
*.pyc
*.pyo
.git/
.gitignore
.env
.env.*
*.db
*.db-shm
*.db-wal
data/
backups/
.ruff_cache/
.pytest_cache/
.vscode/
.idea/

22
padelnomics/.env.example Normal file
View File

@@ -0,0 +1,22 @@
# App
APP_NAME=Padelnomics
SECRET_KEY=change-me-generate-a-real-secret
BASE_URL=http://localhost:5000
DEBUG=true
ADMIN_PASSWORD=admin
# Database
DATABASE_PATH=data/app.db
# Auth
MAGIC_LINK_EXPIRY_MINUTES=15
SESSION_LIFETIME_DAYS=30
# Email (Resend)
RESEND_API_KEY=
EMAIL_FROM=hello@padelnomics.io
ADMIN_EMAIL=leads@padelnomics.io
# Rate limiting
RATE_LIMIT_REQUESTS=100
RATE_LIMIT_WINDOW=60

37
padelnomics/.gitignore vendored Normal file
View File

@@ -0,0 +1,37 @@
# Python
__pycache__/
*.py[cod]
*$py.class
.venv/
venv/
.uv/
# Environment
.env
.env.local
# Database
*.db
*.db-shm
*.db-wal
data/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Testing
.pytest_cache/
.coverage
htmlcov/
# Build
dist/
build/
*.egg-info/

View File

@@ -0,0 +1 @@
3.12

17
padelnomics/Caddyfile Normal file
View File

@@ -0,0 +1,17 @@
# Replace with your domain
padelnomics.io {
reverse_proxy app:5000
# Security headers
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
X-XSS-Protection "1; mode=block"
Referrer-Policy strict-origin-when-cross-origin
}
# Logging
log {
output file /var/log/caddy/access.log
}
}

22
padelnomics/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
# Build stage
FROM python:3.12-slim AS build
COPY --from=ghcr.io/astral-sh/uv:0.8 /uv /uvx /bin/
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
COPY uv.lock pyproject.toml README.md ./
COPY src/ ./src/
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --no-dev --frozen
# Runtime stage
FROM python:3.12-slim AS runtime
ENV PATH="/app/.venv/bin:$PATH"
RUN useradd -m -u 1000 appuser
WORKDIR /app
RUN mkdir -p /app/data && chown -R appuser:appuser /app
COPY --from=build --chown=appuser:appuser /app .
USER appuser
ENV PYTHONUNBUFFERED=1
ENV DATABASE_PATH=/app/data/app.db
EXPOSE 5000
CMD ["hypercorn", "padelnomics.app:app", "--bind", "0.0.0.0:5000", "--workers", "1"]

140
padelnomics/README.md Normal file
View File

@@ -0,0 +1,140 @@
# Padelnomics
Plan, finance, and build your padel business
## Quick Start
```bash
# Install uv (if not already installed)
curl -LsSf https://astral.sh/uv/install.sh | sh
# Install dependencies
uv sync
# Copy environment file
cp .env.example .env
# Edit .env with your settings
# Initialize database
uv run python -m padelnomics.migrations.migrate
# Run the app
uv run python -m padelnomics.app
# In another terminal, run the worker
uv run python -m padelnomics.worker
```
Visit http://localhost:5000
## Configuration
Copy `.env.example` to `.env` and configure:
```bash
# Required
SECRET_KEY=generate-a-real-secret-key
RESEND_API_KEY=re_xxxx
# Stripe (required for billing)
STRIPE_SECRET_KEY=sk_test_xxxx
STRIPE_PUBLISHABLE_KEY=pk_test_xxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxx
STRIPE_PRICE_STARTER=price_xxxx
STRIPE_PRICE_PRO=price_xxxx
```
## Development
```bash
# Run with auto-reload
uv run python -m padelnomics.app
# Run worker
uv run python -m padelnomics.worker
# Run tests
uv run pytest
```
## Project Structure
```
src/padelnomics/
app.py # Application factory, blueprints
core.py # DB, config, email, middleware
worker.py # Background task processor
auth/ # Authentication domain
routes.py # Login, signup, magic links
templates/
billing/ # Billing domain
routes.py # Checkout, webhooks, subscriptions
templates/
dashboard/ # User dashboard domain
routes.py # Settings, API keys, search
templates/
public/ # Marketing pages domain
routes.py # Landing, pricing, terms
templates/
api/ # REST API domain
routes.py # API endpoints, rate limiting
templates/ # Shared templates
base.html
components/
email/
migrations/
schema.sql
migrate.py
```
## Deployment
### Docker (recommended)
```bash
# Build and run
docker compose up -d
# View logs
docker compose logs -f
```
### Manual
```bash
# Install dependencies
uv sync --frozen
# Run migrations
uv run python -m padelnomics.migrations.migrate
# Run with hypercorn
uv run hypercorn padelnomics.app:app --bind 0.0.0.0:5000
```
## Stripe Setup
1. Create products/prices in Stripe Dashboard
2. Add price IDs to `.env`
3. Set up webhook endpoint: `https://yourdomain.com/billing/webhook/stripe`
4. Enable events: `checkout.session.completed`, `customer.subscription.updated`, `customer.subscription.deleted`
## Litestream Backups
Configure `litestream.yml` with your S3/R2 bucket, then:
```bash
# Run with replication
litestream replicate -config litestream.yml
```
## License
MIT

View File

@@ -0,0 +1,70 @@
services:
app:
build: .
restart: unless-stopped
ports:
- "5000:5000"
volumes:
- ./data:/app/data
env_file: .env
environment:
- DATABASE_PATH=/app/data/app.db
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
worker:
build: .
restart: unless-stopped
command: python -m padelnomics.worker
volumes:
- ./data:/app/data
env_file: .env
environment:
- DATABASE_PATH=/app/data/app.db
depends_on:
- app
scheduler:
build: .
restart: unless-stopped
command: python -m padelnomics.worker scheduler
volumes:
- ./data:/app/data
env_file: .env
environment:
- DATABASE_PATH=/app/data/app.db
depends_on:
- app
# Optional: Caddy for HTTPS
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- app
# Optional: Litestream for backups
litestream:
image: litestream/litestream:latest
restart: unless-stopped
command: replicate -config /etc/litestream.yml
volumes:
- ./data:/app/data
- ./litestream.yml:/etc/litestream.yml:ro
depends_on:
- app
volumes:
caddy_data:
caddy_config:

View File

@@ -0,0 +1,22 @@
# Litestream configuration for SQLite replication
# Supports S3, Cloudflare R2, MinIO, etc.
dbs:
- path: /app/data/app.db
replicas:
# Option 1: AWS S3
# - url: s3://your-bucket/padelnomics/app.db
# access-key-id: ${AWS_ACCESS_KEY_ID}
# secret-access-key: ${AWS_SECRET_ACCESS_KEY}
# region: us-east-1
# Option 2: Cloudflare R2
# - url: s3://your-bucket/padelnomics/app.db
# access-key-id: ${R2_ACCESS_KEY_ID}
# secret-access-key: ${R2_SECRET_ACCESS_KEY}
# endpoint: https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com
# Option 3: Local file backup (for development)
- path: /app/data/backups
retention: 24h
snapshot-interval: 1h

View File

@@ -0,0 +1,41 @@
[project]
name = "padelnomics"
version = "0.1.0"
description = "Plan, finance, and build your padel business"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"quart>=0.19.0",
"aiosqlite>=0.19.0",
"httpx>=0.27.0",
"python-dotenv>=1.0.0",
"itsdangerous>=2.1.0",
"jinja2>=3.1.0",
"hypercorn>=0.17.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/padelnomics"]
[tool.uv]
dev-dependencies = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"ruff>=0.3.0",
]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "UP"]
ignore = ["E501"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

View File

@@ -0,0 +1,23 @@
#!/bin/bash
set -e
# Padelnomics Manual Backup Script
BACKUP_DIR="./backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
DB_PATH="./data/app.db"
mkdir -p "$BACKUP_DIR"
# Create backup using SQLite's backup command
sqlite3 "$DB_PATH" ".backup '$BACKUP_DIR/app_$TIMESTAMP.db'"
# Compress
gzip "$BACKUP_DIR/app_$TIMESTAMP.db"
echo "✅ Backup created: $BACKUP_DIR/app_$TIMESTAMP.db.gz"
# Clean old backups (keep last 7 days)
find "$BACKUP_DIR" -name "*.db.gz" -mtime +7 -delete
echo "🧹 Old backups cleaned"

View File

@@ -0,0 +1,22 @@
#!/bin/bash
set -e
# Padelnomics Deployment Script
echo "🚀 Deploying Padelnomics..."
# Pull latest code
git pull origin main
# Build and restart containers
docker compose build
docker compose up -d
# Run migrations
docker compose exec app uv run python -m padelnomics.migrations.migrate
# Health check
sleep 5
curl -f http://localhost:5000/health || exit 1
echo "✅ Deployment complete!"

View File

@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Smoke test: starts the app, hits every route, reports pass/fail.
# Usage: ./scripts/smoke-test.sh
set -euo pipefail
PORT=5099
APP_PID=""
COOKIE_JAR=$(mktemp)
PASS=0
FAIL=0
cleanup() {
[[ -n "$APP_PID" ]] && kill "$APP_PID" 2>/dev/null || true
rm -f "$COOKIE_JAR"
}
trap cleanup EXIT
# --- Start app ---
echo "Starting app on :$PORT ..."
PORT=$PORT uv run python -m padelnomics.app &>/dev/null &
APP_PID=$!
sleep 2
if ! kill -0 "$APP_PID" 2>/dev/null; then
echo "FAIL: App did not start"
exit 1
fi
# --- Helpers ---
check() {
local label="$1" url="$2" expected="${3:-200}" extra="${4:-}"
local code
code=$(curl -s -o /dev/null -w "%{http_code}" $extra "$url")
if [[ "$code" == "$expected" ]]; then
printf " OK %s %s\n" "$code" "$label"
PASS=$((PASS + 1))
else
printf " FAIL %s %s (expected %s)\n" "$code" "$label" "$expected"
FAIL=$((FAIL + 1))
fi
}
# --- Public routes (no auth) ---
echo ""
echo "Public routes:"
check "Landing page" "http://127.0.0.1:$PORT/"
check "Features" "http://127.0.0.1:$PORT/features"
check "About" "http://127.0.0.1:$PORT/about"
check "Terms" "http://127.0.0.1:$PORT/terms"
check "Privacy" "http://127.0.0.1:$PORT/privacy"
check "Sitemap" "http://127.0.0.1:$PORT/sitemap.xml"
check "Pricing" "http://127.0.0.1:$PORT/billing/pricing"
check "Login page" "http://127.0.0.1:$PORT/auth/login"
check "Signup page" "http://127.0.0.1:$PORT/auth/signup"
check "Health" "http://127.0.0.1:$PORT/health"
# --- Auth guards (should redirect when not logged in) ---
echo ""
echo "Auth guards (expect 302):"
check "Planner (no auth)" "http://127.0.0.1:$PORT/planner/" 302
check "Dashboard (no auth)" "http://127.0.0.1:$PORT/dashboard/" 302
check "Suppliers (no auth)" "http://127.0.0.1:$PORT/leads/suppliers" 302
# --- Dev login ---
echo ""
echo "Dev login:"
curl -s -o /dev/null -c "$COOKIE_JAR" -b "$COOKIE_JAR" -L "http://127.0.0.1:$PORT/auth/dev-login?email=test@test.com"
echo " OK Logged in as test@test.com"
# --- Authenticated routes ---
echo ""
echo "Authenticated routes:"
check "Dashboard" "http://127.0.0.1:$PORT/dashboard/" 200 "-b $COOKIE_JAR"
check "Settings" "http://127.0.0.1:$PORT/dashboard/settings" 200 "-b $COOKIE_JAR"
check "Planner" "http://127.0.0.1:$PORT/planner/" 200 "-b $COOKIE_JAR"
check "Suppliers" "http://127.0.0.1:$PORT/leads/suppliers" 200 "-b $COOKIE_JAR"
check "Financing" "http://127.0.0.1:$PORT/leads/financing" 200 "-b $COOKIE_JAR"
# --- Summary ---
echo ""
echo "---"
printf "Passed: %d Failed: %d\n" "$PASS" "$FAIL"
[[ $FAIL -eq 0 ]] && echo "All checks passed." || exit 1

View File

@@ -0,0 +1,3 @@
"""Padelnomics - Plan, finance, and build your padel business"""
__version__ = "0.1.0"

View File

@@ -0,0 +1,318 @@
"""
Admin domain: password-protected admin panel for managing users, tasks, etc.
"""
import secrets
from functools import wraps
from datetime import datetime, timedelta
from pathlib import Path
from quart import Blueprint, render_template, request, redirect, url_for, flash, session, g
from ..core import config, fetch_one, fetch_all, execute, csrf_protect
# Blueprint with its own template folder
bp = Blueprint(
"admin",
__name__,
template_folder=str(Path(__file__).parent / "templates"),
url_prefix="/admin",
)
# =============================================================================
# Config
# =============================================================================
def get_admin_password() -> str:
"""Get admin password from env. Generate one if not set (dev only)."""
import os
password = os.getenv("ADMIN_PASSWORD", "")
if not password and config.DEBUG:
# In dev, use a default password
return "admin"
return password
# =============================================================================
# SQL Queries
# =============================================================================
async def get_dashboard_stats() -> dict:
"""Get admin dashboard statistics."""
now = datetime.utcnow()
today = now.date().isoformat()
week_ago = (now - timedelta(days=7)).isoformat()
month_ago = (now - timedelta(days=30)).isoformat()
users_total = await fetch_one("SELECT COUNT(*) as count FROM users WHERE deleted_at IS NULL")
users_today = await fetch_one(
"SELECT COUNT(*) as count FROM users WHERE created_at >= ? AND deleted_at IS NULL",
(today,)
)
users_week = await fetch_one(
"SELECT COUNT(*) as count FROM users WHERE created_at >= ? AND deleted_at IS NULL",
(week_ago,)
)
subs = await fetch_one(
"SELECT COUNT(*) as count FROM subscriptions WHERE status = 'active'"
)
tasks_pending = await fetch_one("SELECT COUNT(*) as count FROM tasks WHERE status = 'pending'")
tasks_failed = await fetch_one("SELECT COUNT(*) as count FROM tasks WHERE status = 'failed'")
return {
"users_total": users_total["count"] if users_total else 0,
"users_today": users_today["count"] if users_today else 0,
"users_week": users_week["count"] if users_week else 0,
"active_subscriptions": subs["count"] if subs else 0,
"tasks_pending": tasks_pending["count"] if tasks_pending else 0,
"tasks_failed": tasks_failed["count"] if tasks_failed else 0,
}
async def get_users(limit: int = 50, offset: int = 0, search: str = None) -> list[dict]:
"""Get users with optional search."""
if search:
return await fetch_all(
"""
SELECT u.*, s.plan, s.status as sub_status
FROM users u
LEFT JOIN subscriptions s ON s.user_id = u.id AND s.status = 'active'
WHERE u.deleted_at IS NULL AND u.email LIKE ?
ORDER BY u.created_at DESC
LIMIT ? OFFSET ?
""",
(f"%{search}%", limit, offset)
)
return await fetch_all(
"""
SELECT u.*, s.plan, s.status as sub_status
FROM users u
LEFT JOIN subscriptions s ON s.user_id = u.id AND s.status = 'active'
WHERE u.deleted_at IS NULL
ORDER BY u.created_at DESC
LIMIT ? OFFSET ?
""",
(limit, offset)
)
async def get_user_by_id(user_id: int) -> dict | None:
"""Get user by ID with subscription info."""
return await fetch_one(
"""
SELECT u.*, s.plan, s.status as sub_status, s.stripe_customer_id
FROM users u
LEFT JOIN subscriptions s ON s.user_id = u.id
WHERE u.id = ?
""",
(user_id,)
)
async def get_recent_tasks(limit: int = 50) -> list[dict]:
"""Get recent tasks."""
return await fetch_all(
"""
SELECT * FROM tasks
ORDER BY created_at DESC
LIMIT ?
""",
(limit,)
)
async def get_failed_tasks() -> list[dict]:
"""Get failed tasks."""
return await fetch_all(
"SELECT * FROM tasks WHERE status = 'failed' ORDER BY created_at DESC"
)
async def retry_task(task_id: int) -> bool:
"""Retry a failed task."""
result = await execute(
"""
UPDATE tasks
SET status = 'pending', run_at = ?, error = NULL
WHERE id = ? AND status = 'failed'
""",
(datetime.utcnow().isoformat(), task_id)
)
return result > 0
async def delete_task(task_id: int) -> bool:
"""Delete a task."""
result = await execute("DELETE FROM tasks WHERE id = ?", (task_id,))
return result > 0
# =============================================================================
# Decorators
# =============================================================================
def admin_required(f):
"""Require admin authentication."""
@wraps(f)
async def decorated(*args, **kwargs):
if not session.get("is_admin"):
return redirect(url_for("admin.login"))
return await f(*args, **kwargs)
return decorated
# =============================================================================
# Routes
# =============================================================================
@bp.route("/login", methods=["GET", "POST"])
@csrf_protect
async def login():
"""Admin login page."""
admin_password = get_admin_password()
if not admin_password:
await flash("Admin access not configured. Set ADMIN_PASSWORD env var.", "error")
return redirect(url_for("public.landing"))
if session.get("is_admin"):
return redirect(url_for("admin.index"))
if request.method == "POST":
form = await request.form
password = form.get("password", "")
if secrets.compare_digest(password, admin_password):
session["is_admin"] = True
await flash("Welcome, admin!", "success")
return redirect(url_for("admin.index"))
else:
await flash("Invalid password.", "error")
return await render_template("login.html")
@bp.route("/logout", methods=["POST"])
@csrf_protect
async def logout():
"""Admin logout."""
session.pop("is_admin", None)
await flash("Logged out of admin.", "info")
return redirect(url_for("admin.login"))
@bp.route("/")
@admin_required
async def index():
"""Admin dashboard."""
stats = await get_dashboard_stats()
recent_users = await get_users(limit=10)
failed_tasks = await get_failed_tasks()
return await render_template(
"index.html",
stats=stats,
recent_users=recent_users,
failed_tasks=failed_tasks,
)
@bp.route("/users")
@admin_required
async def users():
"""User list."""
search = request.args.get("search", "").strip()
page = int(request.args.get("page", 1))
per_page = 50
offset = (page - 1) * per_page
user_list = await get_users(limit=per_page, offset=offset, search=search or None)
return await render_template(
"users.html",
users=user_list,
search=search,
page=page,
)
@bp.route("/users/<int:user_id>")
@admin_required
async def user_detail(user_id: int):
"""User detail page."""
user = await get_user_by_id(user_id)
if not user:
await flash("User not found.", "error")
return redirect(url_for("admin.users"))
return await render_template("user_detail.html", user=user)
@bp.route("/users/<int:user_id>/impersonate", methods=["POST"])
@admin_required
@csrf_protect
async def impersonate(user_id: int):
"""Impersonate a user (login as them)."""
user = await get_user_by_id(user_id)
if not user:
await flash("User not found.", "error")
return redirect(url_for("admin.users"))
# Store admin session so we can return
session["admin_impersonating"] = True
session["user_id"] = user_id
await flash(f"Now impersonating {user['email']}. Return to admin to stop.", "warning")
return redirect(url_for("dashboard.index"))
@bp.route("/stop-impersonating", methods=["POST"])
@csrf_protect
async def stop_impersonating():
"""Stop impersonating and return to admin."""
session.pop("user_id", None)
session.pop("admin_impersonating", None)
await flash("Stopped impersonating.", "info")
return redirect(url_for("admin.index"))
@bp.route("/tasks")
@admin_required
async def tasks():
"""Task queue management."""
task_list = await get_recent_tasks(limit=100)
failed = await get_failed_tasks()
return await render_template(
"tasks.html",
tasks=task_list,
failed_tasks=failed,
)
@bp.route("/tasks/<int:task_id>/retry", methods=["POST"])
@admin_required
@csrf_protect
async def task_retry(task_id: int):
"""Retry a failed task."""
success = await retry_task(task_id)
if success:
await flash("Task queued for retry.", "success")
else:
await flash("Could not retry task.", "error")
return redirect(url_for("admin.tasks"))
@bp.route("/tasks/<int:task_id>/delete", methods=["POST"])
@admin_required
@csrf_protect
async def task_delete(task_id: int):
"""Delete a task."""
success = await delete_task(task_id)
if success:
await flash("Task deleted.", "success")
else:
await flash("Could not delete task.", "error")
return redirect(url_for("admin.tasks"))

View File

@@ -0,0 +1,124 @@
{% extends "base.html" %}
{% block title %}Admin Dashboard - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<header style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h1>Admin Dashboard</h1>
{% if session.get('admin_impersonating') %}
<mark>Currently impersonating a user</mark>
<form method="post" action="{{ url_for('admin.stop_impersonating') }}" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="secondary outline" style="padding: 0.25rem 0.5rem;">Stop</button>
</form>
{% endif %}
</div>
<form method="post" action="{{ url_for('admin.logout') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="secondary outline">Logout</button>
</form>
</header>
<!-- Stats Grid -->
<div class="grid">
<article>
<header><small>Total Users</small></header>
<p style="font-size: 2rem; margin: 0;"><strong>{{ stats.users_total }}</strong></p>
<small>+{{ stats.users_today }} today, +{{ stats.users_week }} this week</small>
</article>
<article>
<header><small>Active Subscriptions</small></header>
<p style="font-size: 2rem; margin: 0;"><strong>{{ stats.active_subscriptions }}</strong></p>
</article>
<article>
<header><small>Task Queue</small></header>
<p style="font-size: 2rem; margin: 0;"><strong>{{ stats.tasks_pending }}</strong> pending</p>
{% if stats.tasks_failed > 0 %}
<small style="color: var(--del-color);">{{ stats.tasks_failed }} failed</small>
{% else %}
<small style="color: var(--ins-color);">0 failed</small>
{% endif %}
</article>
</div>
<!-- Quick Links -->
<div class="grid" style="margin-bottom: 2rem;">
<a href="{{ url_for('admin.users') }}" role="button" class="secondary outline">All Users</a>
<a href="{{ url_for('admin.tasks') }}" role="button" class="secondary outline">Task Queue</a>
<a href="{{ url_for('dashboard.index') }}" role="button" class="secondary outline">View as User</a>
</div>
<div class="grid">
<!-- Recent Users -->
<section>
<h2>Recent Users</h2>
<article>
{% if recent_users %}
<table>
<thead>
<tr>
<th>Email</th>
<th>Plan</th>
<th>Joined</th>
</tr>
</thead>
<tbody>
{% for u in recent_users %}
<tr>
<td>
<a href="{{ url_for('admin.user_detail', user_id=u.id) }}">{{ u.email }}</a>
</td>
<td>{{ u.plan or 'free' }}</td>
<td>{{ u.created_at[:10] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<a href="{{ url_for('admin.users') }}">View all →</a>
{% else %}
<p>No users yet.</p>
{% endif %}
</article>
</section>
<!-- Failed Tasks -->
<section>
<h2>Failed Tasks</h2>
<article>
{% if failed_tasks %}
<table>
<thead>
<tr>
<th>Task</th>
<th>Error</th>
<th></th>
</tr>
</thead>
<tbody>
{% for task in failed_tasks[:5] %}
<tr>
<td>{{ task.task_name }}</td>
<td><small>{{ task.error[:50] }}...</small></td>
<td>
<form method="post" action="{{ url_for('admin.task_retry', task_id=task.id) }}" style="margin: 0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="outline" style="padding: 0.25rem 0.5rem; margin: 0;">Retry</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<a href="{{ url_for('admin.tasks') }}">View all →</a>
{% else %}
<p style="color: var(--ins-color);">✓ No failed tasks</p>
{% endif %}
</article>
</section>
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block title %}Admin Login - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<article style="max-width: 400px; margin: 4rem auto;">
<header>
<h1>Admin Login</h1>
</header>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label for="password">
Password
<input
type="password"
id="password"
name="password"
required
autofocus
>
</label>
<button type="submit">Login</button>
</form>
</article>
</main>
{% endblock %}

View File

@@ -0,0 +1,106 @@
{% extends "base.html" %}
{% block title %}Tasks - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<header style="display: flex; justify-content: space-between; align-items: center;">
<h1>Task Queue</h1>
<a href="{{ url_for('admin.index') }}" role="button" class="secondary outline">← Dashboard</a>
</header>
<!-- Failed Tasks -->
{% if failed_tasks %}
<section>
<h2 style="color: var(--del-color);">Failed Tasks ({{ failed_tasks | length }})</h2>
<article style="border-color: var(--del-color);">
<table>
<thead>
<tr>
<th>ID</th>
<th>Task</th>
<th>Error</th>
<th>Retries</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
{% for task in failed_tasks %}
<tr>
<td>{{ task.id }}</td>
<td><code>{{ task.task_name }}</code></td>
<td>
<details>
<summary>{{ task.error[:40] if task.error else 'No error' }}...</summary>
<pre style="font-size: 0.75rem; white-space: pre-wrap;">{{ task.error }}</pre>
</details>
</td>
<td>{{ task.retries }}</td>
<td>{{ task.created_at[:16] }}</td>
<td>
<div style="display: flex; gap: 0.5rem;">
<form method="post" action="{{ url_for('admin.task_retry', task_id=task.id) }}" style="margin: 0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="outline" style="padding: 0.25rem 0.5rem; margin: 0;">Retry</button>
</form>
<form method="post" action="{{ url_for('admin.task_delete', task_id=task.id) }}" style="margin: 0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="outline secondary" style="padding: 0.25rem 0.5rem; margin: 0;">Delete</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</article>
</section>
{% endif %}
<!-- All Tasks -->
<section>
<h2>Recent Tasks</h2>
<article>
{% if tasks %}
<table>
<thead>
<tr>
<th>ID</th>
<th>Task</th>
<th>Status</th>
<th>Run At</th>
<th>Created</th>
<th>Completed</th>
</tr>
</thead>
<tbody>
{% for task in tasks %}
<tr>
<td>{{ task.id }}</td>
<td><code>{{ task.task_name }}</code></td>
<td>
{% if task.status == 'complete' %}
<span style="color: var(--ins-color);">✓ complete</span>
{% elif task.status == 'failed' %}
<span style="color: var(--del-color);">✗ failed</span>
{% elif task.status == 'pending' %}
<span style="color: var(--mark-background-color);">○ pending</span>
{% else %}
{{ task.status }}
{% endif %}
</td>
<td>{{ task.run_at[:16] if task.run_at else '-' }}</td>
<td>{{ task.created_at[:16] }}</td>
<td>{{ task.completed_at[:16] if task.completed_at else '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No tasks in queue.</p>
{% endif %}
</article>
</section>
</main>
{% endblock %}

View File

@@ -0,0 +1,73 @@
{% extends "base.html" %}
{% block title %}User: {{ user.email }} - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<header style="display: flex; justify-content: space-between; align-items: center;">
<h1>{{ user.email }}</h1>
<a href="{{ url_for('admin.users') }}" role="button" class="secondary outline">← Users</a>
</header>
<div class="grid">
<!-- User Info -->
<article>
<header><h3>User Info</h3></header>
<dl>
<dt>ID</dt>
<dd>{{ user.id }}</dd>
<dt>Email</dt>
<dd>{{ user.email }}</dd>
<dt>Name</dt>
<dd>{{ user.name or '-' }}</dd>
<dt>Created</dt>
<dd>{{ user.created_at }}</dd>
<dt>Last Login</dt>
<dd>{{ user.last_login_at or 'Never' }}</dd>
</dl>
</article>
<!-- Subscription -->
<article>
<header><h3>Subscription</h3></header>
<dl>
<dt>Plan</dt>
<dd>
{% if user.plan %}
<mark>{{ user.plan }}</mark>
{% else %}
free
{% endif %}
</dd>
<dt>Status</dt>
<dd>{{ user.sub_status or 'N/A' }}</dd>
{% if user.stripe_customer_id %}
<dt>Stripe Customer</dt>
<dd>
<a href="https://dashboard.stripe.com/customers/{{ user.stripe_customer_id }}" target="_blank">
{{ user.stripe_customer_id }}
</a>
</dd>
{% endif %}
</dl>
</article>
</div>
<!-- Actions -->
<article>
<header><h3>Actions</h3></header>
<div style="display: flex; gap: 1rem;">
<form method="post" action="{{ url_for('admin.impersonate', user_id=user.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="secondary">Impersonate User</button>
</form>
</div>
</article>
</main>
{% endblock %}

View File

@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block title %}Users - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<header style="display: flex; justify-content: space-between; align-items: center;">
<h1>Users</h1>
<a href="{{ url_for('admin.index') }}" role="button" class="secondary outline">← Dashboard</a>
</header>
<!-- Search -->
<form method="get" style="margin-bottom: 2rem;">
<div class="grid">
<input
type="search"
name="search"
placeholder="Search by email..."
value="{{ search }}"
>
<button type="submit">Search</button>
</div>
</form>
<!-- User Table -->
<article>
{% if users %}
<table>
<thead>
<tr>
<th>ID</th>
<th>Email</th>
<th>Name</th>
<th>Plan</th>
<th>Joined</th>
<th>Last Login</th>
<th></th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ u.id }}</td>
<td><a href="{{ url_for('admin.user_detail', user_id=u.id) }}">{{ u.email }}</a></td>
<td>{{ u.name or '-' }}</td>
<td>
{% if u.plan %}
<mark>{{ u.plan }}</mark>
{% else %}
free
{% endif %}
</td>
<td>{{ u.created_at[:10] }}</td>
<td>{{ u.last_login_at[:10] if u.last_login_at else 'Never' }}</td>
<td>
<form method="post" action="{{ url_for('admin.impersonate', user_id=u.id) }}" style="margin: 0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="outline secondary" style="padding: 0.25rem 0.5rem; margin: 0;">
Impersonate
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Pagination -->
<div style="display: flex; gap: 1rem; justify-content: center; margin-top: 1rem;">
{% if page > 1 %}
<a href="?page={{ page - 1 }}{% if search %}&search={{ search }}{% endif %}">← Previous</a>
{% endif %}
<span>Page {{ page }}</span>
{% if users | length == 50 %}
<a href="?page={{ page + 1 }}{% if search %}&search={{ search }}{% endif %}">Next →</a>
{% endif %}
</div>
{% else %}
<p>No users found.</p>
{% endif %}
</article>
</main>
{% endblock %}

View File

@@ -0,0 +1,107 @@
"""
Padelnomics - Application factory and entry point.
"""
from quart import Quart, g, session
from pathlib import Path
from .core import config, init_db, close_db, get_csrf_token, setup_request_id
def create_app() -> Quart:
"""Create and configure the Quart application."""
pkg_dir = Path(__file__).parent
app = Quart(
__name__,
template_folder=str(pkg_dir / "templates"),
static_folder=str(pkg_dir / "static"),
)
app.secret_key = config.SECRET_KEY
# Session config
app.config["SESSION_COOKIE_SECURE"] = not config.DEBUG
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
app.config["PERMANENT_SESSION_LIFETIME"] = 60 * 60 * 24 * config.SESSION_LIFETIME_DAYS
# Database lifecycle
@app.before_serving
async def startup():
await init_db()
@app.after_serving
async def shutdown():
await close_db()
# Security headers
@app.after_request
async def add_security_headers(response):
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
if not config.DEBUG:
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
# Load current user before each request
@app.before_request
async def load_user():
g.user = None
user_id = session.get("user_id")
if user_id:
from .auth.routes import get_user_by_id
g.user = await get_user_by_id(user_id)
# Template context globals
@app.context_processor
def inject_globals():
from datetime import datetime
return {
"config": config,
"user": g.get("user"),
"now": datetime.utcnow(),
"csrf_token": get_csrf_token,
}
# Health check
@app.route("/health")
async def health():
from .core import fetch_one
try:
await fetch_one("SELECT 1")
return {"status": "healthy", "db": "ok"}
except Exception as e:
return {"status": "unhealthy", "db": str(e)}, 500
# Register blueprints
from .auth.routes import bp as auth_bp
from .billing.routes import bp as billing_bp
from .dashboard.routes import bp as dashboard_bp
from .public.routes import bp as public_bp
from .planner.routes import bp as planner_bp
from .leads.routes import bp as leads_bp
from .admin.routes import bp as admin_bp
app.register_blueprint(public_bp)
app.register_blueprint(auth_bp)
app.register_blueprint(dashboard_bp)
app.register_blueprint(billing_bp)
app.register_blueprint(planner_bp)
app.register_blueprint(leads_bp)
app.register_blueprint(admin_bp)
# Request ID tracking
setup_request_id(app)
return app
app = create_app()
if __name__ == "__main__":
import os
port = int(os.environ.get("PORT", 5000))
app.run(debug=config.DEBUG, port=port)

View File

@@ -0,0 +1,278 @@
"""
Auth domain: magic link authentication, user management, decorators.
"""
import secrets
from functools import wraps
from datetime import datetime, timedelta
from pathlib import Path
from quart import Blueprint, render_template, request, redirect, url_for, session, flash, g
from ..core import config, fetch_one, fetch_all, execute, csrf_protect
# Blueprint with its own template folder
bp = Blueprint(
"auth",
__name__,
template_folder=str(Path(__file__).parent / "templates"),
url_prefix="/auth",
)
# =============================================================================
# SQL Queries
# =============================================================================
async def get_user_by_id(user_id: int) -> dict | None:
"""Get user by ID."""
return await fetch_one(
"SELECT * FROM users WHERE id = ? AND deleted_at IS NULL",
(user_id,)
)
async def get_user_by_email(email: str) -> dict | None:
"""Get user by email."""
return await fetch_one(
"SELECT * FROM users WHERE email = ? AND deleted_at IS NULL",
(email.lower(),)
)
async def create_user(email: str) -> int:
"""Create new user, return ID."""
now = datetime.utcnow().isoformat()
return await execute(
"INSERT INTO users (email, created_at) VALUES (?, ?)",
(email.lower(), now)
)
async def update_user(user_id: int, **fields) -> None:
"""Update user fields."""
if not fields:
return
sets = ", ".join(f"{k} = ?" for k in fields.keys())
values = list(fields.values()) + [user_id]
await execute(f"UPDATE users SET {sets} WHERE id = ?", tuple(values))
async def create_auth_token(user_id: int, token: str, minutes: int = None) -> int:
"""Create auth token for user."""
minutes = minutes or config.MAGIC_LINK_EXPIRY_MINUTES
expires = datetime.utcnow() + timedelta(minutes=minutes)
return await execute(
"INSERT INTO auth_tokens (user_id, token, expires_at) VALUES (?, ?, ?)",
(user_id, token, expires.isoformat())
)
async def get_valid_token(token: str) -> dict | None:
"""Get token if valid and not expired."""
return await fetch_one(
"""
SELECT at.*, u.email
FROM auth_tokens at
JOIN users u ON u.id = at.user_id
WHERE at.token = ? AND at.expires_at > ? AND at.used_at IS NULL
""",
(token, datetime.utcnow().isoformat())
)
async def mark_token_used(token_id: int) -> None:
"""Mark token as used."""
await execute(
"UPDATE auth_tokens SET used_at = ? WHERE id = ?",
(datetime.utcnow().isoformat(), token_id)
)
# =============================================================================
# Decorators
# =============================================================================
def login_required(f):
"""Require authenticated user."""
@wraps(f)
async def decorated(*args, **kwargs):
if not g.get("user"):
await flash("Please sign in to continue.", "warning")
return redirect(url_for("auth.login", next=request.path))
return await f(*args, **kwargs)
return decorated
# =============================================================================
# Routes
# =============================================================================
@bp.route("/login", methods=["GET", "POST"])
@csrf_protect
async def login():
"""Login page - request magic link."""
if g.get("user"):
return redirect(url_for("dashboard.index"))
if request.method == "POST":
form = await request.form
email = form.get("email", "").strip().lower()
if not email or "@" not in email:
await flash("Please enter a valid email address.", "error")
return redirect(url_for("auth.login"))
# Get or create user
user = await get_user_by_email(email)
if not user:
user_id = await create_user(email)
else:
user_id = user["id"]
# Create magic link token
token = secrets.token_urlsafe(32)
await create_auth_token(user_id, token)
# Queue email
from ..worker import enqueue
await enqueue("send_magic_link", {"email": email, "token": token})
await flash("Check your email for the sign-in link!", "success")
return redirect(url_for("auth.magic_link_sent", email=email))
return await render_template("login.html")
@bp.route("/signup", methods=["GET", "POST"])
@csrf_protect
async def signup():
"""Signup page - same as login but with different messaging."""
if g.get("user"):
return redirect(url_for("dashboard.index"))
plan = request.args.get("plan", "free")
if request.method == "POST":
form = await request.form
email = form.get("email", "").strip().lower()
selected_plan = form.get("plan", "free")
if not email or "@" not in email:
await flash("Please enter a valid email address.", "error")
return redirect(url_for("auth.signup", plan=selected_plan))
# Check if user exists
user = await get_user_by_email(email)
if user:
await flash("Account already exists. Please sign in.", "info")
return redirect(url_for("auth.login"))
# Create user
user_id = await create_user(email)
# Create magic link token
token = secrets.token_urlsafe(32)
await create_auth_token(user_id, token)
# Queue emails
from ..worker import enqueue
await enqueue("send_magic_link", {"email": email, "token": token})
await enqueue("send_welcome", {"email": email})
await flash("Check your email to complete signup!", "success")
return redirect(url_for("auth.magic_link_sent", email=email))
return await render_template("signup.html", plan=plan)
@bp.route("/verify")
async def verify():
"""Verify magic link token."""
token = request.args.get("token")
if not token:
await flash("Invalid or expired link.", "error")
return redirect(url_for("auth.login"))
token_data = await get_valid_token(token)
if not token_data:
await flash("Invalid or expired link. Please request a new one.", "error")
return redirect(url_for("auth.login"))
# Mark token as used
await mark_token_used(token_data["id"])
# Update last login
await update_user(token_data["user_id"], last_login_at=datetime.utcnow().isoformat())
# Set session
session.permanent = True
session["user_id"] = token_data["user_id"]
await flash("Successfully signed in!", "success")
# Redirect to intended page or dashboard
next_url = request.args.get("next", url_for("dashboard.index"))
return redirect(next_url)
@bp.route("/logout", methods=["POST"])
@csrf_protect
async def logout():
"""Log out user."""
session.clear()
await flash("You have been signed out.", "info")
return redirect(url_for("public.landing"))
@bp.route("/magic-link-sent")
async def magic_link_sent():
"""Confirmation page after magic link sent."""
email = request.args.get("email", "")
return await render_template("magic_link_sent.html", email=email)
@bp.route("/dev-login")
async def dev_login():
"""Instant login for development. Only works in DEBUG mode."""
if not config.DEBUG:
return "Not available", 404
email = request.args.get("email", "dev@localhost")
user = await get_user_by_email(email)
if not user:
user_id = await create_user(email)
else:
user_id = user["id"]
session.permanent = True
session["user_id"] = user_id
await flash(f"Dev login as {email}", "success")
return redirect(url_for("dashboard.index"))
@bp.route("/resend", methods=["POST"])
@csrf_protect
async def resend():
"""Resend magic link."""
form = await request.form
email = form.get("email", "").strip().lower()
if not email:
await flash("Email address required.", "error")
return redirect(url_for("auth.login"))
user = await get_user_by_email(email)
if user:
token = secrets.token_urlsafe(32)
await create_auth_token(user["id"], token)
from ..worker import enqueue
await enqueue("send_magic_link", {"email": email, "token": token})
# Always show success (don't reveal if email exists)
await flash("If that email is registered, we've sent a new link.", "success")
return redirect(url_for("auth.magic_link_sent", email=email))

View File

@@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}Sign In - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<article style="max-width: 400px; margin: 4rem auto;">
<header>
<h1>Sign In</h1>
<p>Enter your email to receive a sign-in link.</p>
</header>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label for="email">
Email
<input
type="email"
id="email"
name="email"
placeholder="you@example.com"
required
autofocus
>
</label>
<button type="submit">Send Sign-In Link</button>
</form>
<footer style="text-align: center; margin-top: 1rem;">
<small>
Don't have an account?
<a href="{{ url_for('auth.signup') }}">Sign up</a>
</small>
</footer>
</article>
</main>
{% endblock %}

View File

@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}Check Your Email - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<article style="max-width: 400px; margin: 4rem auto; text-align: center;">
<header>
<h1>Check Your Email</h1>
</header>
<p>We've sent a sign-in link to:</p>
<p><strong>{{ email }}</strong></p>
<p>Click the link in the email to sign in. The link expires in {{ config.MAGIC_LINK_EXPIRY_MINUTES }} minutes.</p>
<hr>
<details>
<summary>Didn't receive the email?</summary>
<ul style="text-align: left;">
<li>Check your spam folder</li>
<li>Make sure the email address is correct</li>
<li>Wait a minute and try again</li>
</ul>
<form method="post" action="{{ url_for('auth.resend') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="email" value="{{ email }}">
<button type="submit" class="secondary outline">Resend Link</button>
</form>
</details>
</article>
</main>
{% endblock %}

View File

@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}Sign Up - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<article style="max-width: 400px; margin: 4rem auto;">
<header>
<h1>Create Free Account</h1>
<p>Save your padel business plan, get supplier quotes, and find financing.</p>
</header>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label for="email">
Email
<input
type="email"
id="email"
name="email"
placeholder="you@example.com"
required
autofocus
>
</label>
<small>No credit card required. Full access to all features.</small>
<button type="submit">Create Free Account</button>
</form>
<footer style="text-align: center; margin-top: 1rem;">
<small>
Already have an account?
<a href="{{ url_for('auth.login') }}">Sign in</a>
</small>
</footer>
</article>
</main>
{% endblock %}

View File

@@ -0,0 +1,19 @@
"""
Services page: links to partner services (court suppliers, financing).
Replaces Stripe billing for Phase 1 — all features are free.
"""
from pathlib import Path
from quart import Blueprint, render_template
bp = Blueprint(
"billing",
__name__,
template_folder=str(Path(__file__).parent / "templates"),
url_prefix="/billing",
)
@bp.route("/pricing")
async def pricing():
"""Redirect pricing to services — everything is free in Phase 1."""
return await render_template("pricing.html")

View File

@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %}Free Financial Planner - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<hgroup>
<h1>100% Free. No Catch.</h1>
<p>The most sophisticated padel court financial planner available — completely free. Plan your investment with 60+ variables, sensitivity analysis, and professional-grade projections.</p>
</hgroup>
<div class="grid">
<article>
<header><strong>Financial Planner</strong></header>
<p><strong>Free</strong> — forever</p>
<ul>
<li>60+ adjustable variables</li>
<li>6 analysis tabs (CAPEX, Operating, Cash Flow, Returns, Metrics)</li>
<li>Sensitivity analysis (utilization + pricing)</li>
<li>Save unlimited scenarios</li>
<li>Interactive charts</li>
<li>Indoor/outdoor &amp; rent/buy models</li>
</ul>
{% if user %}
<a href="{{ url_for('planner.index') }}" role="button">Open Planner</a>
{% else %}
<a href="{{ url_for('auth.signup') }}" role="button">Create Free Account</a>
{% endif %}
</article>
<article>
<header><strong>Need Help Building?</strong></header>
<p>We connect you with verified partners</p>
<ul>
<li>Court supplier quotes</li>
<li>Financing &amp; bank connections</li>
<li>Construction planning</li>
<li>Equipment sourcing</li>
</ul>
{% if user %}
<a href="{{ url_for('leads.suppliers') }}" role="button" class="outline">Get Supplier Quotes</a>
{% else %}
<a href="{{ url_for('auth.signup') }}" role="button" class="outline">Sign Up to Get Started</a>
{% endif %}
</article>
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block title %}Welcome - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<article style="max-width: 500px; margin: 4rem auto; text-align: center;">
<header>
<h1>Welcome to Padelnomics!</h1>
</header>
<p>Your account is ready. Start planning your padel court investment with our financial planner.</p>
<p>
<a href="{{ url_for('planner.index') }}" role="button">Open Planner</a>
</p>
</article>
</main>
{% endblock %}

View File

@@ -0,0 +1,304 @@
"""
Core infrastructure: database, config, email, and shared utilities.
"""
import os
import secrets
import aiosqlite
import httpx
from pathlib import Path
from functools import wraps
from datetime import datetime, timedelta
from contextvars import ContextVar
from quart import request, session, g
from dotenv import load_dotenv
load_dotenv()
# =============================================================================
# Configuration
# =============================================================================
class Config:
APP_NAME: str = os.getenv("APP_NAME", "Padelnomics")
SECRET_KEY: str = os.getenv("SECRET_KEY", "change-me-in-production")
BASE_URL: str = os.getenv("BASE_URL", "http://localhost:5000")
DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true"
DATABASE_PATH: str = os.getenv("DATABASE_PATH", "data/app.db")
MAGIC_LINK_EXPIRY_MINUTES: int = int(os.getenv("MAGIC_LINK_EXPIRY_MINUTES", "15"))
SESSION_LIFETIME_DAYS: int = int(os.getenv("SESSION_LIFETIME_DAYS", "30"))
RESEND_API_KEY: str = os.getenv("RESEND_API_KEY", "")
EMAIL_FROM: str = os.getenv("EMAIL_FROM", "hello@padelnomics.io")
ADMIN_EMAIL: str = os.getenv("ADMIN_EMAIL", "leads@padelnomics.io")
RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
RATE_LIMIT_WINDOW: int = int(os.getenv("RATE_LIMIT_WINDOW", "60"))
config = Config()
# =============================================================================
# Database
# =============================================================================
_db: aiosqlite.Connection | None = None
async def init_db(path: str = None) -> None:
"""Initialize database connection with WAL mode."""
global _db
db_path = path or config.DATABASE_PATH
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
_db = await aiosqlite.connect(db_path)
_db.row_factory = aiosqlite.Row
await _db.execute("PRAGMA journal_mode=WAL")
await _db.execute("PRAGMA foreign_keys=ON")
await _db.execute("PRAGMA busy_timeout=5000")
await _db.execute("PRAGMA synchronous=NORMAL")
await _db.execute("PRAGMA cache_size=-64000")
await _db.execute("PRAGMA temp_store=MEMORY")
await _db.execute("PRAGMA mmap_size=268435456")
await _db.commit()
async def close_db() -> None:
"""Close database connection."""
global _db
if _db:
await _db.execute("PRAGMA wal_checkpoint(TRUNCATE)")
await _db.close()
_db = None
async def get_db() -> aiosqlite.Connection:
"""Get database connection."""
if _db is None:
await init_db()
return _db
async def fetch_one(sql: str, params: tuple = ()) -> dict | None:
"""Fetch a single row as dict."""
db = await get_db()
async with db.execute(sql, params) as cursor:
row = await cursor.fetchone()
return dict(row) if row else None
async def fetch_all(sql: str, params: tuple = ()) -> list[dict]:
"""Fetch all rows as list of dicts."""
db = await get_db()
async with db.execute(sql, params) as cursor:
rows = await cursor.fetchall()
return [dict(row) for row in rows]
async def execute(sql: str, params: tuple = ()) -> int:
"""Execute SQL and return lastrowid."""
db = await get_db()
async with db.execute(sql, params) as cursor:
await db.commit()
return cursor.lastrowid
async def execute_many(sql: str, params_list: list[tuple]) -> None:
"""Execute SQL for multiple parameter sets."""
db = await get_db()
await db.executemany(sql, params_list)
await db.commit()
class transaction:
"""Async context manager for transactions."""
async def __aenter__(self):
self.db = await get_db()
return self.db
async def __aexit__(self, exc_type, exc_val, exc_tb):
if exc_type is None:
await self.db.commit()
else:
await self.db.rollback()
return False
# =============================================================================
# Email
# =============================================================================
async def send_email(to: str, subject: str, html: str, text: str = None) -> bool:
"""Send email via Resend API."""
if not config.RESEND_API_KEY:
print(f"[EMAIL] Would send to {to}: {subject}")
return True
async with httpx.AsyncClient() as client:
response = await client.post(
"https://api.resend.com/emails",
headers={"Authorization": f"Bearer {config.RESEND_API_KEY}"},
json={
"from": config.EMAIL_FROM,
"to": to,
"subject": subject,
"html": html,
"text": text or html,
},
)
return response.status_code == 200
# =============================================================================
# CSRF Protection
# =============================================================================
def get_csrf_token() -> str:
"""Get or create CSRF token for current session."""
if "csrf_token" not in session:
session["csrf_token"] = secrets.token_urlsafe(32)
return session["csrf_token"]
def validate_csrf_token(token: str) -> bool:
"""Validate CSRF token."""
return token and secrets.compare_digest(token, session.get("csrf_token", ""))
def csrf_protect(f):
"""Decorator to require valid CSRF token for POST requests."""
@wraps(f)
async def decorated(*args, **kwargs):
if request.method == "POST":
form = await request.form
token = form.get("csrf_token") or request.headers.get("X-CSRF-Token")
if not validate_csrf_token(token):
return {"error": "Invalid CSRF token"}, 403
return await f(*args, **kwargs)
return decorated
# =============================================================================
# Rate Limiting (SQLite-based)
# =============================================================================
async def check_rate_limit(key: str, limit: int = None, window: int = None) -> tuple[bool, dict]:
"""
Check if rate limit exceeded. Returns (is_allowed, info).
Uses SQLite for storage - no Redis needed.
"""
limit = limit or config.RATE_LIMIT_REQUESTS
window = window or config.RATE_LIMIT_WINDOW
now = datetime.utcnow()
window_start = now - timedelta(seconds=window)
# Clean old entries and count recent
await execute(
"DELETE FROM rate_limits WHERE key = ? AND timestamp < ?",
(key, window_start.isoformat())
)
result = await fetch_one(
"SELECT COUNT(*) as count FROM rate_limits WHERE key = ? AND timestamp > ?",
(key, window_start.isoformat())
)
count = result["count"] if result else 0
info = {
"limit": limit,
"remaining": max(0, limit - count - 1),
"reset": int((window_start + timedelta(seconds=window)).timestamp()),
}
if count >= limit:
return False, info
# Record this request
await execute(
"INSERT INTO rate_limits (key, timestamp) VALUES (?, ?)",
(key, now.isoformat())
)
return True, info
def rate_limit(limit: int = None, window: int = None, key_func=None):
"""Decorator for rate limiting routes."""
def decorator(f):
@wraps(f)
async def decorated(*args, **kwargs):
if key_func:
key = key_func()
else:
key = f"ip:{request.remote_addr}"
allowed, info = await check_rate_limit(key, limit, window)
if not allowed:
response = {"error": "Rate limit exceeded", **info}
return response, 429
return await f(*args, **kwargs)
return decorated
return decorator
# =============================================================================
# Request ID Tracking
# =============================================================================
request_id_var: ContextVar[str] = ContextVar("request_id", default="")
def get_request_id() -> str:
"""Get current request ID."""
return request_id_var.get()
def setup_request_id(app):
"""Setup request ID middleware."""
@app.before_request
async def set_request_id():
rid = request.headers.get("X-Request-ID") or secrets.token_hex(8)
request_id_var.set(rid)
g.request_id = rid
@app.after_request
async def add_request_id_header(response):
response.headers["X-Request-ID"] = get_request_id()
return response
# =============================================================================
# Soft Delete Helpers
# =============================================================================
async def soft_delete(table: str, id: int) -> bool:
"""Mark record as deleted."""
result = await execute(
f"UPDATE {table} SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL",
(datetime.utcnow().isoformat(), id)
)
return result > 0
async def restore(table: str, id: int) -> bool:
"""Restore soft-deleted record."""
result = await execute(
f"UPDATE {table} SET deleted_at = NULL WHERE id = ?",
(id,)
)
return result > 0
async def hard_delete(table: str, id: int) -> bool:
"""Permanently delete record."""
result = await execute(f"DELETE FROM {table} WHERE id = ?", (id,))
return result > 0
async def purge_deleted(table: str, days: int = 30) -> int:
"""Purge records deleted more than X days ago."""
cutoff = (datetime.utcnow() - timedelta(days=days)).isoformat()
return await execute(
f"DELETE FROM {table} WHERE deleted_at IS NOT NULL AND deleted_at < ?",
(cutoff,)
)

View File

@@ -0,0 +1,67 @@
"""
Dashboard domain: user dashboard and settings.
"""
from datetime import datetime
from pathlib import Path
from quart import Blueprint, render_template, request, redirect, url_for, flash, g
from ..core import fetch_one, csrf_protect, soft_delete
from ..auth.routes import login_required, update_user
bp = Blueprint(
"dashboard",
__name__,
template_folder=str(Path(__file__).parent / "templates"),
url_prefix="/dashboard",
)
async def get_user_stats(user_id: int) -> dict:
scenarios = await fetch_one(
"SELECT COUNT(*) as count FROM scenarios WHERE user_id = ? AND deleted_at IS NULL",
(user_id,),
)
leads = await fetch_one(
"SELECT COUNT(*) as count FROM lead_requests WHERE user_id = ?",
(user_id,),
)
return {
"scenarios": scenarios["count"] if scenarios else 0,
"leads": leads["count"] if leads else 0,
}
@bp.route("/")
@login_required
async def index():
stats = await get_user_stats(g.user["id"])
return await render_template("index.html", stats=stats)
@bp.route("/settings", methods=["GET", "POST"])
@login_required
@csrf_protect
async def settings():
if request.method == "POST":
form = await request.form
await update_user(
g.user["id"],
name=form.get("name", "").strip() or None,
updated_at=datetime.utcnow().isoformat(),
)
await flash("Settings saved!", "success")
return redirect(url_for("dashboard.settings"))
return await render_template("settings.html")
@bp.route("/delete-account", methods=["POST"])
@login_required
@csrf_protect
async def delete_account():
from quart import session
await soft_delete("users", g.user["id"])
session.clear()
await flash("Your account has been deleted.", "info")
return redirect(url_for("public.landing"))

View File

@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}Dashboard - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<header>
<h1>Dashboard</h1>
<p>Welcome back{% if user.name %}, {{ user.name }}{% endif %}!</p>
</header>
<div class="grid">
<article>
<header><small>Saved Scenarios</small></header>
<p style="font-size: 2rem; margin: 0;"><strong>{{ stats.scenarios }}</strong></p>
<small>No limits</small>
</article>
<article>
<header><small>Lead Requests</small></header>
<p style="font-size: 2rem; margin: 0;"><strong>{{ stats.leads }}</strong></p>
<small>Supplier &amp; financing inquiries</small>
</article>
<article>
<header><small>Plan</small></header>
<p style="font-size: 2rem; margin: 0;"><strong>Free</strong></p>
<small>Full access to all features</small>
</article>
</div>
<section>
<h2>Quick Actions</h2>
<div class="grid">
<a href="{{ url_for('planner.index') }}" role="button">Open Planner</a>
<a href="{{ url_for('leads.suppliers') }}" role="button" class="secondary outline">Get Supplier Quotes</a>
<a href="{{ url_for('dashboard.settings') }}" role="button" class="secondary outline">Settings</a>
</div>
</section>
</main>
{% endblock %}

View File

@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}Settings - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<header>
<h1>Settings</h1>
</header>
<section>
<h2>Profile</h2>
<article>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label for="email">
Email
<input type="email" id="email" value="{{ user.email }}" disabled>
<small>Email cannot be changed</small>
</label>
<label for="name">
Name
<input type="text" id="name" name="name" value="{{ user.name or '' }}" placeholder="Your name">
</label>
<button type="submit">Save Changes</button>
</form>
</article>
</section>
<section>
<h2>Danger Zone</h2>
<article>
<p>Once you delete your account, there is no going back.</p>
<details>
<summary role="button" class="secondary outline">Delete Account</summary>
<p>This will delete all your scenarios and data permanently.</p>
<form method="post" action="{{ url_for('dashboard.delete_account') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="secondary">Yes, Delete My Account</button>
</form>
</details>
</article>
</section>
</main>
{% endblock %}

View File

@@ -0,0 +1,105 @@
"""
Leads domain: capture interest in court suppliers and financing.
"""
from datetime import datetime
from pathlib import Path
from quart import Blueprint, render_template, request, flash, redirect, url_for, g
from ..core import config, execute, fetch_one, csrf_protect, send_email
from ..auth.routes import login_required
bp = Blueprint(
"leads",
__name__,
template_folder=str(Path(__file__).parent / "templates"),
url_prefix="/leads",
)
@bp.route("/suppliers", methods=["GET", "POST"])
@login_required
@csrf_protect
async def suppliers():
if request.method == "POST":
form = await request.form
await execute(
"""INSERT INTO lead_requests
(user_id, lead_type, location, court_count, budget_estimate, message, created_at)
VALUES (?, 'supplier', ?, ?, ?, ?, ?)""",
(
g.user["id"],
form.get("location", ""),
form.get("court_count", 0),
form.get("budget", 0),
form.get("message", ""),
datetime.utcnow().isoformat(),
),
)
# Notify admin
await send_email(
config.ADMIN_EMAIL,
f"New supplier lead from {g.user['email']}",
f"<p>Location: {form.get('location')}<br>Courts: {form.get('court_count')}<br>Budget: {form.get('budget')}<br>Message: {form.get('message')}</p>",
)
await flash("Thanks! We'll connect you with verified court suppliers.", "success")
return redirect(url_for("leads.suppliers"))
# Pre-fill from latest scenario
scenario = await fetch_one(
"SELECT state_json FROM scenarios WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 1",
(g.user["id"],),
)
prefill = {}
if scenario:
import json
try:
state = json.loads(scenario["state_json"])
prefill["court_count"] = state.get("dblCourts", 0) + state.get("sglCourts", 0)
except (json.JSONDecodeError, TypeError):
pass
return await render_template("suppliers.html", prefill=prefill)
@bp.route("/financing", methods=["GET", "POST"])
@login_required
@csrf_protect
async def financing():
if request.method == "POST":
form = await request.form
await execute(
"""INSERT INTO lead_requests
(user_id, lead_type, location, court_count, budget_estimate, message, created_at)
VALUES (?, 'financing', ?, ?, ?, ?, ?)""",
(
g.user["id"],
form.get("location", ""),
form.get("court_count", 0),
form.get("budget", 0),
form.get("message", ""),
datetime.utcnow().isoformat(),
),
)
await send_email(
config.ADMIN_EMAIL,
f"New financing lead from {g.user['email']}",
f"<p>Location: {form.get('location')}<br>Courts: {form.get('court_count')}<br>Budget: {form.get('budget')}<br>Message: {form.get('message')}</p>",
)
await flash("Thanks! We'll connect you with financing partners.", "success")
return redirect(url_for("leads.financing"))
scenario = await fetch_one(
"SELECT state_json FROM scenarios WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 1",
(g.user["id"],),
)
prefill = {}
if scenario:
import json
try:
state = json.loads(scenario["state_json"])
prefill["court_count"] = state.get("dblCourts", 0) + state.get("sglCourts", 0)
except (json.JSONDecodeError, TypeError):
pass
return await render_template("financing.html", prefill=prefill)

View File

@@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block title %}Find Financing - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<hgroup>
<h1>Find Financing for Your Padel Project</h1>
<p>We work with banks and investors experienced in sports facility financing. Tell us about your project and we'll make introductions.</p>
</hgroup>
<article>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label for="location">Project location
<input type="text" id="location" name="location" placeholder="City, region, or country" required>
</label>
<label for="court_count">Number of courts
<input type="number" id="court_count" name="court_count" min="1" max="50" value="{{ prefill.get('court_count', 4) }}">
</label>
<label for="budget">Estimated total investment
<input type="text" id="budget" name="budget" placeholder="e.g. 500000">
<small>The total CAPEX from your financial plan.</small>
</label>
<label for="message">Additional details
<textarea id="message" name="message" rows="4" placeholder="How much equity can you contribute? Do you have existing real estate? Any existing banking relationships?"></textarea>
</label>
<button type="submit">Find Financing Partners</button>
</form>
</article>
</main>
{% endblock %}

View File

@@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block title %}Get Court Supplier Quotes - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<hgroup>
<h1>Get Court Supplier Quotes</h1>
<p>Tell us about your project and we'll connect you with verified padel court suppliers who can provide detailed quotes.</p>
</hgroup>
<article>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label for="location">Where do you want to build?
<input type="text" id="location" name="location" placeholder="City, region, or country" required>
</label>
<label for="court_count">How many courts?
<input type="number" id="court_count" name="court_count" min="1" max="50" value="{{ prefill.get('court_count', 4) }}">
</label>
<label for="budget">Estimated total budget
<input type="text" id="budget" name="budget" placeholder="e.g. 500000">
<small>Optional — helps suppliers tailor their proposals.</small>
</label>
<label for="message">Tell us more about your project
<textarea id="message" name="message" rows="4" placeholder="Indoor or outdoor? New build or renovation? Timeline? Any specific requirements?"></textarea>
</label>
<button type="submit">Request Supplier Quotes</button>
</form>
</article>
</main>
{% endblock %}

View File

@@ -0,0 +1,53 @@
"""
Simple migration runner. Runs schema.sql against the database.
"""
import sqlite3
from pathlib import Path
import os
import sys
# Add parent to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
from dotenv import load_dotenv
load_dotenv()
def migrate():
"""Run migrations."""
# Get database path from env or default
db_path = os.getenv("DATABASE_PATH", "data/app.db")
# Ensure directory exists
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
# Read schema
schema_path = Path(__file__).parent / "schema.sql"
schema = schema_path.read_text()
# Connect and execute
conn = sqlite3.connect(db_path)
# Enable WAL mode
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
# Run schema
conn.executescript(schema)
conn.commit()
print(f"✓ Migrations complete: {db_path}")
# Show tables
cursor = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
)
tables = [row[0] for row in cursor.fetchall()]
print(f" Tables: {', '.join(tables)}")
conn.close()
if __name__ == "__main__":
migrate()

View File

@@ -0,0 +1,84 @@
-- Padelnomics Database Schema
-- Run with: python -m padelnomics.migrations.migrate
-- Users
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
name TEXT,
created_at TEXT NOT NULL,
updated_at TEXT,
last_login_at TEXT,
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_deleted ON users(deleted_at);
-- Auth Tokens (magic links)
CREATE TABLE IF NOT EXISTS auth_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
token TEXT UNIQUE NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_auth_tokens_token ON auth_tokens(token);
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id);
-- Rate Limits
CREATE TABLE IF NOT EXISTS rate_limits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL,
timestamp TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_rate_limits_key ON rate_limits(key, timestamp);
-- Background Tasks
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_name TEXT NOT NULL,
payload TEXT,
status TEXT NOT NULL DEFAULT 'pending',
run_at TEXT NOT NULL,
retries INTEGER DEFAULT 0,
error TEXT,
created_at TEXT NOT NULL,
completed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status, run_at);
-- Scenarios (core domain entity)
CREATE TABLE IF NOT EXISTS scenarios (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
name TEXT NOT NULL DEFAULT 'Untitled Scenario',
state_json TEXT NOT NULL,
location TEXT,
is_default INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT,
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_scenarios_user ON scenarios(user_id);
-- Lead requests (when user wants supplier quotes or financing)
CREATE TABLE IF NOT EXISTS lead_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
lead_type TEXT NOT NULL,
scenario_id INTEGER REFERENCES scenarios(id),
location TEXT,
court_count INTEGER,
budget_estimate INTEGER,
message TEXT,
status TEXT DEFAULT 'new',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_leads_status ON lead_requests(status);

View File

@@ -0,0 +1,138 @@
"""
Planner domain: padel court financial planner + scenario management.
"""
import json
from datetime import datetime
from pathlib import Path
from quart import Blueprint, render_template, request, g, jsonify
from ..core import fetch_one, fetch_all, execute, csrf_protect
from ..auth.routes import login_required
bp = Blueprint(
"planner",
__name__,
template_folder=str(Path(__file__).parent / "templates"),
url_prefix="/planner",
)
# =============================================================================
# SQL Queries
# =============================================================================
async def count_scenarios(user_id: int) -> int:
row = await fetch_one(
"SELECT COUNT(*) as cnt FROM scenarios WHERE user_id = ? AND deleted_at IS NULL",
(user_id,),
)
return row["cnt"] if row else 0
async def get_default_scenario(user_id: int) -> dict | None:
return await fetch_one(
"SELECT * FROM scenarios WHERE user_id = ? AND is_default = 1 AND deleted_at IS NULL",
(user_id,),
)
async def get_scenarios(user_id: int) -> list[dict]:
return await fetch_all(
"SELECT id, name, location, is_default, created_at, updated_at FROM scenarios WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC",
(user_id,),
)
# =============================================================================
# Routes
# =============================================================================
@bp.route("/")
@login_required
async def index():
scenario_count = await count_scenarios(g.user["id"])
default = await get_default_scenario(g.user["id"])
return await render_template(
"planner.html",
initial_state=default["state_json"] if default else None,
scenario_count=scenario_count,
)
@bp.route("/scenarios", methods=["GET"])
@login_required
async def scenario_list():
scenarios = await get_scenarios(g.user["id"])
return await render_template("partials/scenario_list.html", scenarios=scenarios)
@bp.route("/scenarios/save", methods=["POST"])
@login_required
@csrf_protect
async def save_scenario():
data = await request.get_json()
name = data.get("name", "Untitled Scenario")
state_json = data.get("state_json", "{}")
location = data.get("location", "")
scenario_id = data.get("id")
now = datetime.utcnow().isoformat()
if scenario_id:
# Update existing
await execute(
"UPDATE scenarios SET name = ?, state_json = ?, location = ?, updated_at = ? WHERE id = ? AND user_id = ? AND deleted_at IS NULL",
(name, state_json, location, now, scenario_id, g.user["id"]),
)
else:
# Create new
scenario_id = await execute(
"INSERT INTO scenarios (user_id, name, state_json, location, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
(g.user["id"], name, state_json, location, now, now),
)
count = await count_scenarios(g.user["id"])
return jsonify({"ok": True, "id": scenario_id, "count": count})
@bp.route("/scenarios/<int:scenario_id>", methods=["GET"])
@login_required
async def get_scenario(scenario_id: int):
row = await fetch_one(
"SELECT * FROM scenarios WHERE id = ? AND user_id = ? AND deleted_at IS NULL",
(scenario_id, g.user["id"]),
)
if not row:
return jsonify({"error": "Not found"}), 404
return jsonify({"id": row["id"], "name": row["name"], "state_json": row["state_json"], "location": row["location"]})
@bp.route("/scenarios/<int:scenario_id>", methods=["DELETE"])
@login_required
@csrf_protect
async def delete_scenario(scenario_id: int):
now = datetime.utcnow().isoformat()
await execute(
"UPDATE scenarios SET deleted_at = ? WHERE id = ? AND user_id = ? AND deleted_at IS NULL",
(now, scenario_id, g.user["id"]),
)
scenarios = await get_scenarios(g.user["id"])
return await render_template("partials/scenario_list.html", scenarios=scenarios)
@bp.route("/scenarios/<int:scenario_id>/default", methods=["POST"])
@login_required
@csrf_protect
async def set_default(scenario_id: int):
# Clear existing default
await execute(
"UPDATE scenarios SET is_default = 0 WHERE user_id = ?",
(g.user["id"],),
)
# Set new default
await execute(
"UPDATE scenarios SET is_default = 1 WHERE id = ? AND user_id = ?",
(scenario_id, g.user["id"]),
)
return jsonify({"ok": True})

View File

@@ -0,0 +1,26 @@
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
<h3 style="margin:0;font-size:16px;color:var(--head)">My Scenarios</h3>
<button onclick="document.getElementById('scenario-drawer').classList.remove('open')"
style="background:none;border:none;color:var(--txt-3);cursor:pointer;font-size:18px">&times;</button>
</div>
{% if scenarios %}
{% for s in scenarios %}
<div class="scenario-item" data-id="{{ s.id }}">
<div style="display:flex;justify-content:space-between;align-items:center">
<div class="scenario-item__name">{{ s.name }}</div>
<div style="display:flex;gap:6px">
{% if s.is_default %}<span style="font-size:10px;color:var(--gn,#3dba78)">default</span>{% endif %}
<button onclick="loadScenario({{ s.id }})" style="background:none;border:none;color:var(--bl,#4a90d9);cursor:pointer;font-size:11px;padding:0">Load</button>
<button hx-delete="{{ url_for('planner.delete_scenario', scenario_id=s.id) }}"
hx-target="#scenario-drawer" hx-swap="innerHTML"
hx-confirm="Delete this scenario?"
style="background:none;border:none;color:var(--rd,#d94f4f);cursor:pointer;font-size:11px;padding:0">Del</button>
</div>
</div>
{% if s.location %}<div class="scenario-item__meta">{{ s.location }}</div>{% endif %}
<div class="scenario-item__meta">Updated {{ s.updated_at[:10] if s.updated_at else s.created_at[:10] }}</div>
</div>
{% endfor %}
{% else %}
<p style="color:var(--txt-3,#4d6278);font-size:13px">No saved scenarios yet. Use the Save button to store your current plan.</p>
{% endif %}

View File

@@ -0,0 +1,197 @@
{% extends "base.html" %}
{% block title %}Padel Court Financial Planner - {{ config.APP_NAME }}{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/planner.css') }}">
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
{% endblock %}
{% block content %}
<div class="planner-app">
<header class="planner-header">
<h1>Padel Court Financial Planner</h1>
<span class="brand-badge">v2.1</span>
<span id="headerTag" class="planner-summary"></span>
{% if user %}
<div class="scenario-controls">
<button id="scenarioListBtn"
hx-get="{{ url_for('planner.scenario_list') }}"
hx-target="#scenario-drawer"
hx-swap="innerHTML">
My Scenarios ({{ scenario_count }})
</button>
<button id="saveScenarioBtn">Save</button>
</div>
{% endif %}
</header>
<nav id="nav" class="tab-nav"></nav>
<main class="planner-main">
<!-- ASSUMPTIONS -->
<div class="tab" id="tab-assumptions">
<div class="grid-2">
<div>
<div class="mb-section">
<div class="section-header"><h3>Venue Type</h3></div>
<label class="slider-group__label">Environment</label>
<div class="toggle-group" id="tog-venue"></div>
<label class="slider-group__label">Ownership Model</label>
<div class="toggle-group" id="tog-own"></div>
</div>
<div class="mb-section">
<div class="section-header"><h3>Court Configuration</h3></div>
<div id="inp-courts"></div>
<div class="section-header" style="margin-top:1rem"><h3>Space Requirements</h3></div>
<div id="inp-space"></div>
<div class="court-summary" id="courtSummary"></div>
</div>
<div class="mb-section">
<div class="section-header"><h3>Pricing</h3><span class="hint">Per court per hour</span></div>
<div id="inp-pricing"></div>
</div>
<div class="mb-section">
<div class="section-header"><h3>Utilization &amp; Operations</h3></div>
<div id="inp-util"></div>
</div>
</div>
<div>
<div class="mb-section">
<div class="section-header"><h3>Construction &amp; CAPEX</h3><span class="hint">Adjust per scenario</span></div>
<div id="inp-capex"></div>
</div>
<div class="mb-section">
<div class="section-header"><h3>Monthly Operating Costs</h3></div>
<div id="inp-opex"></div>
</div>
<div class="mb-section">
<div class="section-header"><h3>Financing</h3></div>
<div id="inp-finance"></div>
</div>
<div class="mb-section">
<div class="section-header"><h3>Exit Assumptions</h3></div>
<div id="inp-exit"></div>
</div>
</div>
</div>
</div>
<!-- CAPEX -->
<div class="tab" id="tab-capex">
<div class="grid-3 mb-4" id="capexCards"></div>
<div id="capexTable"></div>
<div class="chart-container mt-4">
<div class="chart-container__label">CAPEX Breakdown</div>
<div class="chart-h-56 chart-container__canvas"><canvas id="chartCapex"></canvas></div>
</div>
<div class="lead-cta mt-4" id="capexCta">
<span class="lead-cta__text">These are estimates. Get actual quotes from verified court suppliers.</span>
<a href="{{ url_for('leads.suppliers') }}" class="lead-cta__btn">Get Quotes</a>
</div>
</div>
<!-- OPERATING -->
<div class="tab" id="tab-operating">
<div class="grid-4 mb-4" id="opCards"></div>
<div class="grid-2 mb-4">
<div class="chart-container">
<div class="chart-container__label">Monthly Revenue Build-Up (Ramp Period)</div>
<div class="chart-h-48 chart-container__canvas"><canvas id="chartRevRamp"></canvas></div>
</div>
<div class="chart-container">
<div class="chart-container__label">Stabilized Monthly P&amp;L</div>
<div class="chart-h-48 chart-container__canvas"><canvas id="chartPL"></canvas></div>
</div>
</div>
<div class="mb-section">
<div class="section-header"><h3>Revenue Streams (Stabilized Month)</h3></div>
<div id="revenueTable"></div>
</div>
<div class="mb-section">
<div class="section-header"><h3>Monthly OpEx Breakdown</h3></div>
<div id="opexDetailTable"></div>
</div>
<div class="mb-section season-section" id="seasonSection">
<div class="section-header"><h3>Outdoor Seasonality</h3></div>
<div class="chart-container"><div class="chart-h-40 chart-container__canvas"><canvas id="chartSeason"></canvas></div></div>
</div>
</div>
<!-- CASH FLOW -->
<div class="tab" id="tab-cashflow">
<div class="grid-4 mb-4" id="cfCards"></div>
<div class="chart-container mb-4">
<div class="chart-container__label">Monthly Net Cash Flow (60 Months)</div>
<div class="chart-h-56 chart-container__canvas"><canvas id="chartCF"></canvas></div>
</div>
<div class="chart-container mb-4">
<div class="chart-container__label">Cumulative Cash Flow</div>
<div class="chart-h-48 chart-container__canvas"><canvas id="chartCum"></canvas></div>
</div>
<div class="mb-section">
<div class="section-header"><h3>Annual Summary</h3></div>
<div id="annualTable"></div>
</div>
</div>
<!-- RETURNS -->
<div class="tab" id="tab-returns">
<div class="grid-4 mb-4" id="retCards"></div>
<div class="grid-2 mb-4">
<div class="chart-container">
<div class="chart-container__label" style="font-size:10px">Exit Valuation Waterfall</div>
<div id="exitWaterfall" style="margin-top:10px"></div>
</div>
<div class="chart-container">
<div class="chart-container__label">DSCR by Year</div>
<div class="chart-h-44 chart-container__canvas"><canvas id="chartDSCR"></canvas></div>
</div>
</div>
<div class="mb-section">
<div class="section-header"><h3>Utilization Sensitivity</h3></div>
<div id="sensTable"></div>
</div>
<div class="mb-section">
<div class="section-header"><h3>Pricing Sensitivity (at target utilization)</h3></div>
<div id="priceSensTable"></div>
</div>
<div class="lead-cta mt-4" id="returnsCta">
<span class="lead-cta__text">Your project looks profitable. Ready to take the next step?</span>
<a href="{{ url_for('leads.suppliers') }}" class="lead-cta__btn">Get Started</a>
</div>
</div>
<!-- METRICS -->
<div class="tab" id="tab-metrics">
<div class="mb-section"><div class="section-header"><h3>Return Metrics</h3></div><div class="grid-4" id="mReturn"></div></div>
<div class="mb-section"><div class="section-header"><h3>Revenue Efficiency</h3></div><div class="grid-4" id="mRevenue"></div></div>
<div class="mb-section"><div class="section-header"><h3>Cost &amp; Margin</h3></div><div class="grid-4" id="mCost"></div></div>
<div class="mb-section"><div class="section-header"><h3>Debt &amp; Coverage</h3></div><div class="grid-4" id="mDebt"></div></div>
<div class="mb-section"><div class="section-header"><h3>Investment Efficiency</h3></div><div class="grid-4" id="mInvest"></div></div>
<div class="mb-section"><div class="section-header"><h3>Operational</h3></div><div class="grid-4" id="mOps"></div></div>
</div>
</main>
<footer class="lead-cta-bar">
<span>Ready to move forward?</span>
<a href="{{ url_for('leads.suppliers') }}">Get Supplier Quotes</a>
<a href="{{ url_for('leads.financing') }}">Find Financing</a>
</footer>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div id="scenario-drawer"></div>
<div id="save-feedback"></div>
</div>
{% endblock %}
{% block scripts %}
<script>
{% if initial_state %}
window.__PADELNOMICS_INITIAL_STATE__ = {{ initial_state | safe }};
{% endif %}
window.__PADELNOMICS_SAVE_URL__ = "{{ url_for('planner.save_scenario') }}";
window.__PADELNOMICS_SCENARIO_URL__ = "{{ url_for('planner.index') }}scenarios/";
</script>
<script src="{{ url_for('static', filename='js/planner.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,58 @@
"""
Public domain: landing page, marketing pages, legal pages.
"""
from pathlib import Path
from quart import Blueprint, render_template, Response
from ..core import config
bp = Blueprint(
"public",
__name__,
template_folder=str(Path(__file__).parent / "templates"),
)
@bp.route("/")
async def landing():
return await render_template("landing.html")
@bp.route("/features")
async def features():
return await render_template("features.html")
@bp.route("/terms")
async def terms():
return await render_template("terms.html")
@bp.route("/privacy")
async def privacy():
return await render_template("privacy.html")
@bp.route("/about")
async def about():
return await render_template("about.html")
@bp.route("/sitemap.xml")
async def sitemap():
base = config.BASE_URL.rstrip("/")
urls = [
f"{base}/",
f"{base}/features",
f"{base}/about",
f"{base}/billing/pricing",
f"{base}/terms",
f"{base}/privacy",
]
xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
for url in urls:
xml += f" <url><loc>{url}</loc></url>\n"
xml += "</urlset>"
return Response(xml, content_type="application/xml")

View File

@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}About - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<article style="max-width: 800px; margin: 0 auto;">
<header style="text-align: center;">
<h1>About {{ config.APP_NAME }}</h1>
</header>
<section>
<p>Padel is the fastest-growing sport in Europe, but opening a padel hall is still a leap of faith for most entrepreneurs. The financials are complex: CAPEX varies wildly depending on venue type, location drives utilization, and the difference between a 60% and 75% occupancy rate can mean the difference between a great investment and a money pit.</p>
<p>We built Padelnomics because we couldn't find a financial planning tool that was good enough. Existing calculators are either too simplistic (5 inputs, one output) or locked behind expensive consulting engagements. We wanted something with the depth of a professional financial model but the accessibility of a web app.</p>
<p>The result is a free financial planner with 60+ adjustable variables, 6 analysis tabs, sensitivity analysis, and the professional metrics that banks and investors need to see. Every assumption is transparent and adjustable. No black boxes.</p>
<h3>Why free?</h3>
<p>The planner is free because we believe better planning leads to better padel venues, and that's good for the entire industry. We make money by connecting entrepreneurs with court suppliers and financing partners when they're ready to move from planning to building.</p>
<h3>What's next</h3>
<p>Padelnomics is building the infrastructure for padel entrepreneurship. After planning comes financing, building, and operating. We're working on market intelligence powered by real booking data, a supplier marketplace for court equipment, and analytics tools for venue operators.</p>
</section>
<section style="text-align: center; margin-top: 3rem;">
{% if user %}
<a href="{{ url_for('planner.index') }}" role="button">Open Planner</a>
{% else %}
<a href="{{ url_for('auth.signup') }}" role="button">Create Free Account</a>
{% endif %}
</section>
</article>
</main>
{% endblock %}

View File

@@ -0,0 +1,82 @@
{% extends "base.html" %}
{% block title %}Features - Padel Court Financial Planner | {{ config.APP_NAME }}{% endblock %}
{% block head %}
<meta name="description" content="60+ adjustable variables, 6 analysis tabs, sensitivity analysis, and professional-grade financial projections for your padel court investment.">
{% endblock %}
{% block content %}
<main class="container">
<header style="text-align: center; margin-bottom: 3rem;">
<h1>Everything You Need to Plan Your Padel Business</h1>
<p>Professional-grade financial modeling, completely free.</p>
</header>
<div class="grid">
<article>
<h2>60+ Variables</h2>
<p>Every assumption is adjustable. Court costs, rent, hourly pricing, utilization curves, financing terms, exit multiples. Nothing is hard-coded &mdash; your model reflects your reality.</p>
</article>
<article>
<h2>6 Analysis Tabs</h2>
<p>Assumptions, Investment (CAPEX), Operating Model, Cash Flow, Returns &amp; Exit, and Key Metrics. Each tab with interactive charts that update in real time as you adjust inputs.</p>
</article>
</div>
<div class="grid">
<article>
<h2>Indoor &amp; Outdoor</h2>
<p>Model indoor halls (rent an existing building or build new) and outdoor courts with seasonality adjustments. Compare scenarios side by side to find the best approach for your market.</p>
</article>
<article>
<h2>Sensitivity Analysis</h2>
<p>See how your IRR and cash yield change across different utilization rates and pricing levels. Find your break-even point instantly with the built-in sensitivity matrix.</p>
</article>
</div>
<div class="grid">
<article>
<h2>Professional Metrics</h2>
<p>IRR, MOIC, DSCR, cash-on-cash yield, break-even utilization, RevPAH, debt yield. The metrics banks and investors expect to see in a padel court business plan.</p>
</article>
<article>
<h2>Save &amp; Compare</h2>
<p>Save unlimited scenarios. Test different locations, court counts, financing structures, and pricing strategies. Load and compare to find the optimal plan for your investment.</p>
</article>
</div>
<section>
<article>
<h2>Detailed CAPEX Breakdown</h2>
<p>Model every cost line individually: court installation, flooring, lighting, climate control, changing rooms, reception, parking, landscaping. Toggle between renting a building and constructing new. Adjust land costs, construction costs per sqm, and fit-out budgets independently.</p>
</article>
<article>
<h2>Operating Model</h2>
<p>Peak and off-peak pricing with configurable hour splits. Monthly utilization ramp-up curves. Staff costs, maintenance, insurance, marketing, and utilities &mdash; all adjustable with sliders. Revenue from court rentals, coaching, equipment, and F&amp;B.</p>
</article>
<article>
<h2>Cash Flow &amp; Financing</h2>
<p>10-year monthly cash flow projections. Model your equity/debt split, interest rates, and loan terms. See debt service coverage ratios and free cash flow month by month. Waterfall charts show exactly where your money goes.</p>
</article>
<article>
<h2>Returns &amp; Exit</h2>
<p>Calculate your equity IRR and MOIC under different exit scenarios. Model cap rate exits with configurable holding periods. See your equity waterfall from initial investment through to exit proceeds.</p>
</article>
</section>
<section style="text-align: center; margin-top: 3rem;">
{% if user %}
<a href="{{ url_for('planner.index') }}" role="button">Open Planner</a>
{% else %}
<a href="{{ url_for('auth.signup') }}" role="button">Create Free Account</a>
{% endif %}
</section>
</main>
{% endblock %}

View File

@@ -0,0 +1,110 @@
{% extends "base.html" %}
{% block title %}Padelnomics - Padel Court Business Plan & ROI Calculator{% endblock %}
{% block head %}
<meta name="description" content="Plan your padel court investment in minutes. Free financial planner with 60+ variables, sensitivity analysis, and professional-grade projections. Indoor/outdoor, rent/buy models.">
<meta property="og:title" content="Padelnomics - Padel Court Financial Planner">
<meta property="og:description" content="The most sophisticated padel court business plan calculator. Free forever. 60+ variables, 6 analysis tabs, charts, sensitivity analysis.">
<meta property="og:type" content="website">
<meta property="og:url" content="{{ config.BASE_URL }}">
<link rel="canonical" href="{{ config.BASE_URL }}">
{% endblock %}
{% block content %}
<main class="container">
<!-- Hero -->
<header style="text-align: center; padding: 4rem 0 3rem;">
<h1 style="font-size: 2.5rem; line-height: 1.15;">Plan Your Padel Business<br>in Minutes, Not Months</h1>
<p style="font-size: 1.2rem; max-width: 640px; margin: 1rem auto 0;">
The most sophisticated padel court financial planner available. Model your investment with 60+ variables, sensitivity analysis, and professional-grade projections. 100% free.
</p>
<div style="margin-top: 2rem;">
{% if user %}
<a href="{{ url_for('planner.index') }}" role="button" style="margin-right: 1rem;">Open Planner</a>
{% else %}
<a href="{{ url_for('auth.signup') }}" role="button" style="margin-right: 1rem;">Create Free Account</a>
{% endif %}
<a href="{{ url_for('billing.pricing') }}" role="button" class="secondary outline">Learn More</a>
</div>
</header>
<!-- The Journey -->
<section style="padding: 3rem 0;">
<h2 style="text-align: center;">From Idea to Operating Hall</h2>
<div class="grid">
<article>
<header><strong>Plan</strong></header>
<p>Model your padel hall investment with our free financial planner. CAPEX, operating costs, cash flow, returns, sensitivity analysis.</p>
</article>
<article>
<header><strong>Finance</strong></header>
<p>Connect with banks and investors experienced in sports facility loans. Your planner data becomes your business case.</p>
</article>
<article>
<header><strong>Build</strong></header>
<p>Get quotes from verified court suppliers. Compare pricing, quality, and delivery timelines for your specific project.</p>
</article>
<article>
<header><strong>Operate</strong></header>
<p>Coming soon: analytics powered by real booking data, benchmarking against similar venues, optimization recommendations.</p>
</article>
</div>
</section>
<!-- Feature Highlights -->
<section style="padding: 3rem 0;">
<h2 style="text-align: center;">Built for Serious Padel Entrepreneurs</h2>
<div class="grid">
<article>
<h3>60+ Variables</h3>
<p>Every assumption is adjustable. Court costs, rent, pricing, utilization, financing terms, exit scenarios. Nothing is hard-coded.</p>
</article>
<article>
<h3>6 Analysis Tabs</h3>
<p>Assumptions, Investment (CAPEX), Operating Model, Cash Flow, Returns &amp; Exit, and Key Metrics. Each with interactive charts.</p>
</article>
<article>
<h3>Indoor &amp; Outdoor</h3>
<p>Model indoor halls (rent or build) and outdoor courts with seasonality. Compare scenarios side by side.</p>
</article>
</div>
<div class="grid">
<article>
<h3>Sensitivity Analysis</h3>
<p>See how your returns change with different utilization rates and pricing. Find your break-even point instantly.</p>
</article>
<article>
<h3>Professional Metrics</h3>
<p>IRR, MOIC, DSCR, cash-on-cash yield, break-even utilization, RevPAH, debt yield. The metrics banks and investors want to see.</p>
</article>
<article>
<h3>Save &amp; Compare</h3>
<p>Save unlimited scenarios. Test different locations, court counts, financing structures. Find the optimal plan.</p>
</article>
</div>
</section>
<!-- SEO Content -->
<section style="padding: 3rem 0; max-width: 720px; margin: 0 auto;">
<h2>Padel Court Investment Planning</h2>
<p>
Padel is the fastest-growing sport in Europe, with demand for courts far outstripping supply in Germany, the UK, Scandinavia, and beyond. Opening a padel hall can be a lucrative investment, but the numbers need to work. A typical indoor padel venue with 6-8 courts requires between &euro;300K (renting an existing building) and &euro;2-3M (building new), with payback periods of 3-5 years for well-located venues.
</p>
<p>
The key variables that determine success are location (driving utilization), construction costs (CAPEX), rent or land costs, and pricing strategy. Our financial planner lets you model all of these variables interactively, seeing the impact on your IRR, MOIC, cash flow, and debt service coverage ratio in real time. Whether you're an entrepreneur exploring your first venue, a real estate developer adding padel to a mixed-use project, or an investor evaluating a padel hall acquisition, Padelnomics gives you the financial clarity to make informed decisions.
</p>
</section>
<!-- Final CTA -->
<section style="text-align: center; padding: 3rem 0;">
<h2>Start Planning Today</h2>
<p>No credit card. No paywall. Full access to every feature.</p>
{% if user %}
<a href="{{ url_for('planner.index') }}" role="button">Open Planner</a>
{% else %}
<a href="{{ url_for('auth.signup') }}" role="button">Create Free Account</a>
{% endif %}
</section>
</main>
{% endblock %}

View File

@@ -0,0 +1,92 @@
{% extends "base.html" %}
{% block title %}Privacy Policy - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<article style="max-width: 800px; margin: 0 auto;">
<header>
<h1>Privacy Policy</h1>
<p><small>Last updated: February 2026</small></p>
</header>
<section>
<h2>1. Information We Collect</h2>
<p>We collect information you provide directly:</p>
<ul>
<li>Email address (required for account creation)</li>
<li>Name (optional)</li>
<li>Financial planning data (scenario inputs and projections)</li>
</ul>
<p>We automatically collect:</p>
<ul>
<li>IP address</li>
<li>Browser type</li>
<li>Usage data</li>
</ul>
</section>
<section>
<h2>2. How We Use Information</h2>
<p>We use your information to:</p>
<ul>
<li>Provide and maintain the service</li>
<li>Process payments</li>
<li>Send transactional emails</li>
<li>Improve the service</li>
<li>Respond to support requests</li>
</ul>
</section>
<section>
<h2>3. Information Sharing</h2>
<p>We do not sell your personal information. We may share information with:</p>
<ul>
<li>Service providers (Resend for email, Plausible for privacy-friendly analytics)</li>
<li>Law enforcement when required by law</li>
</ul>
</section>
<section>
<h2>4. Data Retention</h2>
<p>We retain your data as long as your account is active. Upon deletion, we remove your data within 30 days.</p>
</section>
<section>
<h2>5. Security</h2>
<p>We implement industry-standard security measures including encryption, secure sessions, and regular backups.</p>
</section>
<section>
<h2>6. Cookies</h2>
<p>We use essential cookies for session management. We do not use tracking or advertising cookies.</p>
</section>
<section>
<h2>7. Your Rights</h2>
<p>You have the right to:</p>
<ul>
<li>Access your data</li>
<li>Correct inaccurate data</li>
<li>Delete your account and data</li>
<li>Export your data</li>
</ul>
</section>
<section>
<h2>8. GDPR Compliance</h2>
<p>For EU users: We process data based on consent and legitimate interest. You may contact us to exercise your GDPR rights.</p>
</section>
<section>
<h2>9. Changes</h2>
<p>We may update this policy. We will notify you of significant changes via email.</p>
</section>
<section>
<h2>10. Contact</h2>
<p>For privacy inquiries: {{ config.EMAIL_FROM }}</p>
</section>
</article>
</main>
{% endblock %}

View File

@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block title %}Terms of Service - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<article style="max-width: 800px; margin: 0 auto;">
<header>
<h1>Terms of Service</h1>
<p><small>Last updated: February 2026</small></p>
</header>
<section>
<h2>1. Acceptance of Terms</h2>
<p>By accessing or using {{ config.APP_NAME }}, you agree to be bound by these Terms of Service. If you do not agree, do not use the service.</p>
</section>
<section>
<h2>2. Description of Service</h2>
<p>{{ config.APP_NAME }} provides a software-as-a-service platform. Features and functionality may change over time.</p>
</section>
<section>
<h2>3. User Accounts</h2>
<p>You are responsible for maintaining the security of your account. You must provide accurate information and keep it updated.</p>
</section>
<section>
<h2>4. Acceptable Use</h2>
<p>You agree not to:</p>
<ul>
<li>Violate any laws or regulations</li>
<li>Infringe on intellectual property rights</li>
<li>Transmit harmful code or malware</li>
<li>Attempt to gain unauthorized access</li>
<li>Interfere with service operation</li>
</ul>
</section>
<section>
<h2>5. Financial Projections Disclaimer</h2>
<p>The financial planner provides estimates based on your inputs. Projections are not guarantees of future performance. Always consult qualified financial and legal advisors before making investment decisions.</p>
</section>
<section>
<h2>6. Termination</h2>
<p>We may terminate or suspend your account for violations of these terms. You may cancel your account at any time.</p>
</section>
<section>
<h2>7. Disclaimer of Warranties</h2>
<p>The service is provided "as is" without warranties of any kind. We do not guarantee uninterrupted or error-free operation.</p>
</section>
<section>
<h2>8. Limitation of Liability</h2>
<p>We shall not be liable for any indirect, incidental, special, or consequential damages arising from use of the service.</p>
</section>
<section>
<h2>9. Changes to Terms</h2>
<p>We may modify these terms at any time. Continued use after changes constitutes acceptance of the new terms.</p>
</section>
<section>
<h2>10. Contact</h2>
<p>For questions about these terms, please contact us at {{ config.EMAIL_FROM }}.</p>
</section>
</article>
</main>
{% endblock %}

View File

@@ -0,0 +1,54 @@
/* Padelnomics — Pico CSS brand overrides */
:root[data-theme="dark"] {
/* Background layers */
--pico-background-color: #0b0f14;
--pico-card-background-color: #111820;
--pico-card-sectioning-background-color: #171f2a;
/* Text */
--pico-color: #b8c9da;
--pico-muted-color: #7a8fa3;
--pico-muted-border-color: rgba(255,255,255,0.06);
/* Primary accent (red) */
--pico-primary: #d94f4f;
--pico-primary-hover: #c94545;
--pico-primary-focus: rgba(217,79,79,0.25);
--pico-primary-inverse: #fff;
/* Typography */
--pico-font-family: 'Outfit', system-ui, sans-serif;
--pico-font-family-monospace: 'JetBrains Mono', monospace;
/* Borders */
--pico-border-color: rgba(255,255,255,0.06);
/* Form styling */
--pico-form-element-background-color: #171f2a;
--pico-form-element-border-color: rgba(255,255,255,0.1);
--pico-form-element-focus-color: #d94f4f;
}
/* Headings use warm cream */
h1, h2, h3, h4, h5, h6 {
color: #e8dcc8;
font-family: 'Outfit', system-ui, sans-serif;
}
article {
margin-bottom: 1.5rem;
}
table {
width: 100%;
}
/* HTMX loading indicators */
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
display: inline;
}

View File

@@ -0,0 +1,599 @@
/* Padelnomics Planner — scoped under .planner-app */
/* Isolates planner from Pico CSS resets */
.planner-app {
--bg: #0b0f14;
--bg-2: #111820;
--bg-3: #171f2a;
--bg-4: #1d2733;
--border: rgba(255,255,255,0.06);
--border-2: rgba(255,255,255,0.1);
--txt: #b8c9da;
--txt-2: #7a8fa3;
--txt-3: #4d6278;
--head: #e8dcc8;
--wht: #f5f0e8;
--rd: #d94f4f;
--rd-bg: rgba(217,79,79,0.08);
--gn: #3dba78;
--gn-bg: rgba(61,186,120,0.08);
--bl: #4a90d9;
--bl-bg: rgba(74,144,217,0.08);
--am: #d4a03c;
--am-bg: rgba(212,160,60,0.08);
font-family: 'Outfit', sans-serif;
font-size: 14px;
color: var(--txt);
background: var(--bg);
min-height: 100vh;
}
/* Scrollbar */
.planner-app ::-webkit-scrollbar { width: 5px; }
.planner-app ::-webkit-scrollbar-track { background: transparent; }
.planner-app ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 3px; }
/* ── Header ── */
.planner-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border);
background: var(--bg-2);
display: flex;
align-items: center;
gap: 1rem;
}
.planner-header h1 {
font-size: 1.125rem;
font-weight: 800;
color: var(--head);
letter-spacing: -0.01em;
margin: 0;
}
.brand-badge {
font-size: 10px;
padding: 2px 8px;
border-radius: 12px;
background: var(--rd-bg);
color: var(--rd);
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.planner-summary {
font-size: 11px;
color: var(--txt-2);
margin-left: auto;
font-family: 'JetBrains Mono', monospace;
}
/* ── Scenario controls ── */
.scenario-controls {
display: flex;
gap: 0.5rem;
margin-left: 1rem;
}
.scenario-controls button {
font-size: 11px;
padding: 4px 12px;
border-radius: 6px;
border: 1px solid var(--border-2);
background: transparent;
color: var(--txt-2);
cursor: pointer;
font-family: 'Outfit', sans-serif;
font-weight: 500;
transition: all 0.15s;
}
.scenario-controls button:hover {
background: var(--bg-3);
color: var(--txt);
}
/* ── Tab Navigation ── */
.tab-nav {
display: flex;
border-bottom: 1px solid var(--border);
background: var(--bg);
overflow-x: auto;
position: sticky;
top: 0;
z-index: 50;
}
.tab-btn {
padding: 10px 16px;
font-size: 12px;
font-weight: 600;
border: none;
background: transparent;
color: var(--txt-3);
cursor: pointer;
white-space: nowrap;
font-family: 'Outfit', sans-serif;
border-bottom: 2px solid transparent;
transition: all 0.15s;
}
.tab-btn:hover {
color: var(--txt-2);
background: rgba(255,255,255,0.02);
}
.tab-btn--active {
color: var(--rd) !important;
border-bottom-color: var(--rd) !important;
background: var(--rd-bg) !important;
}
/* ── Main content ── */
.planner-main {
flex: 1;
padding: 1.5rem;
max-width: 1100px;
margin: 0 auto;
width: 100%;
}
/* ── Tab visibility ── */
.tab { display: none; }
.tab.active { display: block; }
/* ── Grid layouts ── */
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; }
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.75rem; }
@media (max-width: 768px) {
.grid-2 { grid-template-columns: 1fr; }
.grid-3 { grid-template-columns: repeat(2, 1fr); }
.grid-4 { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 480px) {
.grid-3 { grid-template-columns: 1fr; }
}
/* ── Metric Cards ── */
.metric-card {
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
}
.metric-card__label {
font-size: 10px;
color: var(--txt-3);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 2px;
}
.metric-card__value {
font-size: 22px;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
line-height: 1.2;
}
.metric-card__sub {
font-size: 10px;
color: var(--txt-3);
margin-top: 2px;
}
.metric-card-sm .metric-card__value {
font-size: 17px;
}
/* Color classes */
.c-head { color: var(--head); }
.c-red { color: var(--rd); }
.c-green { color: var(--gn); }
.c-blue { color: var(--bl); }
.c-amber { color: var(--am); }
/* ── Section headers ── */
.section-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.section-header h3 {
font-size: 14px;
font-weight: 700;
color: var(--head);
margin: 0;
}
.section-header .hint {
font-size: 11px;
color: var(--txt-3);
margin-left: auto;
font-style: italic;
}
/* ── Slider group ── */
.slider-group {
margin-bottom: 14px;
}
.slider-group label {
display: flex;
align-items: center;
margin-bottom: 4px;
}
.slider-group__label {
font-size: 12px;
color: var(--txt-2);
letter-spacing: 0.01em;
}
.slider-combo {
display: flex;
align-items: center;
gap: 12px;
}
.slider-combo input[type=range] {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 5px;
border-radius: 3px;
outline: none;
cursor: pointer;
background: rgba(255,255,255,0.08);
}
.slider-combo input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--rd);
cursor: pointer;
border: 2px solid var(--bg);
}
.slider-combo input[type=range]::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--rd);
cursor: pointer;
border: 2px solid var(--bg);
}
.slider-combo input[type=number] {
width: 80px;
background: var(--bg-3);
border: 1px solid var(--border-2);
border-radius: 4px;
padding: 4px 8px;
text-align: right;
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
color: var(--head);
outline: none;
}
.slider-combo input[type=number]:focus {
border-color: rgba(217,79,79,0.5);
}
/* Hide number spinners */
.slider-combo input[type=number]::-webkit-outer-spin-button,
.slider-combo input[type=number]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.slider-combo input[type=number] {
-moz-appearance: textfield;
}
/* ── Toggle buttons ── */
.toggle-group {
display: flex;
gap: 4px;
margin-bottom: 14px;
}
.toggle-btn {
flex: 1;
padding: 8px 10px;
font-size: 12px;
font-weight: 600;
border: 1px solid var(--border);
background: transparent;
color: var(--txt-3);
border-radius: 6px;
cursor: pointer;
font-family: 'Outfit', sans-serif;
transition: all 0.15s;
}
.toggle-btn--active {
background: var(--rd) !important;
border-color: var(--rd) !important;
color: #fff !important;
}
/* ── Data Tables ── */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.data-table th {
padding: 8px;
text-align: left;
color: var(--txt-3);
font-weight: 600;
font-size: 11px;
border-bottom: 2px solid var(--border-2);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.data-table th.right {
text-align: right;
}
.data-table td {
padding: 6px 8px;
font-family: 'Outfit', sans-serif;
color: var(--txt);
}
.data-table td.mono {
font-family: 'JetBrains Mono', monospace;
text-align: right;
}
.data-table tr:hover {
background: rgba(255,255,255,0.015);
}
.data-table .total-row {
border-top: 2px solid var(--rd);
}
.data-table .total-row td {
font-weight: 700;
color: var(--rd);
padding-top: 10px;
}
/* ── Chart container ── */
.chart-container {
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
}
.chart-container__label {
font-size: 11px;
color: var(--txt-3);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 10px;
}
.chart-container__canvas {
position: relative;
}
/* Fixed heights for chart containers to prevent resize loops */
.chart-h-56 { height: 224px; }
.chart-h-48 { height: 192px; }
.chart-h-44 { height: 176px; }
.chart-h-40 { height: 160px; }
/* ── Tooltip ── */
.ti {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border-radius: 50%;
background: rgba(255,255,255,0.06);
color: var(--txt-3);
font-size: 9px;
cursor: help;
margin-left: 4px;
flex-shrink: 0;
font-style: italic;
font-family: 'Outfit', sans-serif;
vertical-align: middle;
}
.ti .tp {
display: none;
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
width: 240px;
padding: 8px 10px;
border-radius: 6px;
background: var(--bg-4);
border: 1px solid var(--border-2);
color: var(--txt);
font-size: 10px;
line-height: 1.5;
font-style: normal;
font-weight: 400;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
z-index: 200;
pointer-events: none;
}
.ti .tp::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: var(--bg-4);
}
.ti:hover .tp { display: block; }
.ti .tp.tp-left {
left: auto;
right: -8px;
transform: none;
}
.ti .tp.tp-left::after {
left: auto;
right: 12px;
transform: none;
}
/* ── Spacing helpers ── */
.mb-section { margin-bottom: 28px; }
.mb-4 { margin-bottom: 1rem; }
.mt-4 { margin-top: 1rem; }
/* ── Season section (outdoor only) ── */
.season-section { display: none; }
.season-section.visible { display: block; }
/* ── Lead CTA bar ── */
.lead-cta-bar {
position: sticky;
bottom: 0;
display: flex;
align-items: center;
gap: 1rem;
padding: 12px 1.5rem;
background: var(--bg-2);
border-top: 1px solid var(--border);
font-size: 13px;
z-index: 40;
}
.lead-cta-bar span {
color: var(--txt-2);
}
.lead-cta-bar a {
font-size: 12px;
font-weight: 600;
padding: 6px 16px;
border-radius: 6px;
text-decoration: none;
transition: all 0.15s;
}
.lead-cta-bar a:first-of-type {
background: var(--rd);
color: #fff;
}
.lead-cta-bar a:first-of-type:hover {
background: #c94545;
}
.lead-cta-bar a:last-of-type {
background: transparent;
border: 1px solid var(--border-2);
color: var(--txt-2);
}
.lead-cta-bar a:last-of-type:hover {
background: var(--bg-3);
color: var(--txt);
}
/* ── Inline lead CTA ── */
.lead-cta {
background: var(--bg-3);
border: 1px solid var(--border-2);
border-radius: 8px;
padding: 16px 20px;
margin-top: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.lead-cta__text {
font-size: 13px;
color: var(--txt-2);
}
.lead-cta__btn {
font-size: 12px;
font-weight: 600;
padding: 8px 20px;
border-radius: 6px;
background: var(--rd);
color: #fff;
text-decoration: none;
white-space: nowrap;
transition: background 0.15s;
}
.lead-cta__btn:hover {
background: #c94545;
}
/* ── Exit waterfall ── */
.waterfall-row {
display: flex;
justify-content: space-between;
padding: 6px 0;
border-bottom: 1px solid var(--border);
font-size: 12px;
}
.waterfall-row__label { color: var(--txt-2); }
.waterfall-row__value { font-family: 'JetBrains Mono', monospace; font-weight: 600; }
/* ── Court summary cards ── */
.court-summary {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
margin-top: 8px;
}
/* ── Planner footer ── */
.planner-footer {
padding: 12px 1.5rem;
border-top: 1px solid var(--border);
text-align: center;
font-size: 10px;
color: var(--txt-3);
}
/* ── Scenario drawer ── */
#scenario-drawer {
position: fixed;
top: 0;
right: -360px;
width: 360px;
height: 100vh;
background: var(--bg-2);
border-left: 1px solid var(--border);
z-index: 100;
transition: right 0.25s ease;
overflow-y: auto;
padding: 1.5rem;
}
#scenario-drawer.open {
right: 0;
}
.scenario-item {
padding: 12px;
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 8px;
cursor: pointer;
transition: background 0.15s;
}
.scenario-item:hover {
background: var(--bg-3);
}
.scenario-item__name {
font-weight: 600;
color: var(--head);
font-size: 13px;
}
.scenario-item__meta {
font-size: 11px;
color: var(--txt-3);
margin-top: 2px;
}
/* ── Save feedback ── */
#save-feedback {
position: fixed;
bottom: 60px;
right: 1.5rem;
z-index: 90;
}
.save-toast {
background: var(--gn);
color: #fff;
padding: 8px 16px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
animation: fadeInOut 2s ease forwards;
}
@keyframes fadeInOut {
0% { opacity: 0; transform: translateY(10px); }
15% { opacity: 1; transform: translateY(0); }
85% { opacity: 1; }
100% { opacity: 0; }
}

View File

@@ -0,0 +1,746 @@
// ── State ──────────────────────────────────────────────────
const S = {
venue:'indoor', own:'rent',
dblCourts:4, sglCourts:2,
sqmPerDblHall:330, sqmPerSglHall:220, sqmPerDblOutdoor:300, sqmPerSglOutdoor:200,
ratePeak:50, rateOffPeak:35, rateSingle:30,
peakPct:40, hoursPerDay:16, daysPerMonthIndoor:29, daysPerMonthOutdoor:25,
bookingFee:10, utilTarget:40,
membershipRevPerCourt:500, fbRevPerCourt:300, coachingRevPerCourt:200, retailRevPerCourt:80,
racketRentalRate:15, racketPrice:5, racketQty:2, ballRate:10, ballPrice:3, ballCost:1.5,
courtCostDbl:25000, courtCostSgl:15000, shipping:3000,
hallCostSqm:500, foundationSqm:150, landPriceSqm:60,
hvac:100000, electrical:60000, sanitary:80000, parking:50000,
fitout:40000, planning:100000, fireProtection:80000,
floorPrep:12000, hvacUpgrade:20000, lightingUpgrade:10000,
outdoorFoundation:35, outdoorSiteWork:8000, outdoorLighting:4000, outdoorFencing:6000,
equipment:2000, workingCapital:15000, contingencyPct:10,
rentSqm:4, outdoorRent:400, insurance:300, electricity:600, heating:400,
maintenance:300, cleaning:300, marketing:350, staff:0, propertyTax:250, water:125,
loanPct:85, interestRate:5, loanTerm:10, constructionMonths:0,
holdYears:5, exitMultiple:6, annualRevGrowth:2,
ramp:[.25,.35,.45,.55,.65,.75,.82,.88,.93,.96,.98,1],
season:[0,0,0,.7,.9,1,1,1,.8,0,0,0],
};
// Restore saved scenario if available
if (window.__PADELNOMICS_INITIAL_STATE__) {
Object.assign(S, window.__PADELNOMICS_INITIAL_STATE__);
}
const TABS = [
{id:'assumptions',label:'Assumptions'},
{id:'capex',label:'Investment'},
{id:'operating',label:'Operating Model'},
{id:'cashflow',label:'Cash Flow'},
{id:'returns',label:'Returns & Exit'},
{id:'metrics',label:'Key Metrics'},
];
let activeTab = 'assumptions';
const charts = {};
// ── Helpers ────────────────────────────────────────────────
const $=s=>document.querySelector(s);
const $$=s=>document.querySelectorAll(s);
const fmt=n=>new Intl.NumberFormat('de-DE',{style:'currency',currency:'EUR',maximumFractionDigits:0}).format(n);
const fmtK=n=>Math.abs(n)>=1e6?`\u20AC${(n/1e6).toFixed(1)}M`:Math.abs(n)>=1e3?`\u20AC${(n/1e3).toFixed(0)}K`:fmt(n);
const fmtP=n=>`${(n*100).toFixed(1)}%`;
const fmtX=n=>`${n.toFixed(2)}x`;
const fmtN=n=>new Intl.NumberFormat('de-DE').format(Math.round(n));
const fE=v=>fmt(v), fP=v=>v+'%', fN=v=>v, fR=v=>v+'x', fY=v=>v+' yr', fH=v=>v+'h', fD=v=>'\u20AC'+v, fM=v=>v+' mo';
function pmt(rate,nper,pv){if(rate===0)return pv/nper;return pv*rate*Math.pow(1+rate,nper)/(Math.pow(1+rate,nper)-1)}
function calcIRR(cfs,guess=.1){
let r=guess;
for(let i=0;i<300;i++){
let npv=0,d=0;
for(let t=0;t<cfs.length;t++){npv+=cfs[t]/Math.pow(1+r,t);d-=t*cfs[t]/Math.pow(1+r,t+1)}
if(Math.abs(d)<1e-12)break;
const nr=r-npv/d;
if(Math.abs(nr-r)<1e-9)return nr;
r=nr;
if(r<-0.99)r=-0.99;if(r>10)r=10;
}
return r;
}
function ti(text){
if(!text) return '';
return ` <span class="ti">i<span class="tp">${text}</span></span>`;
}
function cardHTML(label,value,sub,cls='',tip=''){
const cc = cls==='green'?'c-green':cls==='red'?'c-red':cls==='blue'?'c-blue':cls==='amber'?'c-amber':'c-head';
return `<div class="metric-card">
<div class="metric-card__label">${label}${ti(tip)}</div>
<div class="metric-card__value ${cc}">${value}</div>
${sub?`<div class="metric-card__sub">${sub}</div>`:''}
</div>`;
}
function cardSmHTML(label,value,sub,cls='',tip=''){
const cc = cls==='green'?'c-green':cls==='red'?'c-red':cls==='blue'?'c-blue':cls==='amber'?'c-amber':'c-head';
return `<div class="metric-card metric-card-sm">
<div class="metric-card__label">${label}${ti(tip)}</div>
<div class="metric-card__value ${cc}">${value}</div>
${sub?`<div class="metric-card__sub">${sub}</div>`:''}
</div>`;
}
// ── Derived Calculations ──────────────────────────────────
function calc(){
const d = {};
const isIn = S.venue==='indoor', isBuy = S.own==='buy';
d.totalCourts = S.dblCourts + S.sglCourts;
d.hallSqm = d.totalCourts ? S.dblCourts*S.sqmPerDblHall + S.sglCourts*S.sqmPerSglHall + 200 + d.totalCourts*20 : 0;
d.outdoorLandSqm = d.totalCourts ? S.dblCourts*S.sqmPerDblOutdoor + S.sglCourts*S.sqmPerSglOutdoor + 100 : 0;
d.sqm = isIn ? d.hallSqm : d.outdoorLandSqm;
d.capexItems = [];
const ci = (name,amount,info) => d.capexItems.push({name,amount:Math.round(amount),info});
ci('Padel Courts', S.dblCourts*S.courtCostDbl + S.sglCourts*S.courtCostSgl, `${S.dblCourts}\u00D7dbl + ${S.sglCourts}\u00D7sgl`);
ci('Shipping', Math.ceil(d.totalCourts/2)*S.shipping);
if(isIn){
if(isBuy){
ci('Hall Construction', d.hallSqm*S.hallCostSqm, `${d.hallSqm}m\u00B2 \u00D7 ${fmt(S.hallCostSqm)}/m\u00B2`);
ci('Foundation', d.hallSqm*S.foundationSqm, `${d.hallSqm}m\u00B2 \u00D7 ${fmt(S.foundationSqm)}/m\u00B2`);
const landSqm = Math.round(d.hallSqm*1.25);
ci('Land Purchase', landSqm*S.landPriceSqm, `${landSqm}m\u00B2 \u00D7 ${fmt(S.landPriceSqm)}/m\u00B2`);
ci('Transaction Costs', Math.round(landSqm*S.landPriceSqm*0.1), '~10% of land');
ci('HVAC System', S.hvac);
ci('Electrical + Lighting', S.electrical);
ci('Sanitary / Changing', S.sanitary);
ci('Parking + Exterior', S.parking);
ci('Planning + Permits', S.planning);
ci('Fire Protection', S.fireProtection);
} else {
ci('Floor Preparation', S.floorPrep);
ci('HVAC Upgrade', S.hvacUpgrade);
ci('Lighting Upgrade', S.lightingUpgrade);
ci('Fit-Out & Reception', S.fitout);
}
} else {
ci('Concrete Foundation', (S.dblCourts*250+S.sglCourts*150)*S.outdoorFoundation);
ci('Site Work', S.outdoorSiteWork);
ci('Lighting', d.totalCourts*S.outdoorLighting);
ci('Fencing', S.outdoorFencing);
if(isBuy){
ci('Land Purchase', d.outdoorLandSqm*S.landPriceSqm, `${d.outdoorLandSqm}m\u00B2 \u00D7 ${fmt(S.landPriceSqm)}/m\u00B2`);
ci('Transaction Costs', Math.round(d.outdoorLandSqm*S.landPriceSqm*0.1));
}
}
ci('Equipment', S.equipment + d.totalCourts*300);
ci('Working Capital', S.workingCapital);
ci('Miscellaneous', isBuy ? 8000 : 6000);
const sub = d.capexItems.reduce((s,i)=>s+i.amount,0);
const cont = Math.round(sub*S.contingencyPct/100);
if(S.contingencyPct>0) ci(`Contingency (${S.contingencyPct}%)`, cont);
d.capex = sub + cont;
d.capexPerCourt = d.totalCourts>0 ? d.capex/d.totalCourts : 0;
d.capexPerSqm = d.sqm>0 ? d.capex/d.sqm : 0;
d.opexItems = [];
const oi = (name,amount,info) => d.opexItems.push({name,amount:Math.round(amount),info});
if(!isBuy){
if(isIn) oi('Rent', d.hallSqm*S.rentSqm, `${d.hallSqm}m\u00B2 \u00D7 \u20AC${S.rentSqm}/m\u00B2`);
else oi('Rent', S.outdoorRent);
} else {
oi('Property Tax', S.propertyTax);
}
oi('Insurance', S.insurance);
oi('Electricity', S.electricity);
if(isIn){ oi('Heating', S.heating); oi('Water', S.water); }
oi('Maintenance', S.maintenance);
if(isIn) oi('Cleaning', S.cleaning);
oi('Marketing / Software / Misc', S.marketing);
if(S.staff>0) oi('Staff', S.staff);
d.opex = d.opexItems.reduce((s,i)=>s+i.amount,0);
d.annualOpex = d.opex*12;
d.equity = Math.round(d.capex*(1-S.loanPct/100));
d.loanAmount = d.capex - d.equity;
d.monthlyPayment = d.loanAmount>0 ? pmt(S.interestRate/100/12, Math.max(S.loanTerm,1)*12, d.loanAmount) : 0;
d.annualDebtService = d.monthlyPayment*12;
d.ltv = d.capex>0 ? d.loanAmount/d.capex : 0;
const dpm = isIn ? S.daysPerMonthIndoor : S.daysPerMonthOutdoor;
d.daysPerMonth = dpm;
const wRate = d.totalCourts>0 ? (S.dblCourts*(S.ratePeak*S.peakPct/100+S.rateOffPeak*(1-S.peakPct/100)) + S.sglCourts*S.rateSingle)/d.totalCourts : S.ratePeak;
d.weightedRate = wRate;
d.availHoursMonth = S.hoursPerDay * dpm * d.totalCourts;
d.bookedHoursMonth = d.availHoursMonth * (S.utilTarget/100);
d.courtRevMonth = d.bookedHoursMonth * wRate;
d.feeDeduction = d.courtRevMonth * (S.bookingFee/100);
d.racketRev = d.bookedHoursMonth * (S.racketRentalRate/100) * S.racketQty * S.racketPrice;
d.ballMargin = d.bookedHoursMonth * (S.ballRate/100) * (S.ballPrice - S.ballCost);
d.membershipRev = d.totalCourts * S.membershipRevPerCourt;
d.fbRev = d.totalCourts * S.fbRevPerCourt;
d.coachingRev = d.totalCourts * S.coachingRevPerCourt;
d.retailRev = d.totalCourts * S.retailRevPerCourt;
d.grossRevMonth = d.courtRevMonth + d.racketRev + d.ballMargin + d.membershipRev + d.fbRev + d.coachingRev + d.retailRev;
d.netRevMonth = d.grossRevMonth - d.feeDeduction;
d.ebitdaMonth = d.netRevMonth - d.opex;
d.netCFMonth = d.ebitdaMonth - d.monthlyPayment;
d.months = [];
for(let m=1;m<=60;m++){
const cm = (m-1)%12;
const yr = Math.ceil(m/12);
const ramp = m<=12 ? S.ramp[m-1] : 1;
const seas = isIn ? 1 : S.season[cm];
const effUtil = (S.utilTarget/100)*ramp*seas;
const avail = seas>0 ? S.hoursPerDay*dpm*d.totalCourts : 0;
const booked = avail*effUtil;
const courtRev = booked*wRate;
const fees = -courtRev*(S.bookingFee/100);
const ancillary = booked*((S.racketRentalRate/100)*S.racketQty*S.racketPrice+(S.ballRate/100)*(S.ballPrice-S.ballCost));
const membership = d.totalCourts*S.membershipRevPerCourt*(seas>0?ramp:0);
const fb = d.totalCourts*S.fbRevPerCourt*(seas>0?ramp:0);
const coaching = d.totalCourts*S.coachingRevPerCourt*(seas>0?ramp:0);
const retail = d.totalCourts*S.retailRevPerCourt*(seas>0?ramp:0);
const totalRev = courtRev+fees+ancillary+membership+fb+coaching+retail;
const opex = -d.opex;
const loan = -d.monthlyPayment;
const ebitda = totalRev+opex;
const ncf = ebitda+loan;
const prev = d.months.length>0?d.months[d.months.length-1]:null;
const cum = (prev?prev.cum:-d.capex)+ncf;
d.months.push({m,cm:cm+1,yr,ramp,seas,effUtil,avail,booked,courtRev,fees,ancillary,membership,totalRev,opex,loan,ebitda,ncf,cum});
}
d.annuals = [];
for(let y=1;y<=5;y++){
const ym = d.months.filter(m=>m.yr===y);
d.annuals.push({year:y,
revenue:ym.reduce((s,m)=>s+m.totalRev,0), ebitda:ym.reduce((s,m)=>s+m.ebitda,0),
ncf:ym.reduce((s,m)=>s+m.ncf,0), ds:ym.reduce((s,m)=>s+Math.abs(m.loan),0),
booked:ym.reduce((s,m)=>s+m.booked,0), avail:ym.reduce((s,m)=>s+m.avail,0)});
}
const y3ebitda = d.annuals.length>=3?d.annuals[2].ebitda:0;
d.stabEbitda = y3ebitda;
d.exitValue = y3ebitda * S.exitMultiple;
d.remainingLoan = d.loanAmount * Math.max(0, 1 - S.holdYears/(Math.max(S.loanTerm,1)*1.5));
d.netExit = d.exitValue - d.remainingLoan;
const irrCFs = [-d.capex];
for(let y=0;y<S.holdYears;y++){
const ycf = y<d.annuals.length?d.annuals[y].ncf:(d.annuals.length>0?d.annuals[d.annuals.length-1].ncf:0);
irrCFs.push(y===S.holdYears-1?ycf+d.netExit:ycf);
}
d.irr = calcIRR(irrCFs);
d.totalReturned = irrCFs.slice(1).reduce((s,v)=>s+v,0);
d.moic = d.capex>0?d.totalReturned/d.capex:0;
d.dscr = d.annuals.map(y=>({year:y.year, dscr:y.ds>0?y.ebitda/y.ds:999}));
d.paybackIdx = d.months.findIndex(m=>m.cum>=0);
const revPerHr = wRate*(1-S.bookingFee/100)+(S.racketRentalRate/100)*S.racketQty*S.racketPrice+(S.ballRate/100)*(S.ballPrice-S.ballCost);
const fixedMonth = d.opex+d.monthlyPayment;
d.breakEvenHrs = fixedMonth/Math.max(revPerHr,0.01);
d.breakEvenUtil = d.availHoursMonth>0?d.breakEvenHrs/d.availHoursMonth:1;
d.breakEvenHrsPerCourt = d.totalCourts>0?d.breakEvenHrs/d.totalCourts/dpm:0;
d.revPAH = d.availHoursMonth>0?d.netRevMonth/d.availHoursMonth:0;
d.revPerSqm = d.sqm>0?(d.netRevMonth*12)/d.sqm:0;
d.ebitdaMargin = d.netRevMonth>0?d.ebitdaMonth/d.netRevMonth:0;
d.opexRatio = d.netRevMonth>0?d.opex/d.netRevMonth:0;
d.rentRatio = d.netRevMonth>0?(d.opexItems.find(i=>i.name==='Rent')?.amount||0)/d.netRevMonth:0;
d.cashOnCash = d.equity>0?(d.annuals.length>=3?d.annuals[2].ncf:0)/d.equity:0;
d.yieldOnCost = d.capex>0?d.stabEbitda/d.capex:0;
d.debtYield = d.loanAmount>0?d.stabEbitda/d.loanAmount:0;
d.costPerBookedHr = d.bookedHoursMonth>0?(d.opex+d.monthlyPayment)/d.bookedHoursMonth:0;
d.avgUtil = d.annuals.length>=3&&d.annuals[2].avail>0?d.annuals[2].booked/d.annuals[2].avail:0;
return d;
}
// ── UI Builders ───────────────────────────────────────────
function buildNav(){
const n = $('#nav');
n.innerHTML = TABS.map(t=>`<button data-tab="${t.id}" class="tab-btn ${t.id===activeTab?'tab-btn--active':''}">${t.label}</button>`).join('');
n.querySelectorAll('button').forEach(b=>b.onclick=()=>{activeTab=b.dataset.tab;render()});
}
function slider(key,label,min,max,step,fmtFn,tip){
return `<div class="slider-group">
<label>
<span class="slider-group__label">${label}</span>${ti(tip)}
</label>
<div class="slider-combo">
<input type="range" min="${min}" max="${max}" step="${step}" value="${S[key]}" data-key="${key}">
<input type="number" value="${S[key]}" step="${step}" data-key="${key}" data-numfor="${key}">
</div>
</div>`;
}
function buildInputs(){
buildToggle('tog-venue',[{v:'indoor',l:'Indoor'},{v:'outdoor',l:'Outdoor'}],'venue');
buildToggle('tog-own',[{v:'rent',l:'Rent / Lease'},{v:'buy',l:'Buy / Build'}],'own');
$('#inp-courts').innerHTML =
slider('dblCourts','Double Courts (20\u00D710m)',0,30,1,fN,'Standard padel court for 4 players. Most common format with highest recreational demand.')+
slider('sglCourts','Single Courts (20\u00D76m)',0,30,1,fN,'Narrow court for 2 players. Popular for coaching, training, and competitive play.');
rebuildSpaceInputs();
$('#inp-pricing').innerHTML =
slider('ratePeak','Peak Hour Rate (\u20AC)',0,150,1,fD,'Price per court per hour during peak times (evenings 17:00\u201322:00 and weekends). Highest demand period.')+
slider('rateOffPeak','Off-Peak Hour Rate (\u20AC)',0,150,1,fD,'Price per court per hour during off-peak (weekday mornings/afternoons). Typically 30\u201340% lower than peak.')+
slider('rateSingle','Single Court Rate (\u20AC)',0,150,1,fD,'Hourly rate for single-width courts. Usually lower than doubles since fewer players share the cost.')+
slider('peakPct','Peak Hours Share',0,100,1,fP,'Percentage of total booked hours at peak rate. Higher means more revenue but harder to fill off-peak slots.')+
slider('bookingFee','Platform Fee',0,30,1,fP,'Commission taken by booking platforms like Playtomic or Matchi. Typically 5\u201315% of court revenue.');
$('#inp-util').innerHTML =
slider('utilTarget','Target Utilization',0,100,1,fP,'Percentage of available court-hours that are actually booked. 35\u201345% is realistic for new venues, 50%+ is strong.')+
slider('hoursPerDay','Operating Hours / Day',0,24,1,fH,'Total operating hours per day. Typical padel venues run 7:00\u201323:00 (16h). Some extend to 6:00\u201324:00.')+
slider('daysPerMonthIndoor','Indoor Days / Month',0,31,1,fN,'Average operating days per month for indoor venue. ~29 accounts for holidays and maintenance closures.')+
slider('daysPerMonthOutdoor','Outdoor Days / Month',0,31,1,fN,'Average playable days per month outdoors. Reduced by rain, extreme heat, or cold weather.')+
'<div style="font-size:11px;color:var(--txt-3);margin:4px 0 8px"><b>Ancillary Revenue (per court/month):</b></div>'+
slider('membershipRevPerCourt','Membership Revenue / Court',0,2000,50,fE,'Monthly membership/subscription income per court. From loyalty programs, monthly plans, or club memberships.')+
slider('fbRevPerCourt','F&B Revenue / Court',0,2000,25,fE,'Food & Beverage revenue per court per month. Income from bar, caf\u00E9, restaurant, or vending machines at the venue.')+
slider('coachingRevPerCourt','Coaching & Events / Court',0,2000,25,fE,'Revenue from coaching sessions, clinics, tournaments, and events allocated per court per month.')+
slider('retailRevPerCourt','Retail / Court',0,1000,10,fE,'Revenue from pro shop sales: grip tape, overgrips, accessories, and branded merchandise per court per month.');
rebuildCapexInputs();
rebuildOpexInputs();
$('#inp-finance').innerHTML =
slider('loanPct','Loan-to-Cost (LTC)',0,100,1,fP,'Percentage of total CAPEX financed by debt. Banks typically offer 70\u201385%. Higher with personal guarantees or subsidies.')+
slider('interestRate','Interest Rate',0,15,0.1,fP,'Annual interest rate on the loan. Depends on creditworthiness, collateral, market conditions, and bank relationship.')+
slider('loanTerm','Loan Term',0,30,1,fY,'Loan repayment period in years. Longer terms mean lower monthly payments but more total interest paid.')+
slider('constructionMonths','Construction Period',0,24,1,fM,'Months of construction/setup before opening. Costs accrue (loan interest, rent) but no revenue is generated.');
$('#inp-exit').innerHTML =
slider('holdYears','Holding Period',1,20,1,fY,'Investment holding period before exit/sale. Typical for PE/investors: 5\u20137 years. Owner-operators may hold indefinitely.')+
slider('exitMultiple','Exit EBITDA Multiple',0,20,0.5,fR,'EBITDA multiple used to value the business at exit. Reflects market demand, brand strength, and growth potential. Small business: 4\u20136x, strong brand: 6\u20138x.')+
slider('annualRevGrowth','Annual Revenue Growth',0,15,0.5,fP,'Expected annual revenue growth rate after the initial 12-month ramp-up period. Driven by price increases and utilization gains.');
}
function rebuildSpaceInputs(){
const isIn = S.venue==='indoor';
let h = '';
if(isIn){
h += slider('sqmPerDblHall','Hall m\u00B2 per Double Court',0,600,10,fN,'Total hall space needed per double court. Includes court (200m\u00B2), safety zones, circulation, and minimum clearances. Standard: 300\u2013350m\u00B2.')+
slider('sqmPerSglHall','Hall m\u00B2 per Single Court',0,400,10,fN,'Total hall space needed per single court. Includes court (120m\u00B2), safety zones, and access. Standard: 200\u2013250m\u00B2.');
} else {
h += slider('sqmPerDblOutdoor','Land m\u00B2 per Double Court',0,500,10,fN,'Outdoor land area per double court. Includes court area, drainage slopes, access paths, and buffer zones. Standard: 280\u2013320m\u00B2.')+
slider('sqmPerSglOutdoor','Land m\u00B2 per Single Court',0,350,10,fN,'Outdoor land area per single court. Includes court, surrounding space, and access paths. Standard: 180\u2013220m\u00B2.');
}
$('#inp-space').innerHTML = h;
}
function rebuildCapexInputs(){
const isIn=S.venue==='indoor', isBuy=S.own==='buy';
let h = slider('courtCostDbl','Court Cost \u2014 Double',0,80000,1000,fE,'Purchase price of one double padel court. Standard glass: \u20AC25\u201330K. Panoramic: \u20AC30\u201345K. WPT-spec: \u20AC40\u201355K.')+
slider('courtCostSgl','Court Cost \u2014 Single',0,60000,1000,fE,'Purchase price of one single padel court. Generally 60\u201370% of a double court cost.');
if(isIn&&isBuy){
h+=slider('hallCostSqm','Hall Construction (\u20AC/m\u00B2)',0,2000,10,fE,'Construction cost per m\u00B2 for a new hall (Warmhalle). Includes structure, insulation, and cladding. Requires 10\u201312m clear height.')+
slider('foundationSqm','Foundation (\u20AC/m\u00B2)',0,400,5,fE,'Foundation cost per m\u00B2. Depends on soil conditions, load-bearing requirements, and local ground water levels.')+
slider('landPriceSqm','Land Price (\u20AC/m\u00B2)',0,500,5,fE,'Land purchase price per m\u00B2. Rural: \u20AC20\u201360. Suburban: \u20AC60\u2013150. Urban: \u20AC150\u2013300+. Varies hugely by location.')+
slider('hvac','HVAC System',0,500000,5000,fE,'Heating, ventilation, and air conditioning. Essential for indoor comfort and humidity control. Cost scales with hall volume.')+
slider('electrical','Electrical + Lighting',0,400000,5000,fE,'Complete electrical installation: court lighting (LED, 500+ lux), power distribution, panels, and outlets.')+
slider('sanitary','Sanitary / Changing',0,400000,5000,fE,'Changing rooms, showers, toilets, and plumbing. Includes fixtures, tiling, waterproofing, and ventilation.')+
slider('fireProtection','Fire Protection',0,500000,5000,fE,'Fire detection, sprinkler suppression, emergency exits, and smoke ventilation. Often the biggest surprise cost for large halls.')+
slider('planning','Planning + Permits',0,500000,5000,fE,'Architectural planning, structural engineering, building permits, zoning applications, and regulatory compliance costs.');
} else if(isIn&&!isBuy){
h+=slider('floorPrep','Floor Preparation',0,100000,1000,fE,'Floor leveling, sealing, and preparation for court installation in an existing rented building.')+
slider('hvacUpgrade','HVAC Upgrade',0,200000,1000,fE,'Upgrading existing HVAC in a rented building to handle sports venue airflow and humidity requirements.')+
slider('lightingUpgrade','Lighting Upgrade',0,100000,1000,fE,'Upgrading existing lighting to meet padel requirements: minimum 500 lux, no glare, even distribution across courts.')+
slider('fitout','Fit-Out & Reception',0,300000,1000,fE,'Interior fit-out for reception, lounge, viewing areas, and common spaces when renting an existing building.');
} else if(!isIn){
h+=slider('outdoorFoundation','Concrete (\u20AC/m\u00B2)',0,150,1,fE,'Concrete pad per m\u00B2 for outdoor courts. Needs proper drainage, level surface, and frost-resistant construction.')+
slider('outdoorSiteWork','Site Work',0,60000,500,fE,'Grading, drainage installation, utilities connection, and site preparation for outdoor courts.')+
slider('outdoorLighting','Lighting per Court',0,20000,500,fE,'Floodlight installation per court. LED recommended for energy efficiency. Must meet competition standards if applicable.')+
slider('outdoorFencing','Fencing',0,40000,500,fE,'Perimeter fencing around outdoor court area. Includes wind screens, security gates, and ball containment nets.');
if(isBuy) h+=slider('landPriceSqm','Land Price (\u20AC/m\u00B2)',0,500,5,fE,'Land purchase price per m\u00B2. Varies by location, zoning, and accessibility.');
}
h+=slider('workingCapital','Working Capital',0,200000,1000,fE,'Cash reserve for operating losses during ramp-up phase and seasonal dips. Critical buffer \u2014 underfunding is a common startup failure.')+
slider('contingencyPct','Contingency',0,30,1,fP,'Percentage buffer on total CAPEX for unexpected costs. 10\u201315% is standard for construction, 15\u201320% for complex projects.');
$('#inp-capex').innerHTML = h;
}
function rebuildOpexInputs(){
const isIn=S.venue==='indoor', isBuy=S.own==='buy';
let h='';
if(!isBuy){
if(isIn) h+=slider('rentSqm','Rent (\u20AC/m\u00B2/month)',0,25,0.5,fD,'Monthly rent per square meter for indoor hall space. Varies by location, building quality, and lease terms.');
else h+=slider('outdoorRent','Monthly Land Rent',0,5000,50,fE,'Monthly land rent for outdoor court area. Much cheaper than indoor space but weather-dependent.');
} else {
h+=slider('propertyTax','Property Tax / month',0,2000,25,fE,'Monthly property tax when owning the building/land. Grundsteuer in Germany, varies by municipality and property value.');
}
h+=slider('insurance','Insurance (\u20AC/mo)',0,2000,25,fE,'Monthly insurance premium covering liability, property damage, business interruption, and equipment.')+
slider('electricity','Electricity (\u20AC/mo)',0,5000,25,fE,'Monthly electricity cost. Major driver for indoor venues due to court lighting, HVAC, and equipment.');
if(isIn) h+=slider('heating','Heating (\u20AC/mo)',0,3000,25,fE,'Monthly heating cost for indoor venue. Significant in northern European climates during winter months.')+
slider('water','Water (\u20AC/mo)',0,1000,25,fE,'Monthly water cost for showers, toilets, cleaning, and potentially outdoor court irrigation.');
h+=slider('maintenance','Maintenance (\u20AC/mo)',0,2000,25,fE,'Monthly court and facility maintenance: glass cleaning, surface repair, net replacement, and equipment upkeep.')+
(isIn?slider('cleaning','Cleaning (\u20AC/mo)',0,2000,25,fE,'Monthly professional cleaning of courts, changing rooms, common areas, and reception.'):'')+
slider('marketing','Marketing / Misc (\u20AC/mo)',0,5000,25,fE,'Monthly spend on marketing, booking platform subscriptions, website, social media, and customer acquisition.')+
slider('staff','Staff (\u20AC/mo)',0,20000,100,fE,'Monthly staff costs including wages, social contributions, and benefits. Many venues run lean using automated booking and access systems.');
$('#inp-opex').innerHTML = h;
}
function buildToggle(id,opts,key){
const el = $(`#${id}`);
el.innerHTML = opts.map(o=>`<button data-val="${o.v}" class="toggle-btn ${S[key]===o.v?'toggle-btn--active':''}">${o.l}</button>`).join('');
el.querySelectorAll('button').forEach(b=>b.onclick=()=>{
S[key]=b.dataset.val;
rebuildSpaceInputs(); rebuildCapexInputs(); rebuildOpexInputs(); bindSliders(); render();
});
}
function bindSliders(){
document.querySelectorAll('input[type=range][data-key]').forEach(inp=>{
inp.oninput = () => {
const k=inp.dataset.key;
S[k]=parseFloat(inp.value);
const numInp = document.querySelector(`input[type=number][data-numfor="${k}"]`);
if(numInp) numInp.value = S[k];
render();
};
});
document.querySelectorAll('input[type=number][data-numfor]').forEach(inp=>{
inp.oninput = () => {
const k=inp.dataset.numfor;
const v = parseFloat(inp.value);
if(isNaN(v)) return;
S[k]=v;
const rangeInp = document.querySelector(`input[type=range][data-key="${k}"]`);
if(rangeInp) rangeInp.value = v;
render();
};
});
}
// ── Render ─────────────────────────────────────────────────
function render(){
const d = calc();
const isIn=S.venue==='indoor';
const label = `${isIn?'Indoor':'Outdoor'} \u00B7 ${S.own==='buy'?'Build/Buy':'Rent'}`;
$('#headerTag').textContent = `${label} \u00B7 ${d.totalCourts} courts \u00B7 ${fmtK(d.capex)}`;
$$('.tab-btn').forEach(b=>{
const a = b.dataset.tab===activeTab;
b.classList.toggle('tab-btn--active', a);
});
$$('.tab').forEach(t=>{
t.classList.toggle('active',t.id===`tab-${activeTab}`);
});
const courtPlaySqm = S.dblCourts*200+S.sglCourts*120;
$('#courtSummary').innerHTML =
cardSmHTML('Total Courts',d.totalCourts)+
cardSmHTML('Floor Area',`${fmtN(d.sqm)} m\u00B2`,isIn?'Indoor hall':'Outdoor land')+
cardSmHTML('Court Area',`${fmtN(courtPlaySqm)} m\u00B2`,'Playing surface');
if(activeTab==='capex') renderCapex(d);
if(activeTab==='operating') renderOperating(d);
if(activeTab==='cashflow') renderCashflow(d);
if(activeTab==='returns') renderReturns(d);
if(activeTab==='metrics') renderMetrics(d);
if(activeTab==='operating'){
const sec = $('#seasonSection');
if(isIn){ sec.classList.remove('visible'); }
else { sec.classList.add('visible'); renderSeasonChart(); }
}
}
// ── Table helper ──
const TH = t => `<th>${t}</th>`;
const THR = t => `<th class="right">${t}</th>`;
function renderCapex(d){
$('#capexCards').innerHTML =
cardHTML('Total CAPEX',fmt(d.capex),'','red','Capital Expenditure: total upfront investment required to build and equip the venue before opening.')+
cardHTML('Per Court',fmt(Math.round(d.capexPerCourt)),d.totalCourts+' courts','','Total investment divided by number of courts. Useful for comparing scenarios and benchmarking.')+
cardHTML('Per m\u00B2',fmt(Math.round(d.capexPerSqm)),fmtN(d.sqm)+' m\u00B2','','Total investment per square meter of venue space. Benchmarks construction efficiency.');
let rows = d.capexItems.map(i=>`<tr><td>${i.name}${i.info?` <span style="color:var(--txt-3);font-size:10px">(${i.info})</span>`:''}</td><td class="mono">${fmt(i.amount)}</td></tr>`).join('');
rows += `<tr class="total-row"><td>TOTAL CAPEX</td><td class="mono">${fmt(d.capex)}</td></tr>`;
$('#capexTable').innerHTML = `<table class="data-table"><thead><tr>${TH('Item')}${THR('Amount')}</tr></thead><tbody>${rows}</tbody></table>`;
renderChart('chartCapex','doughnut',{
labels:d.capexItems.filter(i=>i.amount>0).map(i=>i.name),
datasets:[{data:d.capexItems.filter(i=>i.amount>0).map(i=>i.amount),
backgroundColor:['#d94f4f','#4a90d9','#3dba78','#d4a03c','#8b5cf6','#ec4899','#06b6d4','#84cc16','#f97316','#6366f1','#14b8a6','#a855f7','#ef4444','#22c55e','#eab308'],
borderWidth:0}]
},{plugins:{legend:{position:'right',labels:{color:'#7a8fa3',font:{size:10,family:'Outfit'},boxWidth:10,padding:6}}}});
}
function renderOperating(d){
const margin = d.netRevMonth>0?(d.ebitdaMonth/d.netRevMonth*100).toFixed(1):0;
$('#opCards').innerHTML =
cardHTML('Net Revenue/mo',fmt(Math.round(d.netRevMonth)),'Stabilized','green','Monthly revenue after deducting platform booking fees but before operating expenses.')+
cardHTML('EBITDA/mo',fmt(Math.round(d.ebitdaMonth)),margin+'% margin',d.ebitdaMonth>=0?'green':'red','Earnings Before Interest, Taxes, Depreciation & Amortization. Core monthly operating profit of the business.')+
cardHTML('Annual Revenue',fmt(Math.round(d.annuals.length>=3?d.annuals[2].revenue:0)),'Year 3','','Projected total annual revenue in Year 3 when the business has reached stabilized utilization.')+
cardHTML('RevPAH',fmt(d.revPAH),'Revenue per available hour','blue','Revenue Per Available Hour. Net revenue divided by total available court-hours. Measures how well you monetize capacity.');
const streams=[
['Court Rental (net of fees)',d.courtRevMonth-d.feeDeduction],
['Equipment Rental (rackets/balls)',d.racketRev+d.ballMargin],
['Memberships',d.membershipRev],
['F&B',d.fbRev],
['Coaching & Events',d.coachingRev],
['Retail',d.retailRev],
];
const totalStream = streams.reduce((s,r)=>s+r[1],0);
let sRows = streams.map(([n,v])=>{
const pct=totalStream>0?(v/totalStream*100).toFixed(0):0;
return `<tr><td>${n}</td><td class="mono">${fmt(Math.round(v))}</td><td class="mono">${pct}%</td></tr>`;
}).join('');
sRows+=`<tr class="total-row"><td>Total Net Revenue</td><td class="mono">${fmt(Math.round(totalStream))}</td><td class="mono">100%</td></tr>`;
$('#revenueTable').innerHTML=`<table class="data-table"><thead><tr>${TH('Stream')}${THR('Monthly')}${THR('Share')}</tr></thead><tbody>${sRows}</tbody></table>`;
let oRows=d.opexItems.map(i=>`<tr><td>${i.name}${i.info?` <span style="color:var(--txt-3);font-size:10px">(${i.info})</span>`:''}</td><td class="mono">${fmt(i.amount)}</td></tr>`).join('');
oRows+=`<tr class="total-row"><td>Total Monthly OpEx</td><td class="mono">${fmt(d.opex)}</td></tr>`;
$('#opexDetailTable').innerHTML=`<table class="data-table"><thead><tr>${TH('Item')}${THR('Monthly')}</tr></thead><tbody>${oRows}</tbody></table>`;
const rampData = d.months.slice(0,24);
renderChart('chartRevRamp','bar',{
labels:rampData.map(m=>'M'+m.m),
datasets:[
{label:'Revenue',data:rampData.map(m=>Math.round(m.totalRev)),backgroundColor:'rgba(61,186,120,0.5)',borderRadius:3},
{label:'OpEx+Debt',data:rampData.map(m=>Math.round(Math.abs(m.opex)+Math.abs(m.loan))),backgroundColor:'rgba(217,79,79,0.4)',borderRadius:3},
]
},{scales:{x:{ticks:{maxTicksLimit:12,color:'#4d6278',font:{size:9}}},y:{ticks:{color:'#4d6278',font:{size:9}},grid:{color:'rgba(255,255,255,0.03)'}}},plugins:{legend:{labels:{color:'#7a8fa3',font:{size:10}}}}});
const plData = [
{label:'Court Rev',val:Math.round(d.courtRevMonth)},
{label:'Fees',val:-Math.round(d.feeDeduction)},
{label:'Ancillary',val:Math.round(d.racketRev+d.ballMargin+d.membershipRev+d.fbRev+d.coachingRev+d.retailRev)},
{label:'OpEx',val:-Math.round(d.opex)},
{label:'Debt',val:-Math.round(d.monthlyPayment)},
];
renderChart('chartPL','bar',{
labels:plData.map(p=>p.label),
datasets:[{data:plData.map(p=>p.val),backgroundColor:plData.map(p=>p.val>=0?'rgba(61,186,120,0.6)':'rgba(217,79,79,0.5)'),borderRadius:4}]
},{indexAxis:'y',scales:{x:{ticks:{color:'#4d6278',font:{size:9}},grid:{color:'rgba(255,255,255,0.03)'}},y:{ticks:{color:'#7a8fa3',font:{size:10}}}},plugins:{legend:{display:false}}});
}
function renderCashflow(d){
const payback = d.paybackIdx>=0?`Month ${d.paybackIdx+1}`:'Not reached';
const y1ncf = d.annuals[0]?.ncf||0;
const y3ncf = d.annuals.length>=3?d.annuals[2].ncf:0;
$('#cfCards').innerHTML =
cardHTML('Year 1 Net CF',fmt(Math.round(y1ncf)),'',y1ncf>=0?'green':'red','Net Cash Flow in Year 1. Typically negative due to ramp-up. Includes all revenue minus OpEx and debt service.')+
cardHTML('Year 3 Net CF',fmt(Math.round(y3ncf)),'Stabilized',y3ncf>=0?'green':'red','Net Cash Flow in Year 3 when utilization has reached target levels. This is the stabilized annual performance.')+
cardHTML('Payback',payback,d.paybackIdx>=0?`~${((d.paybackIdx+1)/12).toFixed(1)} years`:'','','Number of months until cumulative cash flows recover the full initial CAPEX investment.')+
cardHTML('Initial Investment',fmt(d.capex),'','red','Total upfront capital required including construction, equipment, permits, and working capital buffer.');
renderChart('chartCF','bar',{
labels:d.months.map(m=>m.m%12===1?'Y'+m.yr:''),
datasets:[{data:d.months.map(m=>Math.round(m.ncf)),
backgroundColor:d.months.map(m=>m.ncf>=0?'rgba(61,186,120,0.5)':'rgba(217,79,79,0.4)'),borderRadius:2}]
},{scales:{x:{ticks:{color:'#4d6278',font:{size:9}}},y:{ticks:{color:'#4d6278',font:{size:9}},grid:{color:'rgba(255,255,255,0.03)'}}},plugins:{legend:{display:false}}});
renderChart('chartCum','line',{
labels:d.months.map(m=>m.m%6===1?'M'+m.m:''),
datasets:[{data:d.months.map(m=>Math.round(m.cum)),borderColor:'#4a90d9',backgroundColor:'rgba(74,144,217,0.08)',fill:true,pointRadius:0,tension:0.3}]
},{scales:{x:{ticks:{color:'#4d6278',font:{size:9}}},y:{ticks:{color:'#4d6278',font:{size:9}},grid:{color:'rgba(255,255,255,0.03)'}}},plugins:{legend:{display:false}}});
let rows = d.annuals.map(y=>{
const dscr = y.ds>0?y.ebitda/y.ds:999;
const util = y.avail>0?(y.booked/y.avail*100).toFixed(0):0;
return `<tr>
<td><b>Year ${y.year}</b></td>
<td class="mono c-green">${fmt(Math.round(y.revenue))}</td>
<td class="mono ${y.ebitda>=0?'c-green':'c-red'}">${fmt(Math.round(y.ebitda))}</td>
<td class="mono c-red">${fmt(Math.round(y.ds))}</td>
<td class="mono" style="font-weight:700;${y.ncf>=0?'color:var(--gn)':'color:var(--rd)'}">${fmt(Math.round(y.ncf))}</td>
<td class="mono">${dscr>99?'\u221E':fmtX(dscr)}</td>
<td class="mono">${util}%</td>
</tr>`;
}).join('');
$('#annualTable').innerHTML=`<table class="data-table"><thead><tr>${TH('Year')}${THR('Revenue')}${THR('EBITDA')}${THR('Debt Service')}${THR('Net CF')}${THR('DSCR')}${THR('Util.')}</tr></thead><tbody>${rows}</tbody></table>`;
}
function renderReturns(d){
const irrOk=isFinite(d.irr)&&!isNaN(d.irr);
$('#retCards').innerHTML =
cardHTML('IRR',irrOk?fmtP(d.irr):'N/A',irrOk&&d.irr>0.2?'\u2713 Above 20%':'\u2717 Below target',irrOk&&d.irr>0.2?'green':'red','Internal Rate of Return. The annualized rate of return that makes the NPV of all cash flows equal zero. Accounts for timing of cash flows. Target: >20% for small business risk.')+
cardHTML('MOIC',fmtX(d.moic),d.moic>2?'\u2713 Above 2.0x':'\u2717 Below 2.0x',d.moic>2?'green':'red','Multiple on Invested Capital. Total money returned (cash flows + exit proceeds) divided by total money invested. 2.0x = you doubled your money.')+
cardHTML('Break-Even Util.',fmtP(d.breakEvenUtil),`${d.breakEvenHrsPerCourt.toFixed(1)} hrs/court/day`,d.breakEvenUtil<0.35?'green':'amber','Minimum court utilization needed to cover all monthly costs (OpEx + debt service). Below this level, the venue loses money each month.')+
cardHTML('Cash-on-Cash',fmtP(d.cashOnCash),'Year 3 NCF \u00F7 Equity',d.cashOnCash>0.15?'green':'amber','Annual cash flow (Year 3, stabilized) divided by your equity investment. Measures the cash yield on your own money, ignoring appreciation.');
const wf = [
['Stabilized EBITDA (Y3)',fmt(Math.round(d.stabEbitda)),'c-head'],
['\u00D7 Exit Multiple',S.exitMultiple+'x','c-head'],
['= Enterprise Value',fmt(Math.round(d.exitValue)),'c-blue'],
['\u2013 Remaining Loan',fmt(Math.round(d.remainingLoan)),'c-red'],
['= Net Exit Proceeds',fmt(Math.round(d.netExit)),d.netExit>0?'c-green':'c-red'],
['+ Cumulative Cash Flow',fmt(Math.round(d.totalReturned-d.netExit)),'c-head'],
['= Total Returns',fmt(Math.round(d.totalReturned)),d.totalReturned>0?'c-green':'c-red'],
['\u00F7 Investment',fmt(d.capex),'c-head'],
['= MOIC',fmtX(d.moic),d.moic>2?'c-green':'c-red'],
];
$('#exitWaterfall').innerHTML = wf.map(([l,v,c])=>`<div class="waterfall-row"><span class="waterfall-row__label">${l}</span><span class="waterfall-row__value ${c}">${v}</span></div>`).join('');
renderChart('chartDSCR','bar',{
labels:d.dscr.map(x=>'Y'+x.year),
datasets:[{data:d.dscr.map(x=>Math.min(x.dscr,10)),backgroundColor:d.dscr.map(x=>x.dscr>=1.2?'rgba(61,186,120,0.5)':'rgba(217,79,79,0.5)'),borderRadius:4}]
},{scales:{x:{ticks:{color:'#7a8fa3'}},y:{ticks:{color:'#4d6278',font:{size:9}},grid:{color:'rgba(255,255,255,0.03)'}}},plugins:{legend:{display:false}}});
const utils = [15,20,25,30,35,40,45,50,55,60,65,70];
const isIn = S.venue==='indoor';
const wRate = d.weightedRate;
const revPerHr = wRate*(1-S.bookingFee/100)+(S.racketRentalRate/100)*S.racketQty*S.racketPrice+(S.ballRate/100)*(S.ballPrice-S.ballCost);
let sRows = utils.map(u=>{
const booked = d.availHoursMonth*(u/100);
const rev = booked*revPerHr + d.totalCourts*(S.membershipRevPerCourt+S.fbRevPerCourt+S.coachingRevPerCourt+S.retailRevPerCourt)*(u/Math.max(S.utilTarget,1));
const ncf = rev-d.opex-d.monthlyPayment;
const annual = ncf*(isIn?12:6);
const ebitda = rev-d.opex;
const dscr = d.annualDebtService>0?(ebitda*(isIn?12:6))/d.annualDebtService:999;
const isTarget = u===S.utilTarget;
return `<tr${isTarget?' style="background:var(--rd-bg)"':''}><td>${isTarget?'<b>\u2192 ':''} ${u}%${isTarget?' \u2190</b>':''}</td><td class="mono">${fmt(Math.round(rev))}</td><td class="mono ${ncf>=0?'c-green':'c-red'}">${fmt(Math.round(ncf))}</td><td class="mono ${annual>=0?'c-green':'c-red'}">${fmt(Math.round(annual))}</td><td class="mono">${dscr>99?'\u221E':fmtX(dscr)}</td></tr>`;
}).join('');
$('#sensTable').innerHTML=`<table class="data-table"><thead><tr>${TH('Utilization')}${THR('Monthly Rev')}${THR('Monthly NCF')}${THR('Annual NCF')}${THR('DSCR')}</tr></thead><tbody>${sRows}</tbody></table>`;
const prices = [-20,-10,-5,0,5,10,15,20];
let pRows = prices.map(delta=>{
const adjRate = wRate*(1+delta/100);
const booked = d.bookedHoursMonth;
const rev = booked*adjRate*(1-S.bookingFee/100)+booked*((S.racketRentalRate/100)*S.racketQty*S.racketPrice+(S.ballRate/100)*(S.ballPrice-S.ballCost))+d.totalCourts*(S.membershipRevPerCourt+S.fbRevPerCourt+S.coachingRevPerCourt+S.retailRevPerCourt);
const ncf = rev-d.opex-d.monthlyPayment;
const isBase = delta===0;
return `<tr${isBase?' style="background:var(--rd-bg)"':''}><td>${isBase?'<b>\u2192 ':''}${delta>=0?'+':''}${delta}%${isBase?' (base)</b>':''}</td><td class="mono">${fmt(Math.round(adjRate))}</td><td class="mono">${fmt(Math.round(rev))}</td><td class="mono ${ncf>=0?'c-green':'c-red'}">${fmt(Math.round(ncf))}</td></tr>`;
}).join('');
$('#priceSensTable').innerHTML=`<table class="data-table"><thead><tr>${TH('Price Change')}${THR('Avg Rate')}${THR('Monthly Rev')}${THR('Monthly NCF')}</tr></thead><tbody>${pRows}</tbody></table>`;
}
function renderMetrics(d){
const isIn=S.venue==='indoor';
const irrOk=isFinite(d.irr)&&!isNaN(d.irr);
const annRev = d.annuals.length>=3?d.annuals[2].revenue:0;
$('#mReturn').innerHTML =
cardSmHTML('IRR',irrOk?fmtP(d.irr):'N/A',`${S.holdYears}-year`,irrOk&&d.irr>.2?'green':'red','Internal Rate of Return. Annualized return accounting for the timing of all cash flows over the holding period.')+
cardSmHTML('MOIC',fmtX(d.moic),'Total return multiple',d.moic>2?'green':'red','Multiple on Invested Capital. Total cash returned divided by total cash invested. 2.0x means you doubled your money.')+
cardSmHTML('Cash-on-Cash',fmtP(d.cashOnCash),'Y3 NCF \u00F7 Equity',d.cashOnCash>.15?'green':'amber','Year 3 net cash flow divided by equity invested. Measures annual cash yield on your own capital, ignoring asset appreciation.')+
cardSmHTML('Payback',d.paybackIdx>=0?`${((d.paybackIdx+1)/12).toFixed(1)} yr`:'N/A','Months: '+(d.paybackIdx>=0?d.paybackIdx+1:'\u221E'),'','Months until cumulative net cash flows fully recover the initial CAPEX investment. Shorter payback = lower risk.');
$('#mRevenue').innerHTML =
cardSmHTML('RevPAH',fmt(d.revPAH),'Revenue per Available Hour','blue','Revenue Per Available Hour. Net revenue divided by total available court-hours (booked + unbooked). Measures capacity monetization.')+
cardSmHTML('Revenue / m\u00B2',fmt(Math.round(d.revPerSqm)),'Annual net revenue \u00F7 area','blue','Annual net revenue divided by total venue floor area. Benchmarks how efficiently you use your space compared to other venues.')+
cardSmHTML('Revenue / Court',fmt(Math.round(annRev/Math.max(1,d.totalCourts))),'Year 3 annual','','Year 3 annual revenue divided by number of courts. Useful for comparing venue performance across different sizes.')+
cardSmHTML('Avg Booked Rate',fmt(Math.round(d.weightedRate)),'Blended peak/off-peak','','Weighted average hourly rate across peak, off-peak, and single court bookings. The effective price per court-hour.');
$('#mCost').innerHTML =
cardSmHTML('EBITDA Margin',fmtP(d.ebitdaMargin),'Operating profit margin',d.ebitdaMargin>.3?'green':'amber','EBITDA as percentage of net revenue. Measures what share of revenue becomes operating profit. Higher = more efficient operations.')+
cardSmHTML('OpEx Ratio',fmtP(d.opexRatio),'OpEx \u00F7 Revenue','','Monthly operating expenses divided by net revenue. Lower ratio means more of each euro earned is profit. Target: <60%.')+
cardSmHTML('Occupancy Cost',fmtP(d.rentRatio),'Rent \u00F7 Revenue',d.rentRatio<.3?'green':'red','Rent as percentage of net revenue. Key metric for rented venues. Above 30% is risky \u2014 it squeezes margins on everything else.')+
cardSmHTML('Cost / Booked Hour',fmt(d.costPerBookedHr),'All-in cost per hour sold','','Total monthly costs (OpEx + debt service) divided by booked hours. Your true all-in cost to deliver one hour of court time.');
const y3dscr = d.dscr.length>=3?d.dscr[2].dscr:0;
$('#mDebt').innerHTML =
cardSmHTML('DSCR (Y3)',y3dscr>99?'\u221E':fmtX(y3dscr),'Min 1.2x for banks',y3dscr>=1.2?'green':'red','Debt Service Coverage Ratio. Annual EBITDA divided by annual loan payments (principal + interest). Banks require minimum 1.2x, prefer 1.5x+.')+
cardSmHTML('LTV',fmtP(d.ltv),'Loan \u00F7 Total Investment','','Loan-to-Value ratio. Total debt as percentage of total investment cost. Banks typically cap at 80\u201385%. Lower = less financial risk.')+
cardSmHTML('Debt Yield',fmtP(d.debtYield),'Stab. EBITDA \u00F7 Loan',d.debtYield>.1?'green':'amber','Stabilized EBITDA divided by total loan amount. Alternative lender risk metric. Above 10% is healthy, indicating the loan is well-supported by earnings.')+
cardSmHTML('Monthly Debt Service',fmt(Math.round(d.monthlyPayment)),'P&I payment','red','Monthly loan payment including both principal repayment and interest. This is a fixed cost that must be paid regardless of revenue.');
$('#mInvest').innerHTML =
cardSmHTML('CAPEX / Court',fmt(Math.round(d.capexPerCourt)),'Total investment per court','','Total CAPEX divided by number of courts. Key benchmark for comparing build costs across scenarios and competitor venues.')+
cardSmHTML('CAPEX / m\u00B2',fmt(Math.round(d.capexPerSqm)),'Investment per floor area','','Total CAPEX divided by total venue area. Measures construction cost efficiency per unit of space.')+
cardSmHTML('Yield on Cost',fmtP(d.yieldOnCost),'Stab. EBITDA \u00F7 CAPEX',d.yieldOnCost>.08?'green':'amber','Stabilized annual EBITDA divided by total CAPEX. Measures the annual return generated by the physical asset. Target: >8%.')+
cardSmHTML('Exit Value',fmtK(d.exitValue),`${S.exitMultiple}x Y3 EBITDA`,'','Estimated sale value of the business at exit. Calculated as stabilized EBITDA multiplied by the exit EBITDA multiple.');
$('#mOps').innerHTML =
cardSmHTML('Break-Even Util.',fmtP(d.breakEvenUtil),`${d.breakEvenHrsPerCourt.toFixed(1)} hrs/court/day`,d.breakEvenUtil<.35?'green':'amber','Minimum utilization needed to cover all costs. The lower this is, the safer the business \u2014 more room for underperformance.')+
cardSmHTML('Y3 Utilization',fmtP(d.avgUtil),'Effective avg utilization','','Average effective utilization in Year 3. Should be at or near your target utilization, accounting for ramp-up completion.')+
cardSmHTML('Available Hours/mo',fmtN(d.availHoursMonth),'All courts combined','','Total available court-hours per month across all courts. Operating hours \u00D7 days per month \u00D7 number of courts.')+
cardSmHTML('Operating Months',isIn?'12':'~'+S.season.filter(s=>s>0).length,isIn?'Year-round':'Seasonal','','Number of months per year the venue generates revenue. Indoor: 12. Outdoor: depends on climate, typically 6\u20138 months.');
}
function renderSeasonChart(){
const months=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
renderChart('chartSeason','bar',{
labels:months,
datasets:[{data:S.season.map(s=>s*100),backgroundColor:S.season.map(s=>s>0?'rgba(61,186,120,0.5)':'rgba(217,79,79,0.2)'),borderRadius:4}]
},{scales:{x:{ticks:{color:'#7a8fa3'}},y:{max:110,ticks:{color:'#4d6278'},grid:{color:'rgba(255,255,255,0.03)'}}},plugins:{legend:{display:false}}});
}
// ── Chart Helper ──────────────────────────────────────────
function renderChart(canvasId,type,data,opts={}){
if(charts[canvasId]) charts[canvasId].destroy();
const ctx = document.getElementById(canvasId);
if(!ctx) return;
const defaults = {
responsive:true, maintainAspectRatio:false, animation:{duration:0},
scales:{},
plugins:{legend:{labels:{color:'#7a8fa3',font:{family:'Outfit',size:10}}}},
};
if(type==='doughnut'||type==='pie'){
delete defaults.scales;
defaults.cutout = '55%';
} else {
defaults.scales = {
x:{ticks:{color:'#4d6278',font:{size:9,family:'Outfit'}},grid:{display:false},border:{color:'rgba(255,255,255,0.06)'}},
y:{ticks:{color:'#4d6278',font:{size:9,family:'JetBrains Mono'}},grid:{color:'rgba(255,255,255,0.03)'},border:{color:'rgba(255,255,255,0.06)'}},
};
}
charts[canvasId] = new Chart(ctx,{type,data,options:deepMerge(defaults,opts)});
}
function deepMerge(t,s){
const o={...t};
for(const k in s){
if(s[k]&&typeof s[k]==='object'&&!Array.isArray(s[k])&&t[k]&&typeof t[k]==='object') o[k]=deepMerge(t[k],s[k]);
else o[k]=s[k];
}
return o;
}
// ── Scenario Save/Load ────────────────────────────────────
function saveScenario(){
const name = prompt('Scenario name:', 'My Padel Plan');
if(!name) return;
const csrf = document.querySelector('input[name="csrf_token"]')?.value;
fetch(window.__PADELNOMICS_SAVE_URL__, {
method: 'POST',
headers: {'Content-Type':'application/json', 'X-CSRF-Token': csrf},
body: JSON.stringify({name, state_json: JSON.stringify(S)}),
})
.then(r=>r.json())
.then(data=>{
if(data.ok){
const fb = document.getElementById('save-feedback');
fb.innerHTML = '<div class="save-toast">Scenario saved!</div>';
const countBtn = document.getElementById('scenarioListBtn');
if(countBtn) countBtn.textContent = `My Scenarios (${data.count})`;
}
});
}
function loadScenario(id){
fetch(window.__PADELNOMICS_SCENARIO_URL__ + id)
.then(r=>r.json())
.then(data=>{
if(data.state_json){
const state = JSON.parse(data.state_json);
Object.assign(S, state);
buildInputs();
bindSliders();
render();
document.getElementById('scenario-drawer').classList.remove('open');
}
});
}
// Wire up save button
document.addEventListener('DOMContentLoaded', () => {
const saveBtn = document.getElementById('saveScenarioBtn');
if(saveBtn) saveBtn.onclick = saveScenario;
const listBtn = document.getElementById('scenarioListBtn');
if(listBtn) {
listBtn.addEventListener('click', () => {
document.getElementById('scenario-drawer').classList.add('open');
});
}
});
// ── Init ──────────────────────────────────────────────────
buildNav();
buildInputs();
bindSliders();
render();

View File

@@ -0,0 +1,107 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ config.APP_NAME }}{% endblock %}</title>
<!-- Pico CSS v2 dark theme -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<!-- Brand overrides -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/custom.css') }}">
{% block head %}{% endblock %}
</head>
<body>
<!-- Navigation -->
<nav class="container">
<ul>
<li><a href="{{ url_for('public.landing') }}"><strong>{{ config.APP_NAME }}</strong></a></li>
</ul>
<ul>
{% if user %}
<li><a href="{{ url_for('planner.index') }}">Planner</a></li>
<li><a href="{{ url_for('dashboard.index') }}">Dashboard</a></li>
{% if session.get('is_admin') %}
<li><a href="{{ url_for('admin.index') }}"><mark>Admin</mark></a></li>
{% endif %}
<li>
<form method="post" action="{{ url_for('auth.logout') }}" style="margin: 0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="outline secondary" style="padding: 0.5rem 1rem; margin: 0;">Sign Out</button>
</form>
</li>
{% else %}
<li><a href="{{ url_for('auth.login') }}">Sign In</a></li>
<li><a href="{{ url_for('auth.signup') }}" role="button">Get Started Free</a></li>
{% endif %}
</ul>
</nav>
<!-- Flash messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="container">
{% for category, message in messages %}
<article
style="padding: 1rem; margin-bottom: 1rem;
{% if category == 'error' %}border-left: 4px solid var(--pico-color-red-500);
{% elif category == 'success' %}border-left: 4px solid var(--pico-color-green-500);
{% elif category == 'warning' %}border-left: 4px solid var(--pico-color-amber-500);
{% else %}border-left: 4px solid var(--pico-primary);{% endif %}"
>
{{ message }}
</article>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
<!-- Footer -->
<footer class="container" style="margin-top: 4rem; padding: 2rem 0; border-top: 1px solid var(--pico-muted-border-color);">
<div class="grid">
<div>
<strong>{{ config.APP_NAME }}</strong>
<p><small>Plan, finance, and build your padel business.</small></p>
</div>
<div>
<strong>Product</strong>
<ul style="list-style: none; padding: 0;">
<li><a href="{{ url_for('planner.index') }}">Planner</a></li>
<li><a href="{{ url_for('leads.suppliers') }}">Suppliers</a></li>
<li><a href="{{ url_for('leads.financing') }}">Financing</a></li>
</ul>
</div>
<div>
<strong>Legal</strong>
<ul style="list-style: none; padding: 0;">
<li><a href="{{ url_for('public.terms') }}">Terms</a></li>
<li><a href="{{ url_for('public.privacy') }}">Privacy</a></li>
<li><a href="{{ url_for('public.about') }}">About</a></li>
</ul>
</div>
</div>
<p style="text-align: center; margin-top: 2rem;">
<small>&copy; {{ now.year }} {{ config.APP_NAME }}. All rights reserved.</small>
</p>
</footer>
<!-- HTMX -->
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script>
// CSRF token for HTMX requests
document.body.addEventListener('htmx:configRequest', function(e) {
const csrfMeta = document.querySelector('input[name="csrf_token"]');
if (csrfMeta) e.detail.headers['X-CSRF-Token'] = csrfMeta.value;
});
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,238 @@
"""
Background task worker - SQLite-based queue (no Redis needed).
"""
import asyncio
import json
import traceback
from datetime import datetime, timedelta
from .core import config, init_db, fetch_one, fetch_all, execute, send_email
# Task handlers registry
HANDLERS: dict[str, callable] = {}
def task(name: str):
"""Decorator to register a task handler."""
def decorator(f):
HANDLERS[name] = f
return f
return decorator
# =============================================================================
# Task Queue Operations
# =============================================================================
async def enqueue(task_name: str, payload: dict = None, run_at: datetime = None) -> int:
"""Add a task to the queue."""
return await execute(
"""
INSERT INTO tasks (task_name, payload, status, run_at, created_at)
VALUES (?, ?, 'pending', ?, ?)
""",
(
task_name,
json.dumps(payload or {}),
(run_at or datetime.utcnow()).isoformat(),
datetime.utcnow().isoformat(),
)
)
async def get_pending_tasks(limit: int = 10) -> list[dict]:
"""Get pending tasks ready to run."""
now = datetime.utcnow().isoformat()
return await fetch_all(
"""
SELECT * FROM tasks
WHERE status = 'pending' AND run_at <= ?
ORDER BY run_at ASC
LIMIT ?
""",
(now, limit)
)
async def mark_complete(task_id: int) -> None:
"""Mark task as completed."""
await execute(
"UPDATE tasks SET status = 'complete', completed_at = ? WHERE id = ?",
(datetime.utcnow().isoformat(), task_id)
)
async def mark_failed(task_id: int, error: str, retries: int) -> None:
"""Mark task as failed, schedule retry if attempts remain."""
max_retries = 3
if retries < max_retries:
# Exponential backoff: 1min, 5min, 25min
delay = timedelta(minutes=5 ** retries)
run_at = datetime.utcnow() + delay
await execute(
"""
UPDATE tasks
SET status = 'pending', error = ?, retries = ?, run_at = ?
WHERE id = ?
""",
(error, retries + 1, run_at.isoformat(), task_id)
)
else:
await execute(
"UPDATE tasks SET status = 'failed', error = ? WHERE id = ?",
(error, task_id)
)
# =============================================================================
# Built-in Task Handlers
# =============================================================================
@task("send_email")
async def handle_send_email(payload: dict) -> None:
"""Send an email."""
await send_email(
to=payload["to"],
subject=payload["subject"],
html=payload["html"],
text=payload.get("text"),
)
@task("send_magic_link")
async def handle_send_magic_link(payload: dict) -> None:
"""Send magic link email."""
link = f"{config.BASE_URL}/auth/verify?token={payload['token']}"
html = f"""
<h2>Sign in to {config.APP_NAME}</h2>
<p>Click the link below to sign in:</p>
<p><a href="{link}">{link}</a></p>
<p>This link expires in {config.MAGIC_LINK_EXPIRY_MINUTES} minutes.</p>
<p>If you didn't request this, you can safely ignore this email.</p>
"""
await send_email(
to=payload["email"],
subject=f"Sign in to {config.APP_NAME}",
html=html,
)
@task("send_welcome")
async def handle_send_welcome(payload: dict) -> None:
"""Send welcome email to new user."""
html = f"""
<h2>Welcome to {config.APP_NAME}!</h2>
<p>Thanks for signing up. We're excited to have you.</p>
<p><a href="{config.BASE_URL}/dashboard">Go to your dashboard</a></p>
"""
await send_email(
to=payload["email"],
subject=f"Welcome to {config.APP_NAME}",
html=html,
)
@task("cleanup_expired_tokens")
async def handle_cleanup_tokens(payload: dict) -> None:
"""Clean up expired auth tokens."""
await execute(
"DELETE FROM auth_tokens WHERE expires_at < ?",
(datetime.utcnow().isoformat(),)
)
@task("cleanup_rate_limits")
async def handle_cleanup_rate_limits(payload: dict) -> None:
"""Clean up old rate limit entries."""
cutoff = (datetime.utcnow() - timedelta(hours=1)).isoformat()
await execute("DELETE FROM rate_limits WHERE timestamp < ?", (cutoff,))
@task("cleanup_old_tasks")
async def handle_cleanup_tasks(payload: dict) -> None:
"""Clean up completed/failed tasks older than 7 days."""
cutoff = (datetime.utcnow() - timedelta(days=7)).isoformat()
await execute(
"DELETE FROM tasks WHERE status IN ('complete', 'failed') AND created_at < ?",
(cutoff,)
)
# =============================================================================
# Worker Loop
# =============================================================================
async def process_task(task: dict) -> None:
"""Process a single task."""
task_name = task["task_name"]
task_id = task["id"]
retries = task.get("retries", 0)
handler = HANDLERS.get(task_name)
if not handler:
await mark_failed(task_id, f"Unknown task: {task_name}", retries)
return
try:
payload = json.loads(task["payload"]) if task["payload"] else {}
await handler(payload)
await mark_complete(task_id)
print(f"[WORKER] Completed: {task_name} (id={task_id})")
except Exception as e:
error = f"{e}\n{traceback.format_exc()}"
await mark_failed(task_id, error, retries)
print(f"[WORKER] Failed: {task_name} (id={task_id}): {e}")
async def run_worker(poll_interval: float = 1.0) -> None:
"""Main worker loop."""
print("[WORKER] Starting...")
await init_db()
while True:
try:
tasks = await get_pending_tasks(limit=10)
for task in tasks:
await process_task(task)
if not tasks:
await asyncio.sleep(poll_interval)
except Exception as e:
print(f"[WORKER] Error: {e}")
await asyncio.sleep(poll_interval * 5)
async def run_scheduler() -> None:
"""Schedule periodic cleanup tasks."""
print("[SCHEDULER] Starting...")
await init_db()
while True:
try:
# Schedule cleanup tasks every hour
await enqueue("cleanup_expired_tokens")
await enqueue("cleanup_rate_limits")
await enqueue("cleanup_old_tasks")
await asyncio.sleep(3600) # 1 hour
except Exception as e:
print(f"[SCHEDULER] Error: {e}")
await asyncio.sleep(60)
if __name__ == "__main__":
import sys
if len(sys.argv) > 1 and sys.argv[1] == "scheduler":
asyncio.run(run_scheduler())
else:
asyncio.run(run_worker())

477
padelnomics/uv.lock generated Normal file
View File

@@ -0,0 +1,477 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "aiofiles"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" },
]
[[package]]
name = "aiosqlite"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
]
[[package]]
name = "anyio"
version = "4.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
]
[[package]]
name = "certifi"
version = "2026.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "flask"
version = "3.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "blinker" },
{ name = "click" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "markupsafe" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "h2"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "hpack" },
{ name = "hyperframe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
]
[[package]]
name = "hpack"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "hypercorn"
version = "0.18.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "h11" },
{ name = "h2" },
{ name = "priority" },
{ name = "wsproto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/44/01/39f41a014b83dd5c795217362f2ca9071cf243e6a75bdcd6cd5b944658cc/hypercorn-0.18.0.tar.gz", hash = "sha256:d63267548939c46b0247dc8e5b45a9947590e35e64ee73a23c074aa3cf88e9da", size = 68420, upload-time = "2025-11-08T13:54:04.78Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/35/850277d1b17b206bd10874c8a9a3f52e059452fb49bb0d22cbb908f6038b/hypercorn-0.18.0-py3-none-any.whl", hash = "sha256:225e268f2c1c2f28f6d8f6db8f40cb8c992963610c5725e13ccfcddccb24b1cd", size = 61640, upload-time = "2025-11-08T13:54:03.202Z" },
]
[[package]]
name = "hyperframe"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
{ url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
{ url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
{ url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" },
{ url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" },
{ url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" },
{ url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" },
{ url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" },
{ url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" },
{ url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" },
{ url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" },
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "padelnomics"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },
{ name = "httpx" },
{ name = "hypercorn" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "python-dotenv" },
{ name = "quart" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "aiosqlite", specifier = ">=0.19.0" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "hypercorn", specifier = ">=0.17.0" },
{ name = "itsdangerous", specifier = ">=2.1.0" },
{ name = "jinja2", specifier = ">=3.1.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "quart", specifier = ">=0.19.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.23.0" },
{ name = "ruff", specifier = ">=0.3.0" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "priority"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/3c/eb7c35f4dcede96fca1842dac5f4f5d15511aa4b52f3a961219e68ae9204/priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0", size = 24792, upload-time = "2021-06-27T10:15:05.487Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/5f/82c8074f7e84978129347c2c6ec8b6c59f3584ff1a20bc3c940a3e061790/priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa", size = 8946, upload-time = "2021-06-27T10:15:03.856Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
]
[[package]]
name = "quart"
version = "0.20.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
{ name = "blinker" },
{ name = "click" },
{ name = "flask" },
{ name = "hypercorn" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "markupsafe" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1d/9d/12e1143a5bd2ccc05c293a6f5ae1df8fd94a8fc1440ecc6c344b2b30ce13/quart-0.20.0.tar.gz", hash = "sha256:08793c206ff832483586f5ae47018c7e40bdd75d886fee3fabbdaa70c2cf505d", size = 63874, upload-time = "2024-12-23T13:53:05.664Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/e9/cc28f21f52913adf333f653b9e0a3bf9cb223f5083a26422968ba73edd8d/quart-0.20.0-py3-none-any.whl", hash = "sha256:003c08f551746710acb757de49d9b768986fd431517d0eb127380b656b98b8f1", size = 77960, upload-time = "2024-12-23T13:53:02.842Z" },
]
[[package]]
name = "ruff"
version = "0.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" },
{ url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" },
{ url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" },
{ url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" },
{ url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" },
{ url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" },
{ url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" },
{ url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" },
{ url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" },
{ url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" },
{ url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" },
{ url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" },
{ url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" },
{ url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" },
{ url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" },
{ url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" },
{ url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "werkzeug"
version = "3.1.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/70/1469ef1d3542ae7c2c7b72bd5e3a4e6ee69d7978fa8a3af05a38eca5becf/werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67", size = 864754, upload-time = "2026-01-08T17:49:23.247Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/e4/8d97cca767bcc1be76d16fb76951608305561c6e056811587f36cb1316a8/werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc", size = 225025, upload-time = "2026-01-08T17:49:21.859Z" },
]
[[package]]
name = "wsproto"
version = "1.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" },
]