From c6ce001aae692b37941e2d624bb88801b2ada01c Mon Sep 17 00:00:00 2001 From: Deeman Date: Wed, 11 Feb 2026 14:53:09 +0100 Subject: [PATCH] initial commit --- .copier-answers.yml | 10 + .gitignore | 1 + README.md | 92 +++ padelnomics/.dockerignore | 17 + padelnomics/.env.example | 22 + padelnomics/.gitignore | 37 + padelnomics/.python-version | 1 + padelnomics/Caddyfile | 17 + padelnomics/Dockerfile | 22 + padelnomics/README.md | 140 ++++ padelnomics/docker-compose.yml | 70 ++ padelnomics/litestream.yml | 22 + padelnomics/pyproject.toml | 41 + padelnomics/scripts/backup.sh | 23 + padelnomics/scripts/deploy.sh | 22 + padelnomics/scripts/smoke-test.sh | 83 ++ padelnomics/src/padelnomics/__init__.py | 3 + padelnomics/src/padelnomics/admin/routes.py | 318 ++++++++ .../padelnomics/admin/templates/index.html | 124 +++ .../padelnomics/admin/templates/login.html | 30 + .../padelnomics/admin/templates/tasks.html | 106 +++ .../admin/templates/user_detail.html | 73 ++ .../padelnomics/admin/templates/users.html | 83 ++ padelnomics/src/padelnomics/app.py | 107 +++ padelnomics/src/padelnomics/auth/routes.py | 278 +++++++ .../src/padelnomics/auth/templates/login.html | 39 + .../auth/templates/magic_link_sent.html | 35 + .../padelnomics/auth/templates/signup.html | 41 + padelnomics/src/padelnomics/billing/routes.py | 19 + .../billing/templates/pricing.html | 47 ++ .../billing/templates/success.html | 19 + padelnomics/src/padelnomics/core.py | 304 +++++++ .../src/padelnomics/dashboard/routes.py | 67 ++ .../dashboard/templates/index.html | 40 + .../dashboard/templates/settings.html | 48 ++ padelnomics/src/padelnomics/leads/routes.py | 105 +++ .../leads/templates/financing.html | 36 + .../leads/templates/suppliers.html | 36 + .../src/padelnomics/migrations/migrate.py | 53 ++ .../src/padelnomics/migrations/schema.sql | 84 ++ padelnomics/src/padelnomics/planner/routes.py | 138 ++++ .../templates/partials/scenario_list.html | 26 + .../planner/templates/planner.html | 197 +++++ padelnomics/src/padelnomics/public/routes.py | 58 ++ .../padelnomics/public/templates/about.html | 35 + .../public/templates/features.html | 82 ++ .../padelnomics/public/templates/landing.html | 110 +++ .../padelnomics/public/templates/privacy.html | 92 +++ .../padelnomics/public/templates/terms.html | 71 ++ .../src/padelnomics/static/css/custom.css | 54 ++ .../src/padelnomics/static/css/planner.css | 599 ++++++++++++++ .../src/padelnomics/static/js/planner.js | 746 ++++++++++++++++++ .../src/padelnomics/templates/base.html | 107 +++ padelnomics/src/padelnomics/worker.py | 238 ++++++ padelnomics/uv.lock | 477 +++++++++++ 55 files changed, 5745 insertions(+) create mode 100644 .copier-answers.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 padelnomics/.dockerignore create mode 100644 padelnomics/.env.example create mode 100644 padelnomics/.gitignore create mode 100644 padelnomics/.python-version create mode 100644 padelnomics/Caddyfile create mode 100644 padelnomics/Dockerfile create mode 100644 padelnomics/README.md create mode 100644 padelnomics/docker-compose.yml create mode 100644 padelnomics/litestream.yml create mode 100644 padelnomics/pyproject.toml create mode 100644 padelnomics/scripts/backup.sh create mode 100644 padelnomics/scripts/deploy.sh create mode 100755 padelnomics/scripts/smoke-test.sh create mode 100644 padelnomics/src/padelnomics/__init__.py create mode 100644 padelnomics/src/padelnomics/admin/routes.py create mode 100644 padelnomics/src/padelnomics/admin/templates/index.html create mode 100644 padelnomics/src/padelnomics/admin/templates/login.html create mode 100644 padelnomics/src/padelnomics/admin/templates/tasks.html create mode 100644 padelnomics/src/padelnomics/admin/templates/user_detail.html create mode 100644 padelnomics/src/padelnomics/admin/templates/users.html create mode 100644 padelnomics/src/padelnomics/app.py create mode 100644 padelnomics/src/padelnomics/auth/routes.py create mode 100644 padelnomics/src/padelnomics/auth/templates/login.html create mode 100644 padelnomics/src/padelnomics/auth/templates/magic_link_sent.html create mode 100644 padelnomics/src/padelnomics/auth/templates/signup.html create mode 100644 padelnomics/src/padelnomics/billing/routes.py create mode 100644 padelnomics/src/padelnomics/billing/templates/pricing.html create mode 100644 padelnomics/src/padelnomics/billing/templates/success.html create mode 100644 padelnomics/src/padelnomics/core.py create mode 100644 padelnomics/src/padelnomics/dashboard/routes.py create mode 100644 padelnomics/src/padelnomics/dashboard/templates/index.html create mode 100644 padelnomics/src/padelnomics/dashboard/templates/settings.html create mode 100644 padelnomics/src/padelnomics/leads/routes.py create mode 100644 padelnomics/src/padelnomics/leads/templates/financing.html create mode 100644 padelnomics/src/padelnomics/leads/templates/suppliers.html create mode 100644 padelnomics/src/padelnomics/migrations/migrate.py create mode 100644 padelnomics/src/padelnomics/migrations/schema.sql create mode 100644 padelnomics/src/padelnomics/planner/routes.py create mode 100644 padelnomics/src/padelnomics/planner/templates/partials/scenario_list.html create mode 100644 padelnomics/src/padelnomics/planner/templates/planner.html create mode 100644 padelnomics/src/padelnomics/public/routes.py create mode 100644 padelnomics/src/padelnomics/public/templates/about.html create mode 100644 padelnomics/src/padelnomics/public/templates/features.html create mode 100644 padelnomics/src/padelnomics/public/templates/landing.html create mode 100644 padelnomics/src/padelnomics/public/templates/privacy.html create mode 100644 padelnomics/src/padelnomics/public/templates/terms.html create mode 100644 padelnomics/src/padelnomics/static/css/custom.css create mode 100644 padelnomics/src/padelnomics/static/css/planner.css create mode 100644 padelnomics/src/padelnomics/static/js/planner.js create mode 100644 padelnomics/src/padelnomics/templates/base.html create mode 100644 padelnomics/src/padelnomics/worker.py create mode 100644 padelnomics/uv.lock diff --git a/.copier-answers.yml b/.copier-answers.yml new file mode 100644 index 0000000..07c7238 --- /dev/null +++ b/.copier-answers.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ceb2b98 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +CLAUDE.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..c577f50 --- /dev/null +++ b/README.md @@ -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 diff --git a/padelnomics/.dockerignore b/padelnomics/.dockerignore new file mode 100644 index 0000000..31cc5f8 --- /dev/null +++ b/padelnomics/.dockerignore @@ -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/ diff --git a/padelnomics/.env.example b/padelnomics/.env.example new file mode 100644 index 0000000..a8fa40f --- /dev/null +++ b/padelnomics/.env.example @@ -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 diff --git a/padelnomics/.gitignore b/padelnomics/.gitignore new file mode 100644 index 0000000..cea4fff --- /dev/null +++ b/padelnomics/.gitignore @@ -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/ diff --git a/padelnomics/.python-version b/padelnomics/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/padelnomics/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/padelnomics/Caddyfile b/padelnomics/Caddyfile new file mode 100644 index 0000000..7da363f --- /dev/null +++ b/padelnomics/Caddyfile @@ -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 + } +} diff --git a/padelnomics/Dockerfile b/padelnomics/Dockerfile new file mode 100644 index 0000000..9d84920 --- /dev/null +++ b/padelnomics/Dockerfile @@ -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"] diff --git a/padelnomics/README.md b/padelnomics/README.md new file mode 100644 index 0000000..227a169 --- /dev/null +++ b/padelnomics/README.md @@ -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 diff --git a/padelnomics/docker-compose.yml b/padelnomics/docker-compose.yml new file mode 100644 index 0000000..966fd25 --- /dev/null +++ b/padelnomics/docker-compose.yml @@ -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: diff --git a/padelnomics/litestream.yml b/padelnomics/litestream.yml new file mode 100644 index 0000000..a76bafd --- /dev/null +++ b/padelnomics/litestream.yml @@ -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 diff --git a/padelnomics/pyproject.toml b/padelnomics/pyproject.toml new file mode 100644 index 0000000..5c184f6 --- /dev/null +++ b/padelnomics/pyproject.toml @@ -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"] diff --git a/padelnomics/scripts/backup.sh b/padelnomics/scripts/backup.sh new file mode 100644 index 0000000..1b8d4d7 --- /dev/null +++ b/padelnomics/scripts/backup.sh @@ -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" diff --git a/padelnomics/scripts/deploy.sh b/padelnomics/scripts/deploy.sh new file mode 100644 index 0000000..0e5bb5d --- /dev/null +++ b/padelnomics/scripts/deploy.sh @@ -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!" diff --git a/padelnomics/scripts/smoke-test.sh b/padelnomics/scripts/smoke-test.sh new file mode 100755 index 0000000..c7aa565 --- /dev/null +++ b/padelnomics/scripts/smoke-test.sh @@ -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 diff --git a/padelnomics/src/padelnomics/__init__.py b/padelnomics/src/padelnomics/__init__.py new file mode 100644 index 0000000..e40d8d0 --- /dev/null +++ b/padelnomics/src/padelnomics/__init__.py @@ -0,0 +1,3 @@ +"""Padelnomics - Plan, finance, and build your padel business""" + +__version__ = "0.1.0" diff --git a/padelnomics/src/padelnomics/admin/routes.py b/padelnomics/src/padelnomics/admin/routes.py new file mode 100644 index 0000000..67afad9 --- /dev/null +++ b/padelnomics/src/padelnomics/admin/routes.py @@ -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/") +@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//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//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//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")) diff --git a/padelnomics/src/padelnomics/admin/templates/index.html b/padelnomics/src/padelnomics/admin/templates/index.html new file mode 100644 index 0000000..8c25d87 --- /dev/null +++ b/padelnomics/src/padelnomics/admin/templates/index.html @@ -0,0 +1,124 @@ +{% extends "base.html" %} + +{% block title %}Admin Dashboard - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+
+

Admin Dashboard

+ {% if session.get('admin_impersonating') %} + Currently impersonating a user +
+ + +
+ {% endif %} +
+
+ + +
+
+ + +
+
+
Total Users
+

{{ stats.users_total }}

+ +{{ stats.users_today }} today, +{{ stats.users_week }} this week +
+ +
+
Active Subscriptions
+

{{ stats.active_subscriptions }}

+
+ +
+
Task Queue
+

{{ stats.tasks_pending }} pending

+ {% if stats.tasks_failed > 0 %} + {{ stats.tasks_failed }} failed + {% else %} + 0 failed + {% endif %} +
+
+ + + + +
+ +
+

Recent Users

+
+ {% if recent_users %} + + + + + + + + + + {% for u in recent_users %} + + + + + + {% endfor %} + +
EmailPlanJoined
+ {{ u.email }} + {{ u.plan or 'free' }}{{ u.created_at[:10] }}
+ View all โ†’ + {% else %} +

No users yet.

+ {% endif %} +
+
+ + +
+

Failed Tasks

+
+ {% if failed_tasks %} + + + + + + + + + + {% for task in failed_tasks[:5] %} + + + + + + {% endfor %} + +
TaskError
{{ task.task_name }}{{ task.error[:50] }}... +
+ + +
+
+ View all โ†’ + {% else %} +

โœ“ No failed tasks

+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/padelnomics/src/padelnomics/admin/templates/login.html b/padelnomics/src/padelnomics/admin/templates/login.html new file mode 100644 index 0000000..6de0942 --- /dev/null +++ b/padelnomics/src/padelnomics/admin/templates/login.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} + +{% block title %}Admin Login - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+
+

Admin Login

+
+ +
+ + + + + +
+
+
+{% endblock %} diff --git a/padelnomics/src/padelnomics/admin/templates/tasks.html b/padelnomics/src/padelnomics/admin/templates/tasks.html new file mode 100644 index 0000000..e72af3f --- /dev/null +++ b/padelnomics/src/padelnomics/admin/templates/tasks.html @@ -0,0 +1,106 @@ +{% extends "base.html" %} + +{% block title %}Tasks - Admin - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+

Task Queue

+ โ† Dashboard +
+ + + {% if failed_tasks %} +
+

Failed Tasks ({{ failed_tasks | length }})

+
+ + + + + + + + + + + + + {% for task in failed_tasks %} + + + + + + + + + {% endfor %} + +
IDTaskErrorRetriesCreated
{{ task.id }}{{ task.task_name }} +
+ {{ task.error[:40] if task.error else 'No error' }}... +
{{ task.error }}
+
+
{{ task.retries }}{{ task.created_at[:16] }} +
+
+ + +
+
+ + +
+
+
+
+
+ {% endif %} + + +
+

Recent Tasks

+
+ {% if tasks %} + + + + + + + + + + + + + {% for task in tasks %} + + + + + + + + + {% endfor %} + +
IDTaskStatusRun AtCreatedCompleted
{{ task.id }}{{ task.task_name }} + {% if task.status == 'complete' %} + โœ“ complete + {% elif task.status == 'failed' %} + โœ— failed + {% elif task.status == 'pending' %} + โ—‹ pending + {% else %} + {{ task.status }} + {% endif %} + {{ task.run_at[:16] if task.run_at else '-' }}{{ task.created_at[:16] }}{{ task.completed_at[:16] if task.completed_at else '-' }}
+ {% else %} +

No tasks in queue.

+ {% endif %} +
+
+
+{% endblock %} diff --git a/padelnomics/src/padelnomics/admin/templates/user_detail.html b/padelnomics/src/padelnomics/admin/templates/user_detail.html new file mode 100644 index 0000000..694caac --- /dev/null +++ b/padelnomics/src/padelnomics/admin/templates/user_detail.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} + +{% block title %}User: {{ user.email }} - Admin - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+

{{ user.email }}

+ โ† Users +
+ +
+ +
+

User Info

+
+
ID
+
{{ user.id }}
+ +
Email
+
{{ user.email }}
+ +
Name
+
{{ user.name or '-' }}
+ +
Created
+
{{ user.created_at }}
+ +
Last Login
+
{{ user.last_login_at or 'Never' }}
+
+
+ + +
+

Subscription

+
+
Plan
+
+ {% if user.plan %} + {{ user.plan }} + {% else %} + free + {% endif %} +
+ +
Status
+
{{ user.sub_status or 'N/A' }}
+ + {% if user.stripe_customer_id %} +
Stripe Customer
+
+ + {{ user.stripe_customer_id }} + +
+ {% endif %} +
+
+
+ + +
+

Actions

+
+
+ + +
+
+
+
+{% endblock %} diff --git a/padelnomics/src/padelnomics/admin/templates/users.html b/padelnomics/src/padelnomics/admin/templates/users.html new file mode 100644 index 0000000..15cd27b --- /dev/null +++ b/padelnomics/src/padelnomics/admin/templates/users.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} + +{% block title %}Users - Admin - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+

Users

+ โ† Dashboard +
+ + +
+
+ + +
+
+ + +
+ {% if users %} + + + + + + + + + + + + + + {% for u in users %} + + + + + + + + + + {% endfor %} + +
IDEmailNamePlanJoinedLast Login
{{ u.id }}{{ u.email }}{{ u.name or '-' }} + {% if u.plan %} + {{ u.plan }} + {% else %} + free + {% endif %} + {{ u.created_at[:10] }}{{ u.last_login_at[:10] if u.last_login_at else 'Never' }} +
+ + +
+
+ + +
+ {% if page > 1 %} + โ† Previous + {% endif %} + Page {{ page }} + {% if users | length == 50 %} + Next โ†’ + {% endif %} +
+ {% else %} +

No users found.

+ {% endif %} +
+
+{% endblock %} diff --git a/padelnomics/src/padelnomics/app.py b/padelnomics/src/padelnomics/app.py new file mode 100644 index 0000000..6fde9f3 --- /dev/null +++ b/padelnomics/src/padelnomics/app.py @@ -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) diff --git a/padelnomics/src/padelnomics/auth/routes.py b/padelnomics/src/padelnomics/auth/routes.py new file mode 100644 index 0000000..bb0eebd --- /dev/null +++ b/padelnomics/src/padelnomics/auth/routes.py @@ -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)) diff --git a/padelnomics/src/padelnomics/auth/templates/login.html b/padelnomics/src/padelnomics/auth/templates/login.html new file mode 100644 index 0000000..89b9964 --- /dev/null +++ b/padelnomics/src/padelnomics/auth/templates/login.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} + +{% block title %}Sign In - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+
+

Sign In

+

Enter your email to receive a sign-in link.

+
+ +
+ + + + + +
+ +
+ + Don't have an account? + Sign up + +
+
+
+{% endblock %} diff --git a/padelnomics/src/padelnomics/auth/templates/magic_link_sent.html b/padelnomics/src/padelnomics/auth/templates/magic_link_sent.html new file mode 100644 index 0000000..b733b1b --- /dev/null +++ b/padelnomics/src/padelnomics/auth/templates/magic_link_sent.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} + +{% block title %}Check Your Email - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+
+

Check Your Email

+
+ +

We've sent a sign-in link to:

+

{{ email }}

+ +

Click the link in the email to sign in. The link expires in {{ config.MAGIC_LINK_EXPIRY_MINUTES }} minutes.

+ +
+ +
+ Didn't receive the email? +
    +
  • Check your spam folder
  • +
  • Make sure the email address is correct
  • +
  • Wait a minute and try again
  • +
+ +
+ + + +
+
+
+
+{% endblock %} diff --git a/padelnomics/src/padelnomics/auth/templates/signup.html b/padelnomics/src/padelnomics/auth/templates/signup.html new file mode 100644 index 0000000..4ddd6ec --- /dev/null +++ b/padelnomics/src/padelnomics/auth/templates/signup.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} + +{% block title %}Sign Up - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+
+

Create Free Account

+

Save your padel business plan, get supplier quotes, and find financing.

+
+ +
+ + + + + No credit card required. Full access to all features. + + +
+ +
+ + Already have an account? + Sign in + +
+
+
+{% endblock %} diff --git a/padelnomics/src/padelnomics/billing/routes.py b/padelnomics/src/padelnomics/billing/routes.py new file mode 100644 index 0000000..836f5ac --- /dev/null +++ b/padelnomics/src/padelnomics/billing/routes.py @@ -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") diff --git a/padelnomics/src/padelnomics/billing/templates/pricing.html b/padelnomics/src/padelnomics/billing/templates/pricing.html new file mode 100644 index 0000000..2becf23 --- /dev/null +++ b/padelnomics/src/padelnomics/billing/templates/pricing.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} +{% block title %}Free Financial Planner - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+

100% Free. No Catch.

+

The most sophisticated padel court financial planner available โ€” completely free. Plan your investment with 60+ variables, sensitivity analysis, and professional-grade projections.

+
+ +
+
+
Financial Planner
+

Free โ€” forever

+
    +
  • 60+ adjustable variables
  • +
  • 6 analysis tabs (CAPEX, Operating, Cash Flow, Returns, Metrics)
  • +
  • Sensitivity analysis (utilization + pricing)
  • +
  • Save unlimited scenarios
  • +
  • Interactive charts
  • +
  • Indoor/outdoor & rent/buy models
  • +
+ {% if user %} + Open Planner + {% else %} + Create Free Account + {% endif %} +
+ +
+
Need Help Building?
+

We connect you with verified partners

+
    +
  • Court supplier quotes
  • +
  • Financing & bank connections
  • +
  • Construction planning
  • +
  • Equipment sourcing
  • +
+ {% if user %} + Get Supplier Quotes + {% else %} + Sign Up to Get Started + {% endif %} +
+
+
+{% endblock %} diff --git a/padelnomics/src/padelnomics/billing/templates/success.html b/padelnomics/src/padelnomics/billing/templates/success.html new file mode 100644 index 0000000..9b9bbc8 --- /dev/null +++ b/padelnomics/src/padelnomics/billing/templates/success.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} + +{% block title %}Welcome - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+
+

Welcome to Padelnomics!

+
+ +

Your account is ready. Start planning your padel court investment with our financial planner.

+ +

+ Open Planner +

+
+
+{% endblock %} diff --git a/padelnomics/src/padelnomics/core.py b/padelnomics/src/padelnomics/core.py new file mode 100644 index 0000000..2bef81b --- /dev/null +++ b/padelnomics/src/padelnomics/core.py @@ -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,) + ) diff --git a/padelnomics/src/padelnomics/dashboard/routes.py b/padelnomics/src/padelnomics/dashboard/routes.py new file mode 100644 index 0000000..93b713f --- /dev/null +++ b/padelnomics/src/padelnomics/dashboard/routes.py @@ -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")) diff --git a/padelnomics/src/padelnomics/dashboard/templates/index.html b/padelnomics/src/padelnomics/dashboard/templates/index.html new file mode 100644 index 0000000..0132732 --- /dev/null +++ b/padelnomics/src/padelnomics/dashboard/templates/index.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}Dashboard - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+

Dashboard

+

Welcome back{% if user.name %}, {{ user.name }}{% endif %}!

+
+ +
+
+
Saved Scenarios
+

{{ stats.scenarios }}

+ No limits +
+ +
+
Lead Requests
+

{{ stats.leads }}

+ Supplier & financing inquiries +
+ +
+
Plan
+

Free

+ Full access to all features +
+
+ +
+

Quick Actions

+ +
+
+{% endblock %} diff --git a/padelnomics/src/padelnomics/dashboard/templates/settings.html b/padelnomics/src/padelnomics/dashboard/templates/settings.html new file mode 100644 index 0000000..04c1234 --- /dev/null +++ b/padelnomics/src/padelnomics/dashboard/templates/settings.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block title %}Settings - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+

Settings

+
+ +
+

Profile

+
+
+ + + + + + + +
+
+
+ +
+

Danger Zone

+
+

Once you delete your account, there is no going back.

+ +
+ Delete Account +

This will delete all your scenarios and data permanently.

+
+ + +
+
+
+
+
+{% endblock %} diff --git a/padelnomics/src/padelnomics/leads/routes.py b/padelnomics/src/padelnomics/leads/routes.py new file mode 100644 index 0000000..f1c9153 --- /dev/null +++ b/padelnomics/src/padelnomics/leads/routes.py @@ -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"

Location: {form.get('location')}
Courts: {form.get('court_count')}
Budget: {form.get('budget')}
Message: {form.get('message')}

", + ) + 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"

Location: {form.get('location')}
Courts: {form.get('court_count')}
Budget: {form.get('budget')}
Message: {form.get('message')}

", + ) + 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) diff --git a/padelnomics/src/padelnomics/leads/templates/financing.html b/padelnomics/src/padelnomics/leads/templates/financing.html new file mode 100644 index 0000000..8e41740 --- /dev/null +++ b/padelnomics/src/padelnomics/leads/templates/financing.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% block title %}Find Financing - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+

Find Financing for Your Padel Project

+

We work with banks and investors experienced in sports facility financing. Tell us about your project and we'll make introductions.

+
+ +
+
+ + + + + + + + + + + +
+
+
+{% endblock %} diff --git a/padelnomics/src/padelnomics/leads/templates/suppliers.html b/padelnomics/src/padelnomics/leads/templates/suppliers.html new file mode 100644 index 0000000..d29ffde --- /dev/null +++ b/padelnomics/src/padelnomics/leads/templates/suppliers.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% block title %}Get Court Supplier Quotes - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+

Get Court Supplier Quotes

+

Tell us about your project and we'll connect you with verified padel court suppliers who can provide detailed quotes.

+
+ +
+
+ + + + + + + + + + + +
+
+
+{% endblock %} diff --git a/padelnomics/src/padelnomics/migrations/migrate.py b/padelnomics/src/padelnomics/migrations/migrate.py new file mode 100644 index 0000000..05aee3d --- /dev/null +++ b/padelnomics/src/padelnomics/migrations/migrate.py @@ -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() diff --git a/padelnomics/src/padelnomics/migrations/schema.sql b/padelnomics/src/padelnomics/migrations/schema.sql new file mode 100644 index 0000000..37337e1 --- /dev/null +++ b/padelnomics/src/padelnomics/migrations/schema.sql @@ -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); diff --git a/padelnomics/src/padelnomics/planner/routes.py b/padelnomics/src/padelnomics/planner/routes.py new file mode 100644 index 0000000..15ccc8d --- /dev/null +++ b/padelnomics/src/padelnomics/planner/routes.py @@ -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/", 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/", 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//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}) diff --git a/padelnomics/src/padelnomics/planner/templates/partials/scenario_list.html b/padelnomics/src/padelnomics/planner/templates/partials/scenario_list.html new file mode 100644 index 0000000..354df81 --- /dev/null +++ b/padelnomics/src/padelnomics/planner/templates/partials/scenario_list.html @@ -0,0 +1,26 @@ +
+

My Scenarios

+ +
+{% if scenarios %} + {% for s in scenarios %} +
+
+
{{ s.name }}
+
+ {% if s.is_default %}default{% endif %} + + +
+
+ {% if s.location %}
{{ s.location }}
{% endif %} +
Updated {{ s.updated_at[:10] if s.updated_at else s.created_at[:10] }}
+
+ {% endfor %} +{% else %} +

No saved scenarios yet. Use the Save button to store your current plan.

+{% endif %} diff --git a/padelnomics/src/padelnomics/planner/templates/planner.html b/padelnomics/src/padelnomics/planner/templates/planner.html new file mode 100644 index 0000000..5331c82 --- /dev/null +++ b/padelnomics/src/padelnomics/planner/templates/planner.html @@ -0,0 +1,197 @@ +{% extends "base.html" %} +{% block title %}Padel Court Financial Planner - {{ config.APP_NAME }}{% endblock %} + +{% block head %} + + +{% endblock %} + +{% block content %} +
+
+

Padel Court Financial Planner

+ v2.1 + + + {% if user %} +
+ + +
+ {% endif %} +
+ + + +
+ +
+
+
+
+

Venue Type

+ +
+ +
+
+
+

Court Configuration

+
+

Space Requirements

+
+
+
+
+

Pricing

Per court per hour
+
+
+
+

Utilization & Operations

+
+
+
+
+
+

Construction & CAPEX

Adjust per scenario
+
+
+
+

Monthly Operating Costs

+
+
+
+

Financing

+
+
+
+

Exit Assumptions

+
+
+
+
+
+ + +
+
+
+
+
CAPEX Breakdown
+
+
+
+ These are estimates. Get actual quotes from verified court suppliers. + Get Quotes +
+
+ + +
+
+
+
+
Monthly Revenue Build-Up (Ramp Period)
+
+
+
+
Stabilized Monthly P&L
+
+
+
+
+

Revenue Streams (Stabilized Month)

+
+
+
+

Monthly OpEx Breakdown

+
+
+
+

Outdoor Seasonality

+
+
+
+ + +
+
+
+
Monthly Net Cash Flow (60 Months)
+
+
+
+
Cumulative Cash Flow
+
+
+
+

Annual Summary

+
+
+
+ + +
+
+
+
+
Exit Valuation Waterfall
+
+
+
+
DSCR by Year
+
+
+
+
+

Utilization Sensitivity

+
+
+
+

Pricing Sensitivity (at target utilization)

+
+
+
+ Your project looks profitable. Ready to take the next step? + Get Started +
+
+ + +
+

Return Metrics

+

Revenue Efficiency

+

Cost & Margin

+

Debt & Coverage

+

Investment Efficiency

+

Operational

+
+
+ + + + +
+
+
+{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/padelnomics/src/padelnomics/public/routes.py b/padelnomics/src/padelnomics/public/routes.py new file mode 100644 index 0000000..88da225 --- /dev/null +++ b/padelnomics/src/padelnomics/public/routes.py @@ -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 = '\n' + xml += '\n' + for url in urls: + xml += f" {url}\n" + xml += "" + return Response(xml, content_type="application/xml") diff --git a/padelnomics/src/padelnomics/public/templates/about.html b/padelnomics/src/padelnomics/public/templates/about.html new file mode 100644 index 0000000..e35157c --- /dev/null +++ b/padelnomics/src/padelnomics/public/templates/about.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} + +{% block title %}About - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+
+

About {{ config.APP_NAME }}

+
+ +
+

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.

+ +

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.

+ +

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.

+ +

Why free?

+

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.

+ +

What's next

+

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.

+
+ +
+ {% if user %} + Open Planner + {% else %} + Create Free Account + {% endif %} +
+
+
+{% endblock %} diff --git a/padelnomics/src/padelnomics/public/templates/features.html b/padelnomics/src/padelnomics/public/templates/features.html new file mode 100644 index 0000000..d21a3ac --- /dev/null +++ b/padelnomics/src/padelnomics/public/templates/features.html @@ -0,0 +1,82 @@ +{% extends "base.html" %} + +{% block title %}Features - Padel Court Financial Planner | {{ config.APP_NAME }}{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
+

Everything You Need to Plan Your Padel Business

+

Professional-grade financial modeling, completely free.

+
+ +
+
+

60+ Variables

+

Every assumption is adjustable. Court costs, rent, hourly pricing, utilization curves, financing terms, exit multiples. Nothing is hard-coded — your model reflects your reality.

+
+ +
+

6 Analysis Tabs

+

Assumptions, Investment (CAPEX), Operating Model, Cash Flow, Returns & Exit, and Key Metrics. Each tab with interactive charts that update in real time as you adjust inputs.

+
+
+ +
+
+

Indoor & Outdoor

+

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.

+
+ +
+

Sensitivity Analysis

+

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.

+
+
+ +
+
+

Professional Metrics

+

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.

+
+ +
+

Save & Compare

+

Save unlimited scenarios. Test different locations, court counts, financing structures, and pricing strategies. Load and compare to find the optimal plan for your investment.

+
+
+ +
+
+

Detailed CAPEX Breakdown

+

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.

+
+ +
+

Operating Model

+

Peak and off-peak pricing with configurable hour splits. Monthly utilization ramp-up curves. Staff costs, maintenance, insurance, marketing, and utilities — all adjustable with sliders. Revenue from court rentals, coaching, equipment, and F&B.

+
+ +
+

Cash Flow & Financing

+

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.

+
+ +
+

Returns & Exit

+

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.

+
+
+ +
+ {% if user %} + Open Planner + {% else %} + Create Free Account + {% endif %} +
+
+{% endblock %} diff --git a/padelnomics/src/padelnomics/public/templates/landing.html b/padelnomics/src/padelnomics/public/templates/landing.html new file mode 100644 index 0000000..0e43f08 --- /dev/null +++ b/padelnomics/src/padelnomics/public/templates/landing.html @@ -0,0 +1,110 @@ +{% extends "base.html" %} + +{% block title %}Padelnomics - Padel Court Business Plan & ROI Calculator{% endblock %} + +{% block head %} + + + + + + +{% endblock %} + +{% block content %} +
+ +
+

Plan Your Padel Business
in Minutes, Not Months

+

+ The most sophisticated padel court financial planner available. Model your investment with 60+ variables, sensitivity analysis, and professional-grade projections. 100% free. +

+
+ {% if user %} + Open Planner + {% else %} + Create Free Account + {% endif %} + Learn More +
+
+ + +
+

From Idea to Operating Hall

+
+
+
Plan
+

Model your padel hall investment with our free financial planner. CAPEX, operating costs, cash flow, returns, sensitivity analysis.

+
+
+
Finance
+

Connect with banks and investors experienced in sports facility loans. Your planner data becomes your business case.

+
+
+
Build
+

Get quotes from verified court suppliers. Compare pricing, quality, and delivery timelines for your specific project.

+
+
+
Operate
+

Coming soon: analytics powered by real booking data, benchmarking against similar venues, optimization recommendations.

+
+
+
+ + +
+

Built for Serious Padel Entrepreneurs

+
+
+

60+ Variables

+

Every assumption is adjustable. Court costs, rent, pricing, utilization, financing terms, exit scenarios. Nothing is hard-coded.

+
+
+

6 Analysis Tabs

+

Assumptions, Investment (CAPEX), Operating Model, Cash Flow, Returns & Exit, and Key Metrics. Each with interactive charts.

+
+
+

Indoor & Outdoor

+

Model indoor halls (rent or build) and outdoor courts with seasonality. Compare scenarios side by side.

+
+
+
+
+

Sensitivity Analysis

+

See how your returns change with different utilization rates and pricing. Find your break-even point instantly.

+
+
+

Professional Metrics

+

IRR, MOIC, DSCR, cash-on-cash yield, break-even utilization, RevPAH, debt yield. The metrics banks and investors want to see.

+
+
+

Save & Compare

+

Save unlimited scenarios. Test different locations, court counts, financing structures. Find the optimal plan.

+
+
+
+ + +
+

Padel Court Investment Planning

+

+ 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 €300K (renting an existing building) and €2-3M (building new), with payback periods of 3-5 years for well-located venues. +

+

+ 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. +

+
+ + +
+

Start Planning Today

+

No credit card. No paywall. Full access to every feature.

+ {% if user %} + Open Planner + {% else %} + Create Free Account + {% endif %} +
+
+{% endblock %} diff --git a/padelnomics/src/padelnomics/public/templates/privacy.html b/padelnomics/src/padelnomics/public/templates/privacy.html new file mode 100644 index 0000000..a24ee35 --- /dev/null +++ b/padelnomics/src/padelnomics/public/templates/privacy.html @@ -0,0 +1,92 @@ +{% extends "base.html" %} + +{% block title %}Privacy Policy - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+
+

Privacy Policy

+

Last updated: February 2026

+
+ +
+

1. Information We Collect

+

We collect information you provide directly:

+
    +
  • Email address (required for account creation)
  • +
  • Name (optional)
  • +
  • Financial planning data (scenario inputs and projections)
  • +
+

We automatically collect:

+
    +
  • IP address
  • +
  • Browser type
  • +
  • Usage data
  • +
+
+ +
+

2. How We Use Information

+

We use your information to:

+
    +
  • Provide and maintain the service
  • +
  • Process payments
  • +
  • Send transactional emails
  • +
  • Improve the service
  • +
  • Respond to support requests
  • +
+
+ +
+

3. Information Sharing

+

We do not sell your personal information. We may share information with:

+
    +
  • Service providers (Resend for email, Plausible for privacy-friendly analytics)
  • +
  • Law enforcement when required by law
  • +
+
+ +
+

4. Data Retention

+

We retain your data as long as your account is active. Upon deletion, we remove your data within 30 days.

+
+ +
+

5. Security

+

We implement industry-standard security measures including encryption, secure sessions, and regular backups.

+
+ +
+

6. Cookies

+

We use essential cookies for session management. We do not use tracking or advertising cookies.

+
+ +
+

7. Your Rights

+

You have the right to:

+
    +
  • Access your data
  • +
  • Correct inaccurate data
  • +
  • Delete your account and data
  • +
  • Export your data
  • +
+
+ +
+

8. GDPR Compliance

+

For EU users: We process data based on consent and legitimate interest. You may contact us to exercise your GDPR rights.

+
+ +
+

9. Changes

+

We may update this policy. We will notify you of significant changes via email.

+
+ +
+

10. Contact

+

For privacy inquiries: {{ config.EMAIL_FROM }}

+
+
+
+{% endblock %} diff --git a/padelnomics/src/padelnomics/public/templates/terms.html b/padelnomics/src/padelnomics/public/templates/terms.html new file mode 100644 index 0000000..cf349fc --- /dev/null +++ b/padelnomics/src/padelnomics/public/templates/terms.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} + +{% block title %}Terms of Service - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+
+
+

Terms of Service

+

Last updated: February 2026

+
+ +
+

1. Acceptance of Terms

+

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.

+
+ +
+

2. Description of Service

+

{{ config.APP_NAME }} provides a software-as-a-service platform. Features and functionality may change over time.

+
+ +
+

3. User Accounts

+

You are responsible for maintaining the security of your account. You must provide accurate information and keep it updated.

+
+ +
+

4. Acceptable Use

+

You agree not to:

+
    +
  • Violate any laws or regulations
  • +
  • Infringe on intellectual property rights
  • +
  • Transmit harmful code or malware
  • +
  • Attempt to gain unauthorized access
  • +
  • Interfere with service operation
  • +
+
+ +
+

5. Financial Projections Disclaimer

+

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.

+
+ +
+

6. Termination

+

We may terminate or suspend your account for violations of these terms. You may cancel your account at any time.

+
+ +
+

7. Disclaimer of Warranties

+

The service is provided "as is" without warranties of any kind. We do not guarantee uninterrupted or error-free operation.

+
+ +
+

8. Limitation of Liability

+

We shall not be liable for any indirect, incidental, special, or consequential damages arising from use of the service.

+
+ +
+

9. Changes to Terms

+

We may modify these terms at any time. Continued use after changes constitutes acceptance of the new terms.

+
+ +
+

10. Contact

+

For questions about these terms, please contact us at {{ config.EMAIL_FROM }}.

+
+
+
+{% endblock %} diff --git a/padelnomics/src/padelnomics/static/css/custom.css b/padelnomics/src/padelnomics/static/css/custom.css new file mode 100644 index 0000000..74b0f45 --- /dev/null +++ b/padelnomics/src/padelnomics/static/css/custom.css @@ -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; +} diff --git a/padelnomics/src/padelnomics/static/css/planner.css b/padelnomics/src/padelnomics/static/css/planner.css new file mode 100644 index 0000000..703d455 --- /dev/null +++ b/padelnomics/src/padelnomics/static/css/planner.css @@ -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; } +} diff --git a/padelnomics/src/padelnomics/static/js/planner.js b/padelnomics/src/padelnomics/static/js/planner.js new file mode 100644 index 0000000..a12580b --- /dev/null +++ b/padelnomics/src/padelnomics/static/js/planner.js @@ -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;t10)r=10; + } + return r; +} + +function ti(text){ + if(!text) return ''; + return ` i${text}`; +} + +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 `
+
${label}${ti(tip)}
+
${value}
+ ${sub?`
${sub}
`:''} +
`; +} +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 `
+
${label}${ti(tip)}
+
${value}
+ ${sub?`
${sub}
`:''} +
`; +} + +// โ”€โ”€ 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;y0?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=>``).join(''); + n.querySelectorAll('button').forEach(b=>b.onclick=()=>{activeTab=b.dataset.tab;render()}); +} + +function slider(key,label,min,max,step,fmtFn,tip){ + return `
+ +
+ + +
+
`; +} + +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.')+ + '
Ancillary Revenue (per court/month):
'+ + 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=>``).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 => `${t}`; +const THR = t => `${t}`; + +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=>`${i.name}${i.info?` (${i.info})`:''}${fmt(i.amount)}`).join(''); + rows += `TOTAL CAPEX${fmt(d.capex)}`; + $('#capexTable').innerHTML = `${TH('Item')}${THR('Amount')}${rows}
`; + + 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 `${n}${fmt(Math.round(v))}${pct}%`; + }).join(''); + sRows+=`Total Net Revenue${fmt(Math.round(totalStream))}100%`; + $('#revenueTable').innerHTML=`${TH('Stream')}${THR('Monthly')}${THR('Share')}${sRows}
`; + + let oRows=d.opexItems.map(i=>`${i.name}${i.info?` (${i.info})`:''}${fmt(i.amount)}`).join(''); + oRows+=`Total Monthly OpEx${fmt(d.opex)}`; + $('#opexDetailTable').innerHTML=`${TH('Item')}${THR('Monthly')}${oRows}
`; + + 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 ` + Year ${y.year} + ${fmt(Math.round(y.revenue))} + ${fmt(Math.round(y.ebitda))} + ${fmt(Math.round(y.ds))} + ${fmt(Math.round(y.ncf))} + ${dscr>99?'\u221E':fmtX(dscr)} + ${util}% + `; + }).join(''); + $('#annualTable').innerHTML=`${TH('Year')}${THR('Revenue')}${THR('EBITDA')}${THR('Debt Service')}${THR('Net CF')}${THR('DSCR')}${THR('Util.')}${rows}
`; +} + +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])=>`
${l}${v}
`).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 `${isTarget?'\u2192 ':''} ${u}%${isTarget?' \u2190':''}${fmt(Math.round(rev))}${fmt(Math.round(ncf))}${fmt(Math.round(annual))}${dscr>99?'\u221E':fmtX(dscr)}`; + }).join(''); + $('#sensTable').innerHTML=`${TH('Utilization')}${THR('Monthly Rev')}${THR('Monthly NCF')}${THR('Annual NCF')}${THR('DSCR')}${sRows}
`; + + 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 `${isBase?'\u2192 ':''}${delta>=0?'+':''}${delta}%${isBase?' (base)':''}${fmt(Math.round(adjRate))}${fmt(Math.round(rev))}${fmt(Math.round(ncf))}`; + }).join(''); + $('#priceSensTable').innerHTML=`${TH('Price Change')}${THR('Avg Rate')}${THR('Monthly Rev')}${THR('Monthly NCF')}${pRows}
`; +} + +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 = '
Scenario saved!
'; + 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(); diff --git a/padelnomics/src/padelnomics/templates/base.html b/padelnomics/src/padelnomics/templates/base.html new file mode 100644 index 0000000..4a239dd --- /dev/null +++ b/padelnomics/src/padelnomics/templates/base.html @@ -0,0 +1,107 @@ + + + + + + {% block title %}{{ config.APP_NAME }}{% endblock %} + + + + + + + + + + + {% block head %}{% endblock %} + + + + + + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} +
+ {% endif %} + {% endwith %} + + {% block content %}{% endblock %} + + +
+
+
+ {{ config.APP_NAME }} +

Plan, finance, and build your padel business.

+
+
+ Product + +
+
+ Legal + +
+
+

+ © {{ now.year }} {{ config.APP_NAME }}. All rights reserved. +

+
+ + + + + + {% block scripts %}{% endblock %} + + diff --git a/padelnomics/src/padelnomics/worker.py b/padelnomics/src/padelnomics/worker.py new file mode 100644 index 0000000..ca42a93 --- /dev/null +++ b/padelnomics/src/padelnomics/worker.py @@ -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""" +

Sign in to {config.APP_NAME}

+

Click the link below to sign in:

+

{link}

+

This link expires in {config.MAGIC_LINK_EXPIRY_MINUTES} minutes.

+

If you didn't request this, you can safely ignore this email.

+ """ + + 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""" +

Welcome to {config.APP_NAME}!

+

Thanks for signing up. We're excited to have you.

+

Go to your dashboard

+ """ + + 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()) diff --git a/padelnomics/uv.lock b/padelnomics/uv.lock new file mode 100644 index 0000000..d67df0d --- /dev/null +++ b/padelnomics/uv.lock @@ -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" }, +]