diff --git a/.env.example b/.env.example index 4b0fdbf..8081ddb 100644 --- a/.env.example +++ b/.env.example @@ -1,19 +1,23 @@ -# === REQUIRED (generated automatically by setup.sh) === -SECRET_KEY= +# Diligence Configuration +# Copy this to .env and fill in values, or run ./setup.sh to auto-generate. + +# Required — auto-generated by setup.sh +SECRET_KEY=change-me-in-production API_TOKEN= -# === APP URL (change for production) === -BASE_URL=http://localhost +# Database — leave empty for SQLite (pip install path) +# Set for PostgreSQL (Docker path): postgresql+asyncpg://fitness@fitness-db:5432/fitness_rewards +DATABASE_URL= -# === DATABASE (defaults work out of the box) === -DB_USER=fitness -DB_NAME=fitness_rewards +# App +BASE_URL=http://localhost:8000 -# === MCP AGENT AUTH (generated automatically by setup.sh) === -# The API_TOKEN authenticates the MCP connector with the backend. -# It is auto-configured — you don't need to touch it unless you're -# connecting an agent from outside Docker. - -# Everything else — Strava, Polar, Garmin, Fitbit, Telegram, -# USDA, Groq, etc. — is configured through the app UI or your AI agent. -# No container restart needed. +# Optional integrations +STRAVA_CLIENT_ID= +STRAVA_CLIENT_SECRET= +POLAR_CLIENT_ID= +POLAR_CLIENT_SECRET= +GROQ_API_KEY= +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= +CRAWL_ENABLED=false diff --git a/.gitignore b/.gitignore index cc1fa53..9a86dd0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,31 @@ +# Environment +.env +*.env.local + +# Python __pycache__/ *.py[cod] *.egg-info/ -.env -.venv/ -node_modules/ dist/ -.DS_Store -*.log +build/ +*.egg -# Excluded from open-source (scraped third-party content) +# Node +frontend/node_modules/ +frontend/dist/ + +# Data +*.db +*.sqlite +*.sqlite3 + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db + +# Content (scraped recipes — not for distribution) content/ diff --git a/README.md b/README.md index 37fb408..7094339 100644 --- a/README.md +++ b/README.md @@ -1,237 +1,211 @@ -# Diligence — Self-Hosted Fitness Rewards with AI Agent Support +# Diligence -Points-based fitness accountability app. Log workouts and food, earn points, unlock rewards. Connect any AI agent via MCP for logging, coaching, and meal planning. Self-hosted, open source, zero cloud dependencies. +Self-hosted fitness rewards platform with AI agent integration. Points-based behavioral economy: earn points through workouts and food logging, spend them on rewards you choose. Science-based onboarding. 14-tool MCP connector for AI agents. Your data stays on your machine. -Built by [DiligenceWorks](https://diligenceworks.online). - -## Quick Start - -### Prerequisites - -- [Docker Desktop](https://www.docker.com/products/docker-desktop/) — that's it. No Python, Node.js, or database install needed. -- Docker Desktop runs natively on **Windows 10/11**, **macOS**, and **Linux**. +**By [DiligenceWorks Pte. Ltd.](https://diligenceworks.online) — MIT License** --- -### Windows 11 Setup +## Which install is right for you? -**1. Install Docker Desktop** +| | **pip install** | **Docker** | +|---|---|---| +| **Who it's for** | You want a fitness app on your laptop. No servers, no DevOps, no fuss. | You're self-hosting for a household, a team, or you want PostgreSQL and a production-grade setup. | +| **What you need** | Python 3.11+ | Docker and Docker Compose | +| **Database** | SQLite (zero config, file on disk) | PostgreSQL 16 (runs in a container) | +| **Runs as** | Single process on localhost | 4 containers behind nginx | +| **Setup time** | 2 minutes | 5 minutes | +| **Best for** | Personal use on a laptop or desktop | Always-on server, multiple users, backups | -Download from [docker.com/products/docker-desktop](https://www.docker.com/products/docker-desktop/). +--- -During installation, the installer presents a checkbox: **"Use WSL 2 instead of Hyper-V (recommended)"**. This is checked by default. +## Install — Personal Laptop (pip) -- **WSL 2 backend (default):** Works on all Windows editions including Home. Lower resource usage, better performance. Requires WSL 2 to be installed first — open PowerShell as Administrator and run `wsl --install`, then reboot. -- **Hyper-V backend:** If WSL 2 causes issues (black screen on reboot, kernel errors, or containers won't start), uncheck the WSL 2 box during installation to use Hyper-V instead. Requires Windows Pro, Enterprise, or Education. +**Prerequisites:** Python 3.11 or newer. -**Important:** Hardware virtualization must be enabled in your BIOS/UEFI settings (Intel VT-x or AMD-V). This is the most common cause of "Docker won't start" issues. Check your laptop/PC manufacturer's documentation for how to access BIOS settings (usually F2, F12, or Del during boot). +```bash +# Clone and install +git clone https://github.com/DiligenceWorks/Diligence.git +cd Diligence +pip install . -**2. Clone and run** - -Open PowerShell: -```powershell -git clone https://github.com/diligenceworks/diligence -cd diligence -powershell -ExecutionPolicy Bypass -File setup.ps1 -docker compose up -d +# Run +diligence ``` -Or without the script: -```powershell -git clone https://github.com/diligenceworks/diligence -cd diligence -copy .env.example .env -# Edit .env and set SECRET_KEY to any random string -docker compose up -d +That's it. The app opens in your browser at `http://localhost:8000`. Your data lives in `~/.diligence/`. + +On first run, Diligence generates a secret key and stores your config in `~/.diligence/.env`. The SQLite database is created automatically at `~/.diligence/data.db`. + +### Options + +```bash +diligence --port 9000 # Different port +diligence --no-browser # Don't auto-open browser +diligence --data-dir /my/path # Custom data directory +python -m diligence # Alternative way to run +``` + +### Building the frontend from source + +The pip package includes a pre-built frontend. If you want to modify the UI: + +```bash +cd frontend +npm install +npm run build +cp -r dist/ ../diligence/frontend/ ``` --- -### macOS Setup +## Install — Server (Docker) -**1. Install Docker Desktop** +**Prerequisites:** Docker and Docker Compose. -Download from [docker.com/products/docker-desktop](https://www.docker.com/products/docker-desktop/). Make sure you pick the right installer for your chip: - -- **Apple Silicon** (M1, M2, M3, M4) — download the Apple Silicon .dmg -- **Intel** — download the Intel .dmg - -To check: Apple menu → About This Mac. Look for "Chip" (Apple Silicon) or "Processor" (Intel). - -Drag Docker.app to Applications and launch it. Requires macOS 13 Ventura or later. - -Alternatively, install via Homebrew: `brew install --cask docker` - -**2. Clone and run** - -Open Terminal: ```bash -git clone https://github.com/diligenceworks/diligence -cd diligence -./setup.sh -docker compose up -d +git clone https://github.com/DiligenceWorks/Diligence.git +cd Diligence +./setup.sh # generates .env with secrets +docker compose up -d # starts 4 containers +``` + +Open `http://localhost` (or your server's IP). Register your account — the first user gets admin. + +### What's running + +| Container | Role | Port | +|-----------|------|------| +| frontend | React app + nginx reverse proxy | 80 | +| backend | FastAPI application server | 8000 (internal) | +| mcp-connector | MCP SSE server for AI agents | 3001 | +| fitness-db | PostgreSQL 16 | 5432 (internal) | + +### Environment variables + +Copy `.env.example` to `.env` (or let `setup.sh` do it). Key variables: + +| Variable | Docker default | Description | +|----------|---------------|-------------| +| `SECRET_KEY` | (generated) | JWT signing key | +| `API_TOKEN` | (generated) | MCP connector auth token | +| `DATABASE_URL` | `postgresql+asyncpg://...` | Database connection | +| `BASE_URL` | `http://localhost` | Public URL for OAuth callbacks | + +--- + +## AI Agent Integration (MCP) + +Diligence exposes 14 tools via the [Model Context Protocol](https://modelcontextprotocol.io). Any MCP-compatible AI agent (Claude, custom agents) can log workouts, track food, manage meal plans, and check progress. + +### Connect your agent + +**pip install path:** +``` +URL: http://localhost:8000/mcp +Token: (shown on first run, or check ~/.diligence/.env) +``` + +**Docker path:** +``` +URL: http://localhost:3001/sse +Token: (in your .env file, API_TOKEN) +``` + +### Available tools + +| Tool | What it does | +|------|-------------| +| `get_context()` | Full profile, motivation type, programs, rules, rewards | +| `get_today()` | Daily points, gate status, activities | +| `get_week()` | Weekly summary and target progress | +| `log_activity(...)` | Log a workout, earn points | +| `log_food(...)` | Log food with macros | +| `search_food(query)` | Search Open Food Facts + USDA (400K+ foods) | +| `get_program_schedule()` | Today's scheduled workout | +| `redeem_reward(name)` | Spend points on a reward | +| `load_meal_plan(...)` | Create a meal plan | +| `get_meal_plan()` | View today's meals | +| `update_meal_compliance(...)` | Mark meals as followed/skipped | +| `get_plan_progress()` | Compliance stats | +| `configure_integration(...)` | Store encrypted credentials | +| `get_integration_status()` | Check provider connections | + +See `AGENT_GUIDE.md` for behavioral guidelines including BREQ-2 tone calibration. + +--- + +## What's inside + +### Science-based onboarding +- **PAR-Q+** physical activity readiness screening +- **TTM** (Transtheoretical Model) stage assessment +- **BREQ-2** motivation profiling — the app adapts its tone to your motivation type + +### Points engine +- Earn points for workouts, food logging, fasting, meal compliance +- Daily gate (minimum to unlock rewards) +- Weekly targets with reset +- Configurable reward shop — you decide what points are worth + +### Integrations +Strava and Polar sync are built in. The provider registry supports Garmin, Fitbit, Withings, WHOOP, and Oura (OAuth flows ready, need provider approval). USDA FoodData Central for nutrition lookup. + +--- + +## Project structure + +``` +Diligence/ + pyproject.toml # pip install config + docker-compose.yml # Docker config + diligence/ # Python package + cli.py # Entry point for pip path + main.py # FastAPI app + config.py # Settings (auto-detects SQLite vs PostgreSQL) + database.py # Dialect-agnostic database layer + models/ # 15 SQLAlchemy models + routers/ # 12 API routers + services/ # Points engine, food lookup, sync, crypto + utils/ # Auth helpers + mcp/ # MCP connector (14 tools) + frontend/ # Pre-built React app (pip path serves this) + frontend/ # React source code + backend/ # Dockerfile + requirements.txt (Docker path) + mcp-connector/ # MCP Dockerfile (Docker path) ``` --- -### Linux Setup - -Install Docker Engine and Docker Compose via your distro's package manager, or install Docker Desktop for Linux. Then: +## Development ```bash -git clone https://github.com/diligenceworks/diligence -cd diligence -./setup.sh -docker compose up -d +# Clone +git clone https://github.com/DiligenceWorks/Diligence.git +cd Diligence + +# Backend +pip install -e ".[dev]" +diligence --port 8000 + +# Frontend (separate terminal) +cd frontend +npm install +npm run dev ``` --- -Open http://localhost and create your account. First user gets admin. +## Data and privacy -### Verify +Your data never leaves your machine. There is no telemetry, no analytics, no cloud sync. The database is a single file (`~/.diligence/data.db` for pip, a Docker volume for Docker). Back it up however you back up your files. -```bash -docker compose ps -``` +Integration credentials (Strava, Polar, etc.) are encrypted at rest using Fernet with HKDF key derivation from your SECRET_KEY. -You should see 4 containers, all healthy: `frontend`, `backend`, `mcp-connector`, `fitness-db`. - -## Features - -- **Points economy** — earn from workouts, food logging, step goals. Daily gate locks rewards until you earn enough. Weekly reset. -- **Science-based onboarding** — PAR-Q+ safety screening, TTM stages of change, BREQ-2 motivation profiling. -- **AI agent integration** — 14 MCP tools for logging, coaching, meal planning, and device configuration. -- **Meal plans** — AI-generated plans with compliance tracking and points integration. -- **Activity sync** — Strava, Polar (OAuth 2.0). Garmin, Fitbit, Withings, WHOOP, Oura configurable in-app. -- **Food search** — Open Food Facts (4M+ products) + USDA FoodData Central (400K+ research-grade). -- **Program tracking** — 90-day structured programs (StrongLifts, Darebee, etc.) with day-by-day progression. -- **Configurable rewards** — you define what's worth earning. Gaming time, screen time, treats — your rules. - -## Built-in AI Coach - -Diligence includes a built-in AI coaching chat. Configure any LLM provider in **Settings → Integrations**, then open the **Coach** tab. - -Supported providers (one API key, that's it): - -| Provider | Free tier | What you get | -|----------|-----------|-------------| -| **OpenRouter** | 26 free models | 300+ models from every major provider, one key | -| **Hugging Face** | Yes | Thousands of open-source models | -| **Groq** | Yes | Ultra-fast Llama inference | -| **Ollama** | Local, free | Run any model on your own machine | -| **OpenAI** | No | GPT-4o, GPT-4o-mini | -| **Claude** | No | Claude Sonnet 4.6, Opus 4.8 | -| **Gemini** | Yes | Gemini 2.0 Flash, 2.5 Pro | -| **Custom** | — | Any OpenAI-compatible endpoint (vLLM, TGI, LiteLLM) | - -The AI coach has access to your profile, points, program schedule, and motivation type. It can log workouts, search food, and create meal plans through the chat. - -### Connecting external AI agents (MCP) - -The MCP connector at port 3001 works with any MCP-compatible agent. The built-in chat and external agents coexist — use whichever fits your workflow. - -**Claude Desktop** — add to `claude_desktop_config.json`: -```json -{ - "mcpServers": { - "diligence": { "url": "http://localhost:3001/sse" } - } -} -``` - -**Claude Code** (CLI): -```bash -claude mcp add diligence --transport sse http://localhost:3001/sse -``` - -**Cursor** — add to `.cursor/mcp.json`: -```json -{ - "mcpServers": { - "diligence": { "url": "http://localhost:3001/sse" } - } -} -``` - -**Windsurf** — add to MCP settings, same format as Cursor. - -**COROS watch owners** — add both Diligence and COROS MCP servers to your agent. The agent bridges your watch data with your fitness log: -```json -{ - "mcpServers": { - "diligence": { "url": "http://localhost:3001/sse" }, - "coros": { "url": "https://your-coros-mcp-url/sse" } - } -} -``` - -Copy the contents of [AGENT_GUIDE.md](AGENT_GUIDE.md) into your agent's system instructions for motivation-aware coaching. - -## Configuring Integrations - -All integrations are configured through the app UI (**Settings → Integrations**) or through your AI agent. No `.env` file editing or container restarts needed. - -Tell your agent: *"I want to connect my Strava"* — it will walk you through getting API credentials and store them encrypted. - -## Data Sovereignty - -Diligence is self-hosted software. Your data never leaves your server. No cloud dependency, no vendor lock-in, no telemetry. MIT license — fork it, modify it, keep it forever. - -Your fitness data — heart rate, body composition, food intake, GPS traces — is biometric data that deserves sovereignty. - -## Architecture - -``` -┌─────────────────────────────────────────────────┐ -│ nginx │ -│ (frontend, /api proxy, /mcp proxy) │ -├──────────┬─────────────────┬────────────────────┤ -│ React │ FastAPI │ MCP Connector │ -│ SPA │ Backend │ (14 tools) │ -│ │ │ port 3001 │ -│ ├──────────────────┤ │ -│ │ PostgreSQL 16 │ │ -│ │ (internal only) │ │ -└──────────┴──────────────────┴────────────────────┘ -``` - -## Stack - -- Python 3.12, FastAPI, SQLAlchemy (async) -- React 18, Vite -- PostgreSQL 16 -- FastMCP (Streamable HTTP/SSE) -- Docker Compose - -## Updating - -```bash -git pull -docker compose build -docker compose up -d -``` - -Database migrations run automatically on startup. Your data is preserved. - -## Backing Up - -Your data lives in the `fitness_db_data` Docker volume: - -```bash -docker compose exec fitness-db pg_dump -U fitness fitness_rewards > backup.sql -``` - -To restore: - -```bash -docker compose exec -i fitness-db psql -U fitness fitness_rewards < backup.sql -``` - -## Contributing - -Contributions welcome. Please open an issue to discuss before submitting a PR. +--- ## License -MIT — see [LICENSE](LICENSE). +MIT. See `LICENSE`. + +Built by [DiligenceWorks Pte. Ltd.](https://diligenceworks.online) diff --git a/TESTER-GUIDE.md b/TESTER-GUIDE.md new file mode 100644 index 0000000..3f18366 --- /dev/null +++ b/TESTER-GUIDE.md @@ -0,0 +1,240 @@ +# Diligence — Beta Tester Guide + +Thanks for helping us test Diligence! This guide walks you through setup, what to test, and how to give us useful feedback. No technical experience needed. + +--- + +## What is Diligence? + +Diligence is a fitness app that runs on your own computer. You earn points for workouts, food logging, and healthy habits, then spend them on rewards you choose. Everything stays on your machine — no cloud accounts, no data collection, no subscriptions. + +--- + +## Setting Up (10 minutes) + +### Step 1 — Install Python + +Diligence needs Python (version 3.11 or newer) installed on your computer. If you're not sure whether you have it, open a terminal and type: + +``` +python --version +``` + +If it says `Python 3.11` or higher, you're good — skip to Step 2. + +**If you need to install Python:** + +- **Windows:** Go to https://www.python.org/downloads/ and click the big yellow "Download" button. During installation, **check the box that says "Add Python to PATH"** — this is important. +- **Mac:** Go to https://www.python.org/downloads/ and download the macOS installer. Run it and follow the prompts. + +After installing, close and reopen your terminal, then check again with `python --version`. + +> **Note for Mac users:** You may need to type `python3` instead of `python` throughout this guide. + +### Step 2 — Download Diligence + +Open a terminal (on Windows, search for "Command Prompt" or "PowerShell"; on Mac, search for "Terminal") and run these commands one at a time: + +``` +git clone https://github.com/DiligenceWorks/Diligence.git +cd Diligence +pip install . +``` + +> **Don't have git?** You can also download Diligence as a ZIP file from https://github.com/DiligenceWorks/Diligence — click the green "Code" button, then "Download ZIP." Unzip it, open a terminal in that folder, and run `pip install .` + +### Step 3 — Run It + +``` +diligence +``` + +Your browser will open to `http://localhost:8000`. Create an account — pick any username and password you like. That's it, you're in. + +> **If `diligence` doesn't work,** try `python -m diligence` instead. + +### Stopping the App + +Go back to your terminal and press `Ctrl+C`. Your data is saved automatically — next time you run `diligence`, everything will be right where you left it. + +--- + +## What to Test + +We need you to actually use the app as if it were your real fitness tracker. Below are the areas we'd love you to explore. You don't have to test everything — focus on whatever interests you, and spend at least a few days with it so you get a feel for the daily experience. + +### 1. First-Time Setup (Onboarding) + +When you first create an account, the app walks you through a health screening and motivation assessment. Pay attention to: + +- Were the questions clear and easy to understand? +- Did any question feel confusing, irrelevant, or uncomfortable? +- Did the process feel too long, too short, or about right? +- At the end, did the app's summary of your fitness profile feel accurate? + +### 2. Logging Workouts + +Try logging different types of exercise — a walk, a gym session, a bike ride, yoga, whatever you actually do. Notice: + +- Was it easy to find and log your activity? +- Did the points awarded feel fair for the effort? +- Did anything feel clunky or take too many clicks? +- Try logging something unusual — did the app handle it? + +### 3. Food Logging + +Log what you eat for a few days. The app searches a database of 400,000+ foods. Pay attention to: + +- Could you find the foods you actually eat? +- Were the portion sizes and nutritional info reasonable? +- Was logging a meal quick enough that you'd actually do it daily? +- Try searching for a brand name, a generic food, and a homemade dish — how did each go? + +### 4. Points and Rewards + +The app has a points system with daily gates (minimums) and a reward shop. Test: + +- Do you understand how points are earned? +- Does the daily gate make sense — is the minimum too easy, too hard? +- Try setting up a reward and redeeming it. Was the process clear? +- Does the points balance update correctly after activities and redemptions? + +### 5. Meal Plans + +If you use the meal planning feature, notice: + +- Were the suggested meals realistic for your diet? +- Could you mark meals as followed or skipped easily? +- Did the compliance tracking (how well you stuck to the plan) feel accurate? + +### 6. Programs (90-Day Training) + +If you enroll in a training program: + +- Were the scheduled workouts appropriate for your level? +- Was it clear what you were supposed to do each day? +- Did progress tracking work as expected? + +### 7. General App Feel + +Beyond specific features, we want to know: + +- Is the app visually appealing and easy to navigate? +- Does it load quickly? +- Are there any pages that feel empty, broken, or confusing? +- Would you actually use this app day-to-day? Why or why not? +- Is there anything you expected to find but couldn't? + +--- + +## How to Give Feedback + +Good feedback is specific. Here's what helps us most: + +### The Format + +For each thing you notice, tell us: + +1. **What you were doing** — "I was trying to log a 30-minute walk" +2. **What happened** — "The app showed 0 points even though I completed the log" +3. **What you expected** — "I expected to earn points for the activity" +4. **How it made you feel** — "Frustrating — felt like the effort wasn't recognized" + +### Examples of Helpful Feedback + +> "I searched for 'pad thai' in food logging and got zero results. I expected at least a generic entry. I ended up not logging my dinner because I couldn't find anything close." + +> "The onboarding questions about motivation were great — I've never had a fitness app ask me WHY I exercise. The summary at the end nailed my personality. Made me want to keep using it." + +> "I logged a workout and a meal on the same day but the points display only showed the workout points. After refreshing the page, both showed up. Minor but confusing." + +> "The reward shop is a cool idea but I didn't understand what to put there. Maybe some example rewards would help new users get started?" + +### What We Especially Want to Hear + +- **Things that don't work** — errors, blank screens, buttons that do nothing +- **Things that are confusing** — where you had to guess or weren't sure what to do +- **Things that are missing** — features you looked for but couldn't find +- **Things you loved** — so we know what to keep and build on +- **Honest reactions** — if something feels pointless or annoying, say so + +### How to Send It + +Send your feedback to **hello@diligenceworks.online** with the subject line **"Diligence Beta Feedback"**. You can write it however you like — bullet points, paragraphs, voice-to-text, whatever works for you. Screenshots are very welcome if you can take them (on most computers: Windows key + Shift + S on Windows, Cmd + Shift + 4 on Mac). + +--- + +## Connecting Claude to Diligence (Optional but Recommended) + +You can connect Claude Desktop to Diligence so Claude can log your workouts, track your food, manage meal plans, and check your progress through conversation — just tell Claude what you did and it handles the rest. + +### What You Need + +- **Claude Desktop** (free) — download from https://claude.ai/download +- A Claude account (free tier works) +- Diligence running on your computer + +### Setup Steps + +1. Make sure Diligence is running (`python -m diligence` in your terminal) +2. Open your browser to `http://localhost:8000/agent` +3. You'll see a **Claude Desktop** section with a JSON config block — click **Copy** +4. Find your Claude Desktop config file: + - **Windows:** Press `Win + R`, paste `%APPDATA%\Claude\claude_desktop_config.json`, press Enter + - **Mac:** Open Finder, press `Cmd + Shift + G`, paste `~/Library/Application Support/Claude/claude_desktop_config.json` +5. Open that file in Notepad (Windows) or TextEdit (Mac) +6. Replace everything in the file with the JSON you copied. If the file doesn't exist, create it. +7. Save the file and **restart Claude Desktop** + +### How to Use It + +Once connected, you can talk to Claude naturally: + +- *"I went for a 30-minute walk this morning"* — Claude logs the activity and tells you the points earned +- *"I had scrambled eggs and avocado for breakfast"* — Claude searches the food database and logs it with full nutrition info +- *"How am I doing today?"* — Claude shows your points, daily gate status, and what's left to earn +- *"What's my meal plan for today?"* — Claude pulls up your planned meals + +### Important + +- Diligence must be running in your terminal for Claude to connect. If you close the terminal, Claude loses the connection until you start Diligence again. +- You don't need Claude Desktop to use the app — the web interface works on its own. Claude is an optional power-up. + +--- + +## Common Questions + +**Where is my data stored?** +In a folder called `.diligence` in your home directory. On Windows that's `C:\Users\YourName\.diligence\`, on Mac it's `/Users/YourName/.diligence/`. It's a single file called `data.db`. + +**Can I use it on my phone?** +Not as a native app, but if you run it on your computer you can open `http://your-computer-ip:8000` from your phone's browser while on the same Wi-Fi network. Run `diligence --host 0.0.0.0` to allow connections from other devices. + +**Will I lose my data if I close the terminal?** +No. Your data is saved to disk continuously. Closing the terminal stops the app, but next time you run `diligence` everything is still there. + +**Something broke and I want to start fresh.** +Delete the `.diligence` folder in your home directory and run `diligence` again. You'll go through onboarding as a new user. + +**I got an error when installing.** +Make sure your Python version is 3.11 or newer (`python --version`). If you see "pip not found," try `python -m pip install .` instead of `pip install .` + +--- + +## Uninstalling + +If you want to remove Diligence completely: + +``` +pip uninstall diligence +``` + +Then delete the `.diligence` folder from your home directory if you want to remove your data too. + +To remove the downloaded source code, just delete the `Diligence` folder wherever you cloned or unzipped it. + +--- + +Thank you for testing. Every piece of feedback makes the app better for everyone. + +*— The Diligence Team at DiligenceWorks Pte. Ltd.* diff --git a/backend/Dockerfile b/backend/Dockerfile index db8324a..a5025dc 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,7 +1,12 @@ FROM python:3.12-slim + WORKDIR /app + RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* -COPY requirements.txt . + +COPY backend/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY . . -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] + +COPY diligence/ ./diligence/ + +CMD ["uvicorn", "diligence.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/config.py b/backend/app/config.py deleted file mode 100644 index 3c2be33..0000000 --- a/backend/app/config.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations - -from pydantic_settings import BaseSettings -from functools import lru_cache - - -class Settings(BaseSettings): - # Database - hostname 'fitness-db' avoids collision with other 'db' containers on Coolify network - database_url: str = "postgresql+asyncpg://fitness@fitness-db:5432/fitness_rewards" - - # Auth - secret_key: str = "change-me-in-production" - crawl_enabled: bool = False # Set CRAWL_ENABLED=true to start program crawl scheduler - access_token_expire_minutes: int = 1440 # 24 hours - api_token: str = "" # MCP connector auth — generated by setup.sh - - # Strava - strava_client_id: str = "" - strava_client_secret: str = "" - - # Polar - polar_client_id: str = "" - polar_client_secret: str = "" - - # Groq (program extraction) - groq_api_key: str = "" - - # Telegram (support notifications — outbound only) - telegram_bot_token: str = "" - telegram_chat_id: str = "" - - # App - base_url: str = "http://localhost" - timezone: str = "Asia/Bangkok" - - model_config = {"env_file": ".env", "extra": "ignore"} - - -@lru_cache -def get_settings() -> Settings: - return Settings() - - -# Module-level shortcut used by services -settings = get_settings() diff --git a/backend/app/main.py b/backend/app/main.py deleted file mode 100644 index b69ab59..0000000 --- a/backend/app/main.py +++ /dev/null @@ -1,428 +0,0 @@ -from __future__ import annotations - -import asyncio -import sys -import logging -from contextlib import asynccontextmanager -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger("fitness-rewards") - - -@asynccontextmanager -async def lifespan(app: FastAPI): - # Retry DB init — container may start before postgres is fully accepting connections - for attempt in range(10): - try: - from app.database import init_db - await init_db() - logger.info("Database tables created successfully") - break - except Exception as e: - logger.warning(f"DB init attempt {attempt + 1}/10 failed: {e}") - if attempt < 9: - await asyncio.sleep(3) - else: - logger.error("Could not initialize database after 10 attempts — starting without tables") - - - # SEC-05: Fail fast if SECRET_KEY not configured - from app.config import get_settings - _s = get_settings() - if _s.secret_key == "change-me-in-production": - logger.error("CRITICAL: SECRET_KEY not set. Run ./setup.sh or set SECRET_KEY in .env") - sys.exit(1) - - # Run lightweight migrations for schema changes - try: - await run_migrations() - logger.info("Migrations completed") - except Exception as e: - logger.warning(f"Migration failed (non-fatal): {e}") - - # Seed resources (non-fatal if it fails) - try: - await seed_resources() - logger.info("Resource library seeded") - except Exception as e: - logger.warning(f"Resource seeding failed (non-fatal): {e}") - - # Start background crawl queue scheduler (gated on CRAWL_ENABLED) - crawl_task = None - if _s.crawl_enabled: - try: - from app.services.crawl_scheduler import crawl_queue_loop - crawl_task = asyncio.create_task(crawl_queue_loop()) - logger.info("Crawl queue scheduler started") - except Exception as e: - logger.warning(f"Crawl scheduler failed to start (non-fatal): {e}") - else: - logger.info("Crawl scheduler disabled (set CRAWL_ENABLED=true to enable)") - - logger.info("Fitness Rewards backend started") - yield - - # Shutdown - if crawl_task: - crawl_task.cancel() - try: - await crawl_task - except asyncio.CancelledError: - pass - logger.info("Fitness Rewards backend shutting down") - - -app = FastAPI(title="Fitness Rewards", version="1.0.0", lifespan=lifespan) - -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=False, # Bearer tokens don't need credentials mode - allow_methods=["*"], - allow_headers=["*"], -) - -# Import routers after app creation to avoid circular imports -from app.routers.auth import router as auth_router -from app.routers.onboarding import router as onboarding_router -from app.routers.activities import router as activities_router -from app.routers.food import router as food_router -from app.routers.points import router as points_router, rewards_router -from app.routers.integrations import router as integrations_router -from app.routers.programs import router as programs_router -from app.routers.catalog import router as catalog_router -from app.routers.support import router as support_router -from app.routers.nutrition import router as nutrition_router -from app.routers.meal_plans import router as meal_plans_router -from app.routers.ai_chat import router as ai_chat_router - -app.include_router(auth_router) -app.include_router(onboarding_router) -app.include_router(activities_router) -app.include_router(food_router) -app.include_router(points_router) -app.include_router(rewards_router) -app.include_router(integrations_router) -app.include_router(catalog_router) -app.include_router(support_router) -app.include_router(programs_router) -app.include_router(nutrition_router) -app.include_router(meal_plans_router) -app.include_router(ai_chat_router) - - -@app.get("/api/health") -async def health(): - return {"status": "ok", "version": "1.0.0"} - - - -async def run_migrations(): - """Add missing columns to existing tables (lightweight schema migration).""" - from app.database import engine - from sqlalchemy import text - - async with engine.begin() as conn: - # v1: Add equipment_list JSONB column if missing - await conn.execute(text(""" - DO $$ BEGIN - ALTER TABLE user_profiles ADD COLUMN IF NOT EXISTS equipment_list JSONB DEFAULT '[]'; - EXCEPTION WHEN undefined_table THEN NULL; - END $$; - """)) - - # v2: Add catalog columns to programs table - await conn.execute(text(""" - DO $$ BEGIN - ALTER TABLE programs ADD COLUMN IF NOT EXISTS catalog_id UUID REFERENCES program_catalog(id); - ALTER TABLE programs ADD COLUMN IF NOT EXISTS current_week INTEGER DEFAULT 1; - ALTER TABLE programs ADD COLUMN IF NOT EXISTS current_day INTEGER DEFAULT 1; - EXCEPTION WHEN undefined_table THEN NULL; - END $$; - """)) - - # v2.1: Add week_number to workout_logs for template rotation tracking - await conn.execute(text(""" - DO $$ BEGIN - ALTER TABLE workout_logs ADD COLUMN IF NOT EXISTS week_number INTEGER NOT NULL DEFAULT 1; - EXCEPTION WHEN undefined_table THEN NULL; - END $$; - """)) - - # v3: Seed keto point rules (fast_completed, keto_day, meal_logged) - # NOTE: Keto rules below are from v2. v3+ migrations (is_admin, integration_configs, - # meal plans) are added after this block. - await conn.execute(text(""" - DO $$ BEGIN - INSERT INTO point_rules (id, user_id, category, points, unit, is_active) - SELECT gen_random_uuid(), u.id, 'fast_completed', 200, 'per_event', TRUE - FROM users u - WHERE NOT EXISTS ( - SELECT 1 FROM point_rules pr - WHERE pr.user_id = u.id AND pr.category = 'fast_completed' - ); - INSERT INTO point_rules (id, user_id, category, points, unit, is_active) - SELECT gen_random_uuid(), u.id, 'keto_compliant_day', 100, 'per_event', TRUE - FROM users u - WHERE NOT EXISTS ( - SELECT 1 FROM point_rules pr - WHERE pr.user_id = u.id AND pr.category = 'keto_compliant_day' - ); - EXCEPTION WHEN undefined_table THEN NULL; - END $$; - """)) - - # v4: Add is_admin column to users - await conn.execute(text(""" - DO $$ BEGIN - ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN DEFAULT FALSE; - EXCEPTION WHEN undefined_table THEN NULL; - END $$; - """)) - - # v4.1: Grant admin to first registered user if no admin exists - await conn.execute(text(""" - DO $$ BEGIN - IF NOT EXISTS (SELECT 1 FROM users WHERE is_admin = TRUE) THEN - UPDATE users SET is_admin = TRUE - WHERE id = (SELECT id FROM users ORDER BY created_at ASC LIMIT 1); - END IF; - EXCEPTION WHEN undefined_table THEN NULL; - WHEN undefined_column THEN NULL; - END $$; - """)) - - # v5: Create integration_configs table - await conn.execute(text(""" - CREATE TABLE IF NOT EXISTS integration_configs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id), - provider VARCHAR(50) NOT NULL, - config_key VARCHAR(100) NOT NULL, - config_value TEXT NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(user_id, provider, config_key) - ); - """)) - - # v6: Create meal plan tables - await conn.execute(text(""" - CREATE TABLE IF NOT EXISTS meal_plans ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id), - name VARCHAR(200) NOT NULL, - diet_type VARCHAR(50), - daily_calories INTEGER, - daily_protein_g INTEGER, - daily_carbs_g INTEGER, - daily_fat_g INTEGER, - restrictions JSONB DEFAULT '[]', - duration_days INTEGER NOT NULL, - start_date DATE NOT NULL, - status VARCHAR(20) DEFAULT 'active', - created_at TIMESTAMPTZ DEFAULT NOW() - ); - """)) - - await conn.execute(text(""" - CREATE TABLE IF NOT EXISTS meal_plan_items ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - plan_id UUID NOT NULL REFERENCES meal_plans(id) ON DELETE CASCADE, - day_number INTEGER NOT NULL, - meal_type VARCHAR(20) NOT NULL, - food_name VARCHAR(300) NOT NULL, - description TEXT, - calories INTEGER, - protein_g DECIMAL(6,1), - carbs_g DECIMAL(6,1), - fat_g DECIMAL(6,1), - fiber_g DECIMAL(6,1), - serving_size VARCHAR(100), - sort_order INTEGER DEFAULT 0, - created_at TIMESTAMPTZ DEFAULT NOW() - ); - """)) - - await conn.execute(text(""" - CREATE TABLE IF NOT EXISTS meal_compliance ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id), - plan_id UUID NOT NULL REFERENCES meal_plans(id), - plan_item_id UUID, - compliance_date DATE NOT NULL, - status VARCHAR(20) NOT NULL, - substitution TEXT, - food_log_id UUID, - created_at TIMESTAMPTZ DEFAULT NOW() - ); - """)) - - await conn.execute(text(""" - CREATE INDEX IF NOT EXISTS idx_meal_plan_items_day ON meal_plan_items(plan_id, day_number); - """)) - await conn.execute(text(""" - CREATE INDEX IF NOT EXISTS idx_meal_compliance_date ON meal_compliance(user_id, compliance_date); - """)) - - # v7: Seed meal plan point rules - await conn.execute(text(""" - DO $$ BEGIN - INSERT INTO point_rules (id, user_id, category, points, unit, is_active) - SELECT gen_random_uuid(), u.id, 'meal_plan_followed', 40, 'per_event', TRUE - FROM users u - WHERE NOT EXISTS ( - SELECT 1 FROM point_rules pr - WHERE pr.user_id = u.id AND pr.category = 'meal_plan_followed' - ); - INSERT INTO point_rules (id, user_id, category, points, unit, is_active) - SELECT gen_random_uuid(), u.id, 'meal_plan_partial', 20, 'per_event', TRUE - FROM users u - WHERE NOT EXISTS ( - SELECT 1 FROM point_rules pr - WHERE pr.user_id = u.id AND pr.category = 'meal_plan_partial' - ); - EXCEPTION WHEN undefined_table THEN NULL; - END $$; - """)) - - -async def seed_resources(): - """Seed the resource library with curated fitness programs.""" - from app.database import async_session - from app.models.resource import Resource - from sqlalchemy import select - - async with async_session() as db: - existing = await db.execute(select(Resource).limit(1)) - if existing.scalar_one_or_none(): - return # Already seeded - - resources = [ - Resource( - name="Darebee Foundation Program", - source="darebee", - url="https://darebee.com/programs/foundation-program.html", - description="30-day beginner program. Bodyweight exercises, no equipment needed. Perfect starting point.", - goal_tags=["get_active", "feel_better"], - activity_tags=["bodyweight"], - equipment_needed="none", - ttm_stages=["precontemplation", "contemplation", "preparation"], - difficulty="beginner", - duration_days=30, - ), - Resource( - name="Darebee 30 Days of Change", - source="darebee", - url="https://darebee.com/programs/30-days-of-change.html", - description="30-day progressive program for building fitness habits. Bodyweight only.", - goal_tags=["get_active", "lose_weight", "feel_better"], - activity_tags=["bodyweight"], - equipment_needed="none", - ttm_stages=["contemplation", "preparation"], - difficulty="beginner", - duration_days=30, - ), - Resource( - name="Darebee IRONHEART", - source="darebee", - url="https://darebee.com/programs/ironheart.html", - description="Strength-focused bodyweight program. No equipment, all levels.", - goal_tags=["build_strength"], - activity_tags=["bodyweight"], - equipment_needed="none", - ttm_stages=["preparation", "action"], - difficulty="intermediate", - duration_days=30, - ), - Resource( - name="Darebee Total Body Strength", - source="darebee", - url="https://darebee.com/programs/total-body-strength.html", - description="Comprehensive strength building program using bodyweight.", - goal_tags=["build_strength"], - activity_tags=["bodyweight"], - equipment_needed="none", - ttm_stages=["action", "maintenance"], - difficulty="intermediate", - duration_days=30, - ), - Resource( - name="Darebee 8 Weeks to 5K", - source="darebee", - url="https://darebee.com/programs/8-weeks-to-5k-program.html", - description="Progressive running program from beginner to 5K in 8 weeks.", - goal_tags=["get_active", "lose_weight"], - activity_tags=["running"], - equipment_needed="none", - ttm_stages=["preparation", "action"], - difficulty="beginner", - duration_days=56, - ), - Resource( - name="StrongLifts 5x5", - source="stronglifts", - url="https://stronglifts.com/5x5/", - description="Simple barbell strength program. 3 days/week, 5 exercises, progressive overload.", - goal_tags=["build_strength"], - activity_tags=["weights"], - equipment_needed="full_gym", - ttm_stages=["preparation", "action", "maintenance"], - difficulty="beginner", - duration_days=90, - ), - Resource( - name="Darebee 30 Days of Cardio", - source="darebee", - url="https://darebee.com/programs/30-days-of-cardio.html", - description="30-day cardio program, no equipment. Great for weight loss.", - goal_tags=["lose_weight", "get_active"], - activity_tags=["bodyweight"], - equipment_needed="none", - ttm_stages=["preparation", "action"], - difficulty="beginner", - duration_days=30, - ), - Resource( - name="Darebee POWERBUILDER", - source="darebee", - url="https://darebee.com/programs/powerbuilder-program.html", - description="Advanced strength and power program using bodyweight.", - goal_tags=["build_strength"], - activity_tags=["bodyweight"], - equipment_needed="none", - ttm_stages=["action", "maintenance"], - difficulty="advanced", - duration_days=30, - ), - Resource( - name="Fitness Blender (YouTube)", - source="youtube", - url="https://www.youtube.com/@fitnessblender", - description="Free workout videos for all levels. Huge variety: HIIT, strength, yoga, pilates.", - goal_tags=["lose_weight", "build_strength", "get_active", "feel_better"], - activity_tags=["bodyweight", "yoga"], - equipment_needed="none", - ttm_stages=["contemplation", "preparation", "action", "maintenance"], - difficulty="beginner", - duration_days=None, - ), - Resource( - name="Darebee Yoga Flexibility Program", - source="darebee", - url="https://darebee.com/programs/flexibility-program.html", - description="30-day flexibility and yoga program for beginners.", - goal_tags=["feel_better"], - activity_tags=["yoga"], - equipment_needed="none", - ttm_stages=["precontemplation", "contemplation", "preparation"], - difficulty="beginner", - duration_days=30, - ), - ] - - for r in resources: - db.add(r) - await db.commit() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py deleted file mode 100644 index 8af76b0..0000000 --- a/backend/app/models/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -from app.models.user import User -from app.models.profile import UserProfile -from app.models.program import Program -from app.models.activity import ActivityLog -from app.models.food import FoodLog -from app.models.points import PointRule, DailyTarget -from app.models.reward import Reward, RewardRedemption -from app.models.oauth import OAuthToken -from app.models.resource import Resource -from app.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue, WorkoutLog -from app.models.support import SupportThread, SupportMessage -from app.models.nutrition import NutritionGoal, Fast, ElectrolyteLog -from app.models.integration_config import IntegrationConfig -from app.models.meal_plan import MealPlan, MealPlanItem, MealCompliance - -__all__ = [ - "User", "UserProfile", "Program", "ActivityLog", "FoodLog", - "PointRule", "DailyTarget", "Reward", "RewardRedemption", - "OAuthToken", "Resource", - "ProgramCatalog", "CatalogWorkout", "CrawlQueue", "WorkoutLog", - "SupportThread", "SupportMessage", - "NutritionGoal", "Fast", "ElectrolyteLog", - "IntegrationConfig", "MealPlan", "MealPlanItem", "MealCompliance", -] diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/utils/dates.py b/backend/app/utils/dates.py deleted file mode 100644 index 328fb7d..0000000 --- a/backend/app/utils/dates.py +++ /dev/null @@ -1,20 +0,0 @@ -from __future__ import annotations - -from datetime import date, timedelta - - -def get_week_boundaries(d: date) -> tuple[date, date]: - """Return Monday and Sunday of the week containing date d.""" - monday = d - timedelta(days=d.weekday()) - sunday = monday + timedelta(days=6) - return monday, sunday - - -def get_month_boundaries(year: int, month: int) -> tuple[date, date]: - """Return first and last day of a month.""" - first = date(year, month, 1) - if month == 12: - last = date(year + 1, 1, 1) - timedelta(days=1) - else: - last = date(year, month + 1, 1) - timedelta(days=1) - return first, last diff --git a/backend/requirements.txt b/backend/requirements.txt index b7a7237..b16d3cc 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,12 +1,14 @@ -# Backend +# Core fastapi==0.115.6 uvicorn[standard]==0.34.0 sqlalchemy[asyncio]==2.0.36 -asyncpg==0.30.0 -alembic==1.14.0 pydantic==2.10.3 pydantic-settings==2.7.0 python-jose[cryptography]==3.3.0 bcrypt==4.2.1 httpx==0.28.1 python-multipart==0.0.18 + +# Database drivers +asyncpg==0.30.0 +aiosqlite==0.20.0 diff --git a/diligence/__init__.py b/diligence/__init__.py new file mode 100644 index 0000000..54d4733 --- /dev/null +++ b/diligence/__init__.py @@ -0,0 +1,3 @@ +"""Diligence — self-hosted fitness rewards platform with AI agent integration.""" + +__version__ = "2.0.0" diff --git a/diligence/__main__.py b/diligence/__main__.py new file mode 100644 index 0000000..16dd2f0 --- /dev/null +++ b/diligence/__main__.py @@ -0,0 +1,4 @@ +"""Allow running as: python -m diligence""" +from diligence.cli import main + +main() diff --git a/diligence/cli.py b/diligence/cli.py new file mode 100644 index 0000000..0609968 --- /dev/null +++ b/diligence/cli.py @@ -0,0 +1,122 @@ +"""Diligence CLI — run the fitness app from the command line.""" +from __future__ import annotations + +import argparse +import os +import sys +import secrets +import webbrowser +from pathlib import Path + + +def main(): + parser = argparse.ArgumentParser( + prog="diligence", + description="Diligence — self-hosted fitness rewards platform", + ) + parser.add_argument( + "--port", type=int, default=8000, + help="Port to run on (default: 8000)", + ) + parser.add_argument( + "--host", type=str, default="127.0.0.1", + help="Host to bind to (default: 127.0.0.1)", + ) + parser.add_argument( + "--no-browser", action="store_true", + help="Don't open the browser automatically", + ) + parser.add_argument( + "--no-mcp", action="store_true", + help="Disable the MCP connector", + ) + parser.add_argument( + "--mcp-port", type=int, default=3001, + help="Port for MCP SSE server (default: 3001)", + ) + parser.add_argument( + "--data-dir", type=str, default=None, + help="Data directory (default: ~/.diligence)", + ) + + args = parser.parse_args() + + # Set up data directory + data_dir = Path(args.data_dir) if args.data_dir else Path.home() / ".diligence" + data_dir.mkdir(parents=True, exist_ok=True) + + # Auto-generate secrets on first run + env_file = data_dir / ".env" + api_token = "" + if not env_file.exists(): + secret_key = secrets.token_hex(32) + api_token = secrets.token_hex(32) + env_file.write_text( + f"SECRET_KEY={secret_key}\n" + f"API_TOKEN={api_token}\n" + f"BASE_URL=http://localhost:{args.port}\n" + f"DATA_DIR={data_dir}\n" + ) + print(f"Created config at {env_file}") + + # Point pydantic-settings at the env file + os.environ.setdefault("DATA_DIR", str(data_dir)) + + # Load existing env file + if env_file.exists(): + for line in env_file.read_text().splitlines(): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, _, value = line.partition("=") + os.environ.setdefault(key.strip(), value.strip()) + if key.strip() == "API_TOKEN": + api_token = value.strip() + + if args.no_mcp: + os.environ["MCP_ENABLED"] = "false" + + # Start MCP server in background + mcp_started = False + if not args.no_mcp: + try: + from diligence.mcp import start_mcp_background + mcp_started = start_mcp_background( + api_url=f"http://localhost:{args.port}", + api_token=api_token, + port=args.mcp_port, + ) + except ImportError: + pass + + # Open browser after a short delay + url = f"http://localhost:{args.port}" + if not args.no_browser: + import threading + + def _open(): + import time + time.sleep(2) + webbrowser.open(url) + + threading.Thread(target=_open, daemon=True).start() + + print(f"\n Diligence running at {url}") + print(f" Data: {data_dir}") + if mcp_started: + print(f" MCP: http://localhost:{args.mcp_port}/sse") + elif not args.no_mcp: + print(f" MCP: not available (install with: pip install diligence[mcp])") + print(f" Press Ctrl+C to stop\n") + + # Run uvicorn + import uvicorn + uvicorn.run( + "diligence.main:app", + host=args.host, + port=args.port, + log_level="info", + ) + + +if __name__ == "__main__": + main() diff --git a/diligence/config.py b/diligence/config.py new file mode 100644 index 0000000..6bedfef --- /dev/null +++ b/diligence/config.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from pathlib import Path +from pydantic_settings import BaseSettings +from functools import lru_cache + + +def _default_data_dir() -> Path: + """~/.diligence on all platforms.""" + d = Path.home() / ".diligence" + d.mkdir(exist_ok=True) + return d + + +class Settings(BaseSettings): + # Database — empty string triggers SQLite auto-detect (pip install path) + # Set DATABASE_URL explicitly for PostgreSQL (Docker path) + database_url: str = "" + + # Auth + secret_key: str = "change-me-in-production" + crawl_enabled: bool = False + access_token_expire_minutes: int = 1440 # 24 hours + api_token: str = "" # MCP connector auth + + # Strava + strava_client_id: str = "" + strava_client_secret: str = "" + + # Polar + polar_client_id: str = "" + polar_client_secret: str = "" + + # Groq (program extraction) + groq_api_key: str = "" + + # Telegram (support notifications) + telegram_bot_token: str = "" + telegram_chat_id: str = "" + + # App + base_url: str = "http://localhost:8000" + timezone: str = "UTC" + data_dir: str = str(_default_data_dir()) + + # MCP + mcp_enabled: bool = True + mcp_port: int = 3001 + + model_config = {"env_file": ".env", "extra": "ignore"} + + @property + def effective_database_url(self) -> str: + """Return the database URL, falling back to SQLite if not set.""" + if self.database_url: + return self.database_url + db_path = Path(self.data_dir) / "data.db" + return f"sqlite+aiosqlite:///{db_path}" + + @property + def is_sqlite(self) -> bool: + return self.effective_database_url.startswith("sqlite") + + +@lru_cache +def get_settings() -> Settings: + return Settings() + + +# Module-level shortcut used by services +settings = get_settings() diff --git a/backend/app/database.py b/diligence/database.py similarity index 67% rename from backend/app/database.py rename to diligence/database.py index 615156c..bc432d3 100644 --- a/backend/app/database.py +++ b/diligence/database.py @@ -2,11 +2,17 @@ from __future__ import annotations from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession from sqlalchemy.orm import DeclarativeBase -from app.config import get_settings +from diligence.config import get_settings settings = get_settings() -engine = create_async_engine(settings.database_url, echo=False, pool_size=5, max_overflow=10) +# Build engine with dialect-appropriate settings +_engine_kwargs: dict = {"echo": False} +if not settings.is_sqlite: + # PostgreSQL — connection pooling + _engine_kwargs.update(pool_size=5, max_overflow=10) + +engine = create_async_engine(settings.effective_database_url, **_engine_kwargs) async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) @@ -28,7 +34,7 @@ async def get_db(): async def init_db(): # Import all models so their tables are registered on Base.metadata - import app.models # noqa: F401 + import diligence.models # noqa: F401 async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) diff --git a/diligence/frontend/.well-known/agent-card.json b/diligence/frontend/.well-known/agent-card.json new file mode 100644 index 0000000..d9b04f7 --- /dev/null +++ b/diligence/frontend/.well-known/agent-card.json @@ -0,0 +1,102 @@ +{ + "name": "Diligence Fitness Rewards", + "description": "Self-hosted fitness rewards platform with points-based behavioral economy. Your fitness data stays on your hardware. AI agents can log activities, query progress, manage meal plans, and configure device integrations via MCP.", + "url": "{BASE_URL}/mcp", + "provider": { + "organization": "DiligenceWorks Pte. Ltd.", + "url": "https://diligenceworks.online" + }, + "version": "1.0.0", + "documentationUrl": "https://github.com/diligenceworks/diligence", + "capabilities": { + "streaming": true, + "pushNotifications": false, + "stateTransitionHistory": false + }, + "authentication": { + "schemes": ["apiKey"], + "credentials": null + }, + "defaultInputModes": ["text/plain", "application/json"], + "defaultOutputModes": ["application/json"], + "skills": [ + { + "id": "context-status", + "name": "Context & Status", + "description": "Get full user profile with motivation type (BREQ-2), active program, points status, rewards, and integration connections. Includes get_context (full brief), get_today (daily points/gate), and get_week (weekly summary).", + "tags": ["fitness", "status", "profile", "motivation"], + "examples": [ + "How am I doing today?", + "What's my weekly progress?", + "Show me my fitness dashboard" + ] + }, + { + "id": "activity-logging", + "name": "Activity Logging", + "description": "Record workouts, runs, walks, cycling, yoga, or any physical activity. Awards points based on activity type and duration. Supports structured program workouts via program_day parameter.", + "tags": ["fitness", "tracking", "points", "workout"], + "examples": [ + "I just did a 30-minute run", + "Log my StrongLifts Workout B today", + "I did 45 minutes of yoga this morning" + ] + }, + { + "id": "nutrition-tracking", + "name": "Food & Nutrition", + "description": "Log food intake with calorie and macro tracking. Search Open Food Facts (4M+ products) and USDA FoodData Central (400K+ research-grade items). Awards points for consistent food logging.", + "tags": ["nutrition", "food", "tracking", "barcode", "search"], + "examples": [ + "I had 2 eggs and toast for breakfast", + "Search for chicken breast nutrition", + "Log a salad for lunch, about 400 calories" + ] + }, + { + "id": "program-schedule", + "name": "Program Schedule", + "description": "View today's scheduled workout from the active program including exercises, sets, reps, and weight progression. Shows upcoming workouts and overall program completion percentage.", + "tags": ["program", "schedule", "workout", "planning"], + "examples": [ + "What's my workout today?", + "Show me this week's program schedule", + "How far through my program am I?" + ] + }, + { + "id": "rewards", + "name": "Rewards", + "description": "View available rewards with point costs and spend earned points. Rewards are user-configurable (gaming time, screen time, treats, etc.). Points gate must be passed before redemption.", + "tags": ["rewards", "points", "spending", "gamification"], + "examples": [ + "What rewards can I unlock?", + "Spend points on 30 minutes of gaming", + "Do I have enough points for a movie?" + ] + }, + { + "id": "meal-planning", + "name": "Meal Planning", + "description": "Create, view, and track AI-generated meal plans. The agent generates plan content; the app stores and tracks compliance. Includes load_meal_plan, get_meal_plan, update_meal_compliance, and get_plan_progress.", + "tags": ["nutrition", "meal-plan", "diet", "compliance"], + "examples": [ + "Create a 7-day keto meal plan", + "What am I supposed to eat today?", + "I followed my breakfast plan", + "How's my meal plan compliance?" + ] + }, + { + "id": "device-integration", + "name": "Device Integration", + "description": "Configure connections to fitness devices and services (Strava, Polar, Garmin, Fitbit, WHOOP, Oura, Withings). Write-only credential storage — agent can set but never read credentials back.", + "tags": ["integration", "strava", "garmin", "fitbit", "sync"], + "examples": [ + "Connect my Strava account", + "What integrations are available?", + "I want to set up my Garmin" + ] + } + ] +} diff --git a/diligence/frontend/.well-known/skills/diligence-fitness/SKILL.md b/diligence/frontend/.well-known/skills/diligence-fitness/SKILL.md new file mode 100644 index 0000000..1597825 --- /dev/null +++ b/diligence/frontend/.well-known/skills/diligence-fitness/SKILL.md @@ -0,0 +1,50 @@ +# Diligence — Agent Skill Definition + +## What Is Diligence? + +Diligence is a self-hosted fitness rewards platform. Users earn points through fitness activities (workouts, food logging, step goals) and spend them on configurable rewards (gaming time, screen time, etc.). A daily gate keeps rewards locked until enough points are earned. Points reset weekly. + +## Architecture + +- **Backend:** FastAPI (Python 3.12) + PostgreSQL 16 +- **Frontend:** React 18 + Vite, served by nginx +- **MCP Connector:** FastMCP (Streamable HTTP/SSE) on port 3001, proxied via nginx at `/mcp` +- **Deployment:** Docker Compose (4 services: frontend, backend, mcp-connector, fitness-db) + +## Connecting + +Point your MCP client to: +- **Development:** `http://localhost:3001/sse` +- **Production:** `https://your-domain/mcp` + +## Available Tools (14) + +| Tool | Category | Description | +|------|----------|-------------| +| `get_context()` | Status | Full profile, motivation, program, rules, rewards | +| `get_today(date?)` | Status | Daily points, gate status, activities | +| `get_week(start_date?)` | Status | Weekly summary, active days | +| `log_activity(category, title, duration_minutes, ...)` | Activity | Log workout, earn points | +| `log_food(meal_type, food_name, ...)` | Food | Log food with macros | +| `search_food(query)` | Food | Search Open Food Facts + USDA | +| `get_program_schedule(date?)` | Programs | Today's workout from active program | +| `redeem_reward(reward_name)` | Rewards | Spend points on a reward | +| `load_meal_plan(name, duration_days, meals, ...)` | Meal Plans | Create a full meal plan | +| `get_meal_plan(date?)` | Meal Plans | View today's planned meals | +| `update_meal_compliance(plan_item_id, status, ...)` | Meal Plans | Mark meal followed/skipped | +| `get_plan_progress(plan_id?)` | Meal Plans | Compliance statistics | +| `configure_integration(provider, credentials)` | Integrations | Store encrypted credentials (write-only) | +| `get_integration_status()` | Integrations | Check provider connection status | + +## Key Design Decisions + +- The **agent generates meal plans** — the app only stores and tracks them. Zero AI API cost. +- Integration credentials are **write-only through MCP** — agents can set but never read them back. +- The app collects **BREQ-2 motivation data** during onboarding. Use `get_context()` to read the user's motivation profile and calibrate tone accordingly. +- Points reset weekly. Each week is a fresh start. No guilt carry-over. + +## Source + +- **Repository:** https://github.com/diligenceworks/diligence +- **License:** MIT +- **Built by:** DiligenceWorks Pte. Ltd. (https://diligenceworks.online) diff --git a/diligence/frontend/assets/index-CsKoBN0D.js b/diligence/frontend/assets/index-CsKoBN0D.js new file mode 100644 index 0000000..2fa9c27 --- /dev/null +++ b/diligence/frontend/assets/index-CsKoBN0D.js @@ -0,0 +1,68 @@ +function Gf(o,c){for(var s=0;sd[f]})}}}return Object.freeze(Object.defineProperty(o,Symbol.toStringTag,{value:"Module"}))}(function(){const c=document.createElement("link").relList;if(c&&c.supports&&c.supports("modulepreload"))return;for(const f of document.querySelectorAll('link[rel="modulepreload"]'))d(f);new MutationObserver(f=>{for(const v of f)if(v.type==="childList")for(const g of v.addedNodes)g.tagName==="LINK"&&g.rel==="modulepreload"&&d(g)}).observe(document,{childList:!0,subtree:!0});function s(f){const v={};return f.integrity&&(v.integrity=f.integrity),f.referrerPolicy&&(v.referrerPolicy=f.referrerPolicy),f.crossOrigin==="use-credentials"?v.credentials="include":f.crossOrigin==="anonymous"?v.credentials="omit":v.credentials="same-origin",v}function d(f){if(f.ep)return;f.ep=!0;const v=s(f);fetch(f.href,v)}})();function Oc(o){return o&&o.__esModule&&Object.prototype.hasOwnProperty.call(o,"default")?o.default:o}var Zo={exports:{}},Ir={},ea={exports:{}},ce={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var uc;function Xf(){if(uc)return ce;uc=1;var o=Symbol.for("react.element"),c=Symbol.for("react.portal"),s=Symbol.for("react.fragment"),d=Symbol.for("react.strict_mode"),f=Symbol.for("react.profiler"),v=Symbol.for("react.provider"),g=Symbol.for("react.context"),z=Symbol.for("react.forward_ref"),S=Symbol.for("react.suspense"),T=Symbol.for("react.memo"),P=Symbol.for("react.lazy"),y=Symbol.iterator;function L(w){return w===null||typeof w!="object"?null:(w=y&&w[y]||w["@@iterator"],typeof w=="function"?w:null)}var A={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},R=Object.assign,N={};function j(w,O,ae){this.props=w,this.context=O,this.refs=N,this.updater=ae||A}j.prototype.isReactComponent={},j.prototype.setState=function(w,O){if(typeof w!="object"&&typeof w!="function"&&w!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,w,O,"setState")},j.prototype.forceUpdate=function(w){this.updater.enqueueForceUpdate(this,w,"forceUpdate")};function F(){}F.prototype=j.prototype;function _(w,O,ae){this.props=w,this.context=O,this.refs=N,this.updater=ae||A}var B=_.prototype=new F;B.constructor=_,R(B,j.prototype),B.isPureReactComponent=!0;var W=Array.isArray,Z=Object.prototype.hasOwnProperty,fe={current:null},we={key:!0,ref:!0,__self:!0,__source:!0};function xe(w,O,ae){var ue,se={},b=null,ee=null;if(O!=null)for(ue in O.ref!==void 0&&(ee=O.ref),O.key!==void 0&&(b=""+O.key),O)Z.call(O,ue)&&!we.hasOwnProperty(ue)&&(se[ue]=O[ue]);var ie=arguments.length-2;if(ie===1)se.children=ae;else if(1>>1,O=H[w];if(0>>1;wf(se,V))bf(ee,se)?(H[w]=ee,H[b]=V,w=b):(H[w]=se,H[ue]=V,w=ue);else if(bf(ee,V))H[w]=ee,H[b]=V,w=b;else break e}}return I}function f(H,I){var V=H.sortIndex-I.sortIndex;return V!==0?V:H.id-I.id}if(typeof performance=="object"&&typeof performance.now=="function"){var v=performance;o.unstable_now=function(){return v.now()}}else{var g=Date,z=g.now();o.unstable_now=function(){return g.now()-z}}var S=[],T=[],P=1,y=null,L=3,A=!1,R=!1,N=!1,j=typeof setTimeout=="function"?setTimeout:null,F=typeof clearTimeout=="function"?clearTimeout:null,_=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function B(H){for(var I=s(T);I!==null;){if(I.callback===null)d(T);else if(I.startTime<=H)d(T),I.sortIndex=I.expirationTime,c(S,I);else break;I=s(T)}}function W(H){if(N=!1,B(H),!R)if(s(S)!==null)R=!0,Be(Z);else{var I=s(T);I!==null&&Se(W,I.startTime-H)}}function Z(H,I){R=!1,N&&(N=!1,F(xe),xe=-1),A=!0;var V=L;try{for(B(I),y=s(S);y!==null&&(!(y.expirationTime>I)||H&&!pe());){var w=y.callback;if(typeof w=="function"){y.callback=null,L=y.priorityLevel;var O=w(y.expirationTime<=I);I=o.unstable_now(),typeof O=="function"?y.callback=O:y===s(S)&&d(S),B(I)}else d(S);y=s(S)}if(y!==null)var ae=!0;else{var ue=s(T);ue!==null&&Se(W,ue.startTime-I),ae=!1}return ae}finally{y=null,L=V,A=!1}}var fe=!1,we=null,xe=-1,re=5,oe=-1;function pe(){return!(o.unstable_now()-oeH||125w?(H.sortIndex=V,c(T,H),s(S)===null&&H===s(T)&&(N?(F(xe),xe=-1):N=!0,Se(W,V-w))):(H.sortIndex=O,c(S,H),R||A||(R=!0,Be(Z))),H},o.unstable_shouldYield=pe,o.unstable_wrapCallback=function(H){var I=L;return function(){var V=L;L=I;try{return H.apply(this,arguments)}finally{L=V}}}})(ra)),ra}var mc;function np(){return mc||(mc=1,na.exports=tp()),na.exports}/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var hc;function rp(){if(hc)return lt;hc=1;var o=ca(),c=np();function s(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),S=Object.prototype.hasOwnProperty,T=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,P={},y={};function L(e){return S.call(y,e)?!0:S.call(P,e)?!1:T.test(e)?y[e]=!0:(P[e]=!0,!1)}function A(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function R(e,t,n,r){if(t===null||typeof t>"u"||A(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function N(e,t,n,r,i,a,u){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=i,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=a,this.removeEmptyString=u}var j={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){j[e]=new N(e,0,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];j[t]=new N(t,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){j[e]=new N(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){j[e]=new N(e,2,!1,e,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){j[e]=new N(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){j[e]=new N(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){j[e]=new N(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){j[e]=new N(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){j[e]=new N(e,5,!1,e.toLowerCase(),null,!1,!1)});var F=/[\-:]([a-z])/g;function _(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(F,_);j[t]=new N(t,1,!1,e,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(F,_);j[t]=new N(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(F,_);j[t]=new N(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){j[e]=new N(e,1,!1,e.toLowerCase(),null,!1,!1)}),j.xlinkHref=new N("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){j[e]=new N(e,1,!1,e.toLowerCase(),null,!0,!0)});function B(e,t,n,r){var i=j.hasOwnProperty(t)?j[t]:null;(i!==null?i.type!==0:r||!(2p||i[u]!==a[p]){var h=` +`+i[u].replace(" at new "," at ");return e.displayName&&h.includes("")&&(h=h.replace("",e.displayName)),h}while(1<=u&&0<=p);break}}}finally{ae=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?O(e):""}function se(e){switch(e.tag){case 5:return O(e.type);case 16:return O("Lazy");case 13:return O("Suspense");case 19:return O("SuspenseList");case 0:case 2:case 15:return e=ue(e.type,!1),e;case 11:return e=ue(e.type.render,!1),e;case 1:return e=ue(e.type,!0),e;default:return""}}function b(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case we:return"Fragment";case fe:return"Portal";case re:return"Profiler";case xe:return"StrictMode";case ve:return"Suspense";case Te:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case pe:return(e.displayName||"Context")+".Consumer";case oe:return(e._context.displayName||"Context")+".Provider";case Ne:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case Me:return t=e.displayName||null,t!==null?t:b(e.type)||"Memo";case Be:t=e._payload,e=e._init;try{return b(e(t))}catch{}}return null}function ee(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return b(t);case 8:return t===xe?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function ie(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function de(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function ye(e){var t=de(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var i=n.get,a=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return i.call(this)},set:function(u){r=""+u,a.call(this,u)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(u){r=""+u},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function Or(e){e._valueTracker||(e._valueTracker=ye(e))}function ha(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=de(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function Mr(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function ii(e,t){var n=t.checked;return V({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function ga(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=ie(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function va(e,t){t=t.checked,t!=null&&B(e,"checked",t,!1)}function oi(e,t){va(e,t);var n=ie(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?ai(e,t.type,n):t.hasOwnProperty("defaultValue")&&ai(e,t.type,ie(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function ya(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function ai(e,t,n){(t!=="number"||Mr(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var Jn=Array.isArray;function Sn(e,t,n,r){if(e=e.options,t){t={};for(var i=0;i"+t.valueOf().toString()+"",t=Dr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Gn(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Xn={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Zc=["Webkit","ms","Moz","O"];Object.keys(Xn).forEach(function(e){Zc.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Xn[t]=Xn[e]})});function _a(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||Xn.hasOwnProperty(e)&&Xn[e]?(""+t).trim():t+"px"}function Ca(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,i=_a(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,i):e[n]=i}}var ed=V({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function ci(e,t){if(t){if(ed[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(s(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(s(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(s(61))}if(t.style!=null&&typeof t.style!="object")throw Error(s(62))}}function di(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var fi=null;function pi(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var mi=null,wn=null,jn=null;function Na(e){if(e=xr(e)){if(typeof mi!="function")throw Error(s(280));var t=e.stateNode;t&&(t=ul(t),mi(e.stateNode,e.type,t))}}function Ea(e){wn?jn?jn.push(e):jn=[e]:wn=e}function za(){if(wn){var e=wn,t=jn;if(jn=wn=null,Na(e),t)for(e=0;e>>=0,e===0?32:31-(dd(e)/fd|0)|0}var Hr=64,Qr=4194304;function tr(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Kr(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,i=e.suspendedLanes,a=e.pingedLanes,u=n&268435455;if(u!==0){var p=u&~i;p!==0?r=tr(p):(a&=u,a!==0&&(r=tr(a)))}else u=n&~i,u!==0?r=tr(u):a!==0&&(r=tr(a));if(r===0)return 0;if(t!==0&&t!==r&&(t&i)===0&&(i=r&-r,a=t&-t,i>=a||i===16&&(a&4194240)!==0))return t;if((r&4)!==0&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function nr(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-ht(t),e[t]=n}function gd(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=cr),ns=" ",rs=!1;function ls(e,t){switch(e){case"keyup":return Vd.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function is(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Cn=!1;function Qd(e,t){switch(e){case"compositionend":return is(t);case"keypress":return t.which!==32?null:(rs=!0,ns);case"textInput":return e=t.data,e===ns&&rs?null:e;default:return null}}function Kd(e,t){if(Cn)return e==="compositionend"||!Ri&&ls(e,t)?(e=Ga(),qr=Ni=$t=null,Cn=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=fs(n)}}function ms(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?ms(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function hs(){for(var e=window,t=Mr();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=Mr(e.document)}return t}function Fi(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function nf(e){var t=hs(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&ms(n.ownerDocument.documentElement,n)){if(r!==null&&Fi(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var i=n.textContent.length,a=Math.min(r.start,i);r=r.end===void 0?a:Math.min(r.end,i),!e.extend&&a>r&&(i=r,r=a,a=i),i=ps(n,a);var u=ps(n,r);i&&u&&(e.rangeCount!==1||e.anchorNode!==i.node||e.anchorOffset!==i.offset||e.focusNode!==u.node||e.focusOffset!==u.offset)&&(t=t.createRange(),t.setStart(i.node,i.offset),e.removeAllRanges(),a>r?(e.addRange(t),e.extend(u.node,u.offset)):(t.setEnd(u.node,u.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,Nn=null,Bi=null,mr=null,Wi=!1;function gs(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Wi||Nn==null||Nn!==Mr(r)||(r=Nn,"selectionStart"in r&&Fi(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),mr&&pr(mr,r)||(mr=r,r=ol(Bi,"onSelect"),0bn||(e.current=Ji[bn],Ji[bn]=null,bn--)}function je(e,t){bn++,Ji[bn]=e.current,e.current=t}var Qt={},Qe=Ht(Qt),Ze=Ht(!1),sn=Qt;function Rn(e,t){var n=e.type.contextTypes;if(!n)return Qt;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var i={},a;for(a in n)i[a]=t[a];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=i),i}function et(e){return e=e.childContextTypes,e!=null}function cl(){_e(Ze),_e(Qe)}function bs(e,t,n){if(Qe.current!==Qt)throw Error(s(168));je(Qe,t),je(Ze,n)}function Rs(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var i in r)if(!(i in t))throw Error(s(108,ee(e)||"Unknown",i));return V({},n,r)}function dl(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Qt,sn=Qe.current,je(Qe,e),je(Ze,Ze.current),!0}function Ls(e,t,n){var r=e.stateNode;if(!r)throw Error(s(169));n?(e=Rs(e,t,sn),r.__reactInternalMemoizedMergedChildContext=e,_e(Ze),_e(Qe),je(Qe,e)):_e(Ze),je(Ze,n)}var zt=null,fl=!1,Gi=!1;function Is(e){zt===null?zt=[e]:zt.push(e)}function hf(e){fl=!0,Is(e)}function Kt(){if(!Gi&&zt!==null){Gi=!0;var e=0,t=ge;try{var n=zt;for(ge=1;e>=u,i-=u,Pt=1<<32-ht(t)+i|n<le?($e=ne,ne=null):$e=ne.sibling;var he=M(k,ne,C[le],U);if(he===null){ne===null&&(ne=$e);break}e&&ne&&he.alternate===null&&t(k,ne),x=a(he,x,le),te===null?q=he:te.sibling=he,te=he,ne=$e}if(le===C.length)return n(k,ne),Ce&&cn(k,le),q;if(ne===null){for(;lele?($e=ne,ne=null):$e=ne.sibling;var nn=M(k,ne,he.value,U);if(nn===null){ne===null&&(ne=$e);break}e&&ne&&nn.alternate===null&&t(k,ne),x=a(nn,x,le),te===null?q=nn:te.sibling=nn,te=nn,ne=$e}if(he.done)return n(k,ne),Ce&&cn(k,le),q;if(ne===null){for(;!he.done;le++,he=C.next())he=$(k,he.value,U),he!==null&&(x=a(he,x,le),te===null?q=he:te.sibling=he,te=he);return Ce&&cn(k,le),q}for(ne=r(k,ne);!he.done;le++,he=C.next())he=Q(ne,k,le,he.value,U),he!==null&&(e&&he.alternate!==null&&ne.delete(he.key===null?le:he.key),x=a(he,x,le),te===null?q=he:te.sibling=he,te=he);return e&&ne.forEach(function(Jf){return t(k,Jf)}),Ce&&cn(k,le),q}function Le(k,x,C,U){if(typeof C=="object"&&C!==null&&C.type===we&&C.key===null&&(C=C.props.children),typeof C=="object"&&C!==null){switch(C.$$typeof){case Z:e:{for(var q=C.key,te=x;te!==null;){if(te.key===q){if(q=C.type,q===we){if(te.tag===7){n(k,te.sibling),x=i(te,C.props.children),x.return=k,k=x;break e}}else if(te.elementType===q||typeof q=="object"&&q!==null&&q.$$typeof===Be&&Ds(q)===te.type){n(k,te.sibling),x=i(te,C.props),x.ref=Sr(k,te,C),x.return=k,k=x;break e}n(k,te);break}else t(k,te);te=te.sibling}C.type===we?(x=yn(C.props.children,k.mode,U,C.key),x.return=k,k=x):(U=Dl(C.type,C.key,C.props,null,k.mode,U),U.ref=Sr(k,x,C),U.return=k,k=U)}return u(k);case fe:e:{for(te=C.key;x!==null;){if(x.key===te)if(x.tag===4&&x.stateNode.containerInfo===C.containerInfo&&x.stateNode.implementation===C.implementation){n(k,x.sibling),x=i(x,C.children||[]),x.return=k,k=x;break e}else{n(k,x);break}else t(k,x);x=x.sibling}x=Yo(C,k.mode,U),x.return=k,k=x}return u(k);case Be:return te=C._init,Le(k,x,te(C._payload),U)}if(Jn(C))return G(k,x,C,U);if(I(C))return X(k,x,C,U);gl(k,C)}return typeof C=="string"&&C!==""||typeof C=="number"?(C=""+C,x!==null&&x.tag===6?(n(k,x.sibling),x=i(x,C),x.return=k,k=x):(n(k,x),x=Ko(C,k.mode,U),x.return=k,k=x),u(k)):n(k,x)}return Le}var Bn=As(!0),$s=As(!1),vl=Ht(null),yl=null,Wn=null,no=null;function ro(){no=Wn=yl=null}function lo(e){var t=vl.current;_e(vl),e._currentValue=t}function io(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function On(e,t){yl=e,no=Wn=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&t)!==0&&(tt=!0),e.firstContext=null)}function dt(e){var t=e._currentValue;if(no!==e)if(e={context:e,memoizedValue:t,next:null},Wn===null){if(yl===null)throw Error(s(308));Wn=e,yl.dependencies={lanes:0,firstContext:e}}else Wn=Wn.next=e;return t}var dn=null;function oo(e){dn===null?dn=[e]:dn.push(e)}function Us(e,t,n,r){var i=t.interleaved;return i===null?(n.next=n,oo(t)):(n.next=i.next,i.next=n),t.interleaved=n,bt(e,r)}function bt(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var Yt=!1;function ao(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Vs(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Rt(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function Jt(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,(me&2)!==0){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,bt(e,n)}return i=r.interleaved,i===null?(t.next=t,oo(r)):(t.next=i.next,i.next=t),r.interleaved=t,bt(e,n)}function xl(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,wi(e,n)}}function Hs(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var u={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};a===null?i=a=u:a=a.next=u,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function Sl(e,t,n,r){var i=e.updateQueue;Yt=!1;var a=i.firstBaseUpdate,u=i.lastBaseUpdate,p=i.shared.pending;if(p!==null){i.shared.pending=null;var h=p,E=h.next;h.next=null,u===null?a=E:u.next=E,u=h;var D=e.alternate;D!==null&&(D=D.updateQueue,p=D.lastBaseUpdate,p!==u&&(p===null?D.firstBaseUpdate=E:p.next=E,D.lastBaseUpdate=h))}if(a!==null){var $=i.baseState;u=0,D=E=h=null,p=a;do{var M=p.lane,Q=p.eventTime;if((r&M)===M){D!==null&&(D=D.next={eventTime:Q,lane:0,tag:p.tag,payload:p.payload,callback:p.callback,next:null});e:{var G=e,X=p;switch(M=t,Q=n,X.tag){case 1:if(G=X.payload,typeof G=="function"){$=G.call(Q,$,M);break e}$=G;break e;case 3:G.flags=G.flags&-65537|128;case 0:if(G=X.payload,M=typeof G=="function"?G.call(Q,$,M):G,M==null)break e;$=V({},$,M);break e;case 2:Yt=!0}}p.callback!==null&&p.lane!==0&&(e.flags|=64,M=i.effects,M===null?i.effects=[p]:M.push(p))}else Q={eventTime:Q,lane:M,tag:p.tag,payload:p.payload,callback:p.callback,next:null},D===null?(E=D=Q,h=$):D=D.next=Q,u|=M;if(p=p.next,p===null){if(p=i.shared.pending,p===null)break;M=p,p=M.next,M.next=null,i.lastBaseUpdate=M,i.shared.pending=null}}while(!0);if(D===null&&(h=$),i.baseState=h,i.firstBaseUpdate=E,i.lastBaseUpdate=D,t=i.shared.interleaved,t!==null){i=t;do u|=i.lane,i=i.next;while(i!==t)}else a===null&&(i.shared.lanes=0);mn|=u,e.lanes=u,e.memoizedState=$}}function Qs(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=po.transition;po.transition={};try{e(!1),t()}finally{ge=n,po.transition=r}}function du(){return ft().memoizedState}function xf(e,t,n){var r=Zt(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},fu(e))pu(t,n);else if(n=Us(e,t,n,r),n!==null){var i=qe();wt(n,e,r,i),mu(n,t,r)}}function Sf(e,t,n){var r=Zt(e),i={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(fu(e))pu(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var u=t.lastRenderedState,p=a(u,n);if(i.hasEagerState=!0,i.eagerState=p,gt(p,u)){var h=t.interleaved;h===null?(i.next=i,oo(t)):(i.next=h.next,h.next=i),t.interleaved=i;return}}catch{}finally{}n=Us(e,t,i,r),n!==null&&(i=qe(),wt(n,e,r,i),mu(n,t,r))}}function fu(e){var t=e.alternate;return e===ze||t!==null&&t===ze}function pu(e,t){_r=kl=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function mu(e,t,n){if((n&4194240)!==0){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,wi(e,n)}}var Nl={readContext:dt,useCallback:Ke,useContext:Ke,useEffect:Ke,useImperativeHandle:Ke,useInsertionEffect:Ke,useLayoutEffect:Ke,useMemo:Ke,useReducer:Ke,useRef:Ke,useState:Ke,useDebugValue:Ke,useDeferredValue:Ke,useTransition:Ke,useMutableSource:Ke,useSyncExternalStore:Ke,useId:Ke,unstable_isNewReconciler:!1},wf={readContext:dt,useCallback:function(e,t){return Ct().memoizedState=[e,t===void 0?null:t],e},useContext:dt,useEffect:ru,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,_l(4194308,4,ou.bind(null,t,e),n)},useLayoutEffect:function(e,t){return _l(4194308,4,e,t)},useInsertionEffect:function(e,t){return _l(4,2,e,t)},useMemo:function(e,t){var n=Ct();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=Ct();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=xf.bind(null,ze,e),[r.memoizedState,e]},useRef:function(e){var t=Ct();return e={current:e},t.memoizedState=e},useState:tu,useDebugValue:So,useDeferredValue:function(e){return Ct().memoizedState=e},useTransition:function(){var e=tu(!1),t=e[0];return e=yf.bind(null,e[1]),Ct().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=ze,i=Ct();if(Ce){if(n===void 0)throw Error(s(407));n=n()}else{if(n=t(),Ae===null)throw Error(s(349));(pn&30)!==0||Gs(r,t,n)}i.memoizedState=n;var a={value:n,getSnapshot:t};return i.queue=a,ru(qs.bind(null,r,a,e),[e]),r.flags|=2048,Er(9,Xs.bind(null,r,a,n,t),void 0,null),n},useId:function(){var e=Ct(),t=Ae.identifierPrefix;if(Ce){var n=Tt,r=Pt;n=(r&~(1<<32-ht(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=Cr++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=u.createElement(n,{is:r.is}):(e=u.createElement(n),n==="select"&&(u=e,r.multiple?u.multiple=!0:r.size&&(u.size=r.size))):e=u.createElementNS(e,n),e[kt]=t,e[yr]=r,Iu(e,t,!1,!1),t.stateNode=e;e:{switch(u=di(n,r),n){case"dialog":ke("cancel",e),ke("close",e),i=r;break;case"iframe":case"object":case"embed":ke("load",e),i=r;break;case"video":case"audio":for(i=0;iUn&&(t.flags|=128,r=!0,zr(a,!1),t.lanes=4194304)}else{if(!r)if(e=wl(u),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),zr(a,!0),a.tail===null&&a.tailMode==="hidden"&&!u.alternate&&!Ce)return Ye(t),null}else 2*Re()-a.renderingStartTime>Un&&n!==1073741824&&(t.flags|=128,r=!0,zr(a,!1),t.lanes=4194304);a.isBackwards?(u.sibling=t.child,t.child=u):(n=a.last,n!==null?n.sibling=u:t.child=u,a.last=u)}return a.tail!==null?(t=a.tail,a.rendering=t,a.tail=t.sibling,a.renderingStartTime=Re(),t.sibling=null,n=Ee.current,je(Ee,r?n&1|2:n&1),t):(Ye(t),null);case 22:case 23:return Vo(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&(t.mode&1)!==0?(st&1073741824)!==0&&(Ye(t),t.subtreeFlags&6&&(t.flags|=8192)):Ye(t),null;case 24:return null;case 25:return null}throw Error(s(156,t.tag))}function Pf(e,t){switch(qi(t),t.tag){case 1:return et(t.type)&&cl(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Mn(),_e(Ze),_e(Qe),fo(),e=t.flags,(e&65536)!==0&&(e&128)===0?(t.flags=e&-65537|128,t):null;case 5:return uo(t),null;case 13:if(_e(Ee),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(s(340));Fn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return _e(Ee),null;case 4:return Mn(),null;case 10:return lo(t.type._context),null;case 22:case 23:return Vo(),null;case 24:return null;default:return null}}var Tl=!1,Je=!1,Tf=typeof WeakSet=="function"?WeakSet:Set,J=null;function An(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){be(e,t,r)}else n.current=null}function Ro(e,t,n){try{n()}catch(r){be(e,t,r)}}var Wu=!1;function bf(e,t){if(Ui=Gr,e=hs(),Fi(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var i=r.anchorOffset,a=r.focusNode;r=r.focusOffset;try{n.nodeType,a.nodeType}catch{n=null;break e}var u=0,p=-1,h=-1,E=0,D=0,$=e,M=null;t:for(;;){for(var Q;$!==n||i!==0&&$.nodeType!==3||(p=u+i),$!==a||r!==0&&$.nodeType!==3||(h=u+r),$.nodeType===3&&(u+=$.nodeValue.length),(Q=$.firstChild)!==null;)M=$,$=Q;for(;;){if($===e)break t;if(M===n&&++E===i&&(p=u),M===a&&++D===r&&(h=u),(Q=$.nextSibling)!==null)break;$=M,M=$.parentNode}$=Q}n=p===-1||h===-1?null:{start:p,end:h}}else n=null}n=n||{start:0,end:0}}else n=null;for(Vi={focusedElem:e,selectionRange:n},Gr=!1,J=t;J!==null;)if(t=J,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,J=e;else for(;J!==null;){t=J;try{var G=t.alternate;if((t.flags&1024)!==0)switch(t.tag){case 0:case 11:case 15:break;case 1:if(G!==null){var X=G.memoizedProps,Le=G.memoizedState,k=t.stateNode,x=k.getSnapshotBeforeUpdate(t.elementType===t.type?X:yt(t.type,X),Le);k.__reactInternalSnapshotBeforeUpdate=x}break;case 3:var C=t.stateNode.containerInfo;C.nodeType===1?C.textContent="":C.nodeType===9&&C.documentElement&&C.removeChild(C.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(s(163))}}catch(U){be(t,t.return,U)}if(e=t.sibling,e!==null){e.return=t.return,J=e;break}J=t.return}return G=Wu,Wu=!1,G}function Pr(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var i=r=r.next;do{if((i.tag&e)===e){var a=i.destroy;i.destroy=void 0,a!==void 0&&Ro(t,n,a)}i=i.next}while(i!==r)}}function bl(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function Lo(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function Ou(e){var t=e.alternate;t!==null&&(e.alternate=null,Ou(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[kt],delete t[yr],delete t[Yi],delete t[pf],delete t[mf])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Mu(e){return e.tag===5||e.tag===3||e.tag===4}function Du(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Mu(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Io(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=sl));else if(r!==4&&(e=e.child,e!==null))for(Io(e,t,n),e=e.sibling;e!==null;)Io(e,t,n),e=e.sibling}function Fo(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Fo(e,t,n),e=e.sibling;e!==null;)Fo(e,t,n),e=e.sibling}var Ve=null,xt=!1;function Gt(e,t,n){for(n=n.child;n!==null;)Au(e,t,n),n=n.sibling}function Au(e,t,n){if(jt&&typeof jt.onCommitFiberUnmount=="function")try{jt.onCommitFiberUnmount(Vr,n)}catch{}switch(n.tag){case 5:Je||An(n,t);case 6:var r=Ve,i=xt;Ve=null,Gt(e,t,n),Ve=r,xt=i,Ve!==null&&(xt?(e=Ve,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):Ve.removeChild(n.stateNode));break;case 18:Ve!==null&&(xt?(e=Ve,n=n.stateNode,e.nodeType===8?Ki(e.parentNode,n):e.nodeType===1&&Ki(e,n),ar(e)):Ki(Ve,n.stateNode));break;case 4:r=Ve,i=xt,Ve=n.stateNode.containerInfo,xt=!0,Gt(e,t,n),Ve=r,xt=i;break;case 0:case 11:case 14:case 15:if(!Je&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){i=r=r.next;do{var a=i,u=a.destroy;a=a.tag,u!==void 0&&((a&2)!==0||(a&4)!==0)&&Ro(n,t,u),i=i.next}while(i!==r)}Gt(e,t,n);break;case 1:if(!Je&&(An(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(p){be(n,t,p)}Gt(e,t,n);break;case 21:Gt(e,t,n);break;case 22:n.mode&1?(Je=(r=Je)||n.memoizedState!==null,Gt(e,t,n),Je=r):Gt(e,t,n);break;default:Gt(e,t,n)}}function $u(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new Tf),t.forEach(function(r){var i=Df.bind(null,e,r);n.has(r)||(n.add(r),r.then(i,i))})}}function St(e,t){var n=t.deletions;if(n!==null)for(var r=0;ri&&(i=u),r&=~a}if(r=i,r=Re()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*Lf(r/1960))-r,10e?16:e,qt===null)var r=!1;else{if(e=qt,qt=null,Bl=0,(me&6)!==0)throw Error(s(331));var i=me;for(me|=4,J=e.current;J!==null;){var a=J,u=a.child;if((J.flags&16)!==0){var p=a.deletions;if(p!==null){for(var h=0;hRe()-Oo?gn(e,0):Wo|=n),rt(e,t)}function tc(e,t){t===0&&((e.mode&1)===0?t=1:(t=Qr,Qr<<=1,(Qr&130023424)===0&&(Qr=4194304)));var n=qe();e=bt(e,t),e!==null&&(nr(e,t,n),rt(e,n))}function Mf(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),tc(e,n)}function Df(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,i=e.memoizedState;i!==null&&(n=i.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(s(314))}r!==null&&r.delete(t),tc(e,n)}var nc;nc=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||Ze.current)tt=!0;else{if((e.lanes&n)===0&&(t.flags&128)===0)return tt=!1,Ef(e,t,n);tt=(e.flags&131072)!==0}else tt=!1,Ce&&(t.flags&1048576)!==0&&Fs(t,ml,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;Pl(e,t),e=t.pendingProps;var i=Rn(t,Qe.current);On(t,n),i=ho(null,t,r,e,i,n);var a=go();return t.flags|=1,typeof i=="object"&&i!==null&&typeof i.render=="function"&&i.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,et(r)?(a=!0,dl(t)):a=!1,t.memoizedState=i.state!==null&&i.state!==void 0?i.state:null,ao(t),i.updater=El,t.stateNode=i,i._reactInternals=t,jo(t,r,e,n),t=No(null,t,r,!0,a,n)):(t.tag=0,Ce&&a&&Xi(t),Xe(null,t,i,n),t=t.child),t;case 16:r=t.elementType;e:{switch(Pl(e,t),e=t.pendingProps,i=r._init,r=i(r._payload),t.type=r,i=t.tag=$f(r),e=yt(r,e),i){case 0:t=Co(null,t,r,e,n);break e;case 1:t=zu(null,t,r,e,n);break e;case 11:t=ku(null,t,r,e,n);break e;case 14:t=_u(null,t,r,yt(r.type,e),n);break e}throw Error(s(306,r,""))}return t;case 0:return r=t.type,i=t.pendingProps,i=t.elementType===r?i:yt(r,i),Co(e,t,r,i,n);case 1:return r=t.type,i=t.pendingProps,i=t.elementType===r?i:yt(r,i),zu(e,t,r,i,n);case 3:e:{if(Pu(t),e===null)throw Error(s(387));r=t.pendingProps,a=t.memoizedState,i=a.element,Vs(e,t),Sl(t,r,null,n);var u=t.memoizedState;if(r=u.element,a.isDehydrated)if(a={element:r,isDehydrated:!1,cache:u.cache,pendingSuspenseBoundaries:u.pendingSuspenseBoundaries,transitions:u.transitions},t.updateQueue.baseState=a,t.memoizedState=a,t.flags&256){i=Dn(Error(s(423)),t),t=Tu(e,t,r,n,i);break e}else if(r!==i){i=Dn(Error(s(424)),t),t=Tu(e,t,r,n,i);break e}else for(at=Vt(t.stateNode.containerInfo.firstChild),ot=t,Ce=!0,vt=null,n=$s(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(Fn(),r===i){t=Lt(e,t,n);break e}Xe(e,t,r,n)}t=t.child}return t;case 5:return Ks(t),e===null&&eo(t),r=t.type,i=t.pendingProps,a=e!==null?e.memoizedProps:null,u=i.children,Hi(r,i)?u=null:a!==null&&Hi(r,a)&&(t.flags|=32),Eu(e,t),Xe(e,t,u,n),t.child;case 6:return e===null&&eo(t),null;case 13:return bu(e,t,n);case 4:return so(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=Bn(t,null,r,n):Xe(e,t,r,n),t.child;case 11:return r=t.type,i=t.pendingProps,i=t.elementType===r?i:yt(r,i),ku(e,t,r,i,n);case 7:return Xe(e,t,t.pendingProps,n),t.child;case 8:return Xe(e,t,t.pendingProps.children,n),t.child;case 12:return Xe(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,i=t.pendingProps,a=t.memoizedProps,u=i.value,je(vl,r._currentValue),r._currentValue=u,a!==null)if(gt(a.value,u)){if(a.children===i.children&&!Ze.current){t=Lt(e,t,n);break e}}else for(a=t.child,a!==null&&(a.return=t);a!==null;){var p=a.dependencies;if(p!==null){u=a.child;for(var h=p.firstContext;h!==null;){if(h.context===r){if(a.tag===1){h=Rt(-1,n&-n),h.tag=2;var E=a.updateQueue;if(E!==null){E=E.shared;var D=E.pending;D===null?h.next=h:(h.next=D.next,D.next=h),E.pending=h}}a.lanes|=n,h=a.alternate,h!==null&&(h.lanes|=n),io(a.return,n,t),p.lanes|=n;break}h=h.next}}else if(a.tag===10)u=a.type===t.type?null:a.child;else if(a.tag===18){if(u=a.return,u===null)throw Error(s(341));u.lanes|=n,p=u.alternate,p!==null&&(p.lanes|=n),io(u,n,t),u=a.sibling}else u=a.child;if(u!==null)u.return=a;else for(u=a;u!==null;){if(u===t){u=null;break}if(a=u.sibling,a!==null){a.return=u.return,u=a;break}u=u.return}a=u}Xe(e,t,i.children,n),t=t.child}return t;case 9:return i=t.type,r=t.pendingProps.children,On(t,n),i=dt(i),r=r(i),t.flags|=1,Xe(e,t,r,n),t.child;case 14:return r=t.type,i=yt(r,t.pendingProps),i=yt(r.type,i),_u(e,t,r,i,n);case 15:return Cu(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,i=t.pendingProps,i=t.elementType===r?i:yt(r,i),Pl(e,t),t.tag=1,et(r)?(e=!0,dl(t)):e=!1,On(t,n),gu(t,r,i),jo(t,r,i,n),No(null,t,r,!0,e,n);case 19:return Lu(e,t,n);case 22:return Nu(e,t,n)}throw Error(s(156,t.tag))};function rc(e,t){return Ba(e,t)}function Af(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function mt(e,t,n,r){return new Af(e,t,n,r)}function Qo(e){return e=e.prototype,!(!e||!e.isReactComponent)}function $f(e){if(typeof e=="function")return Qo(e)?1:0;if(e!=null){if(e=e.$$typeof,e===Ne)return 11;if(e===Me)return 14}return 2}function tn(e,t){var n=e.alternate;return n===null?(n=mt(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Dl(e,t,n,r,i,a){var u=2;if(r=e,typeof e=="function")Qo(e)&&(u=1);else if(typeof e=="string")u=5;else e:switch(e){case we:return yn(n.children,i,a,t);case xe:u=8,i|=8;break;case re:return e=mt(12,n,t,i|2),e.elementType=re,e.lanes=a,e;case ve:return e=mt(13,n,t,i),e.elementType=ve,e.lanes=a,e;case Te:return e=mt(19,n,t,i),e.elementType=Te,e.lanes=a,e;case Se:return Al(n,i,a,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case oe:u=10;break e;case pe:u=9;break e;case Ne:u=11;break e;case Me:u=14;break e;case Be:u=16,r=null;break e}throw Error(s(130,e==null?e:typeof e,""))}return t=mt(u,n,t,i),t.elementType=e,t.type=r,t.lanes=a,t}function yn(e,t,n,r){return e=mt(7,e,r,t),e.lanes=n,e}function Al(e,t,n,r){return e=mt(22,e,r,t),e.elementType=Se,e.lanes=n,e.stateNode={isHidden:!1},e}function Ko(e,t,n){return e=mt(6,e,null,t),e.lanes=n,e}function Yo(e,t,n){return t=mt(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Uf(e,t,n,r,i){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Si(0),this.expirationTimes=Si(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Si(0),this.identifierPrefix=r,this.onRecoverableError=i,this.mutableSourceEagerHydrationData=null}function Jo(e,t,n,r,i,a,u,p,h){return e=new Uf(e,t,n,p,h),t===1?(t=1,a===!0&&(t|=8)):t=0,a=mt(3,null,null,t),e.current=a,a.stateNode=e,a.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},ao(a),e}function Vf(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(o)}catch(c){console.error(c)}}return o(),ta.exports=rp(),ta.exports}var vc;function lp(){if(vc)return Yl;vc=1;var o=Dc();return Yl.createRoot=o.createRoot,Yl.hydrateRoot=o.hydrateRoot,Yl}var ip=lp();const op=Oc(ip);Dc();/** + * @remix-run/router v1.23.3 + * + * Copyright (c) Remix Software Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE.md file in the root directory of this source tree. + * + * @license MIT + */function Br(){return Br=Object.assign?Object.assign.bind():function(o){for(var c=1;c"u")throw new Error(c)}function da(o,c){if(!o){typeof console<"u"&&console.warn(c);try{throw new Error(c)}catch{}}}function sp(){return Math.random().toString(36).substr(2,8)}function xc(o,c){return{usr:o.state,key:o.key,idx:c}}function ia(o,c,s,d){return s===void 0&&(s=null),Br({pathname:typeof o=="string"?o:o.pathname,search:"",hash:""},typeof c=="string"?Kn(c):c,{state:s,key:c&&c.key||d||sp()})}function Zl(o){let{pathname:c="/",search:s="",hash:d=""}=o;return s&&s!=="?"&&(c+=s.charAt(0)==="?"?s:"?"+s),d&&d!=="#"&&(c+=d.charAt(0)==="#"?d:"#"+d),c}function Kn(o){let c={};if(o){let s=o.indexOf("#");s>=0&&(c.hash=o.substr(s),o=o.substr(0,s));let d=o.indexOf("?");d>=0&&(c.search=o.substr(d),o=o.substr(0,d)),o&&(c.pathname=o)}return c}function up(o,c,s,d){d===void 0&&(d={});let{window:f=document.defaultView,v5Compat:v=!1}=d,g=f.history,z=rn.Pop,S=null,T=P();T==null&&(T=0,g.replaceState(Br({},g.state,{idx:T}),""));function P(){return(g.state||{idx:null}).idx}function y(){z=rn.Pop;let j=P(),F=j==null?null:j-T;T=j,S&&S({action:z,location:N.location,delta:F})}function L(j,F){z=rn.Push;let _=ia(N.location,j,F);T=P()+1;let B=xc(_,T),W=N.createHref(_);try{g.pushState(B,"",W)}catch(Z){if(Z instanceof DOMException&&Z.name==="DataCloneError")throw Z;f.location.assign(W)}v&&S&&S({action:z,location:N.location,delta:1})}function A(j,F){z=rn.Replace;let _=ia(N.location,j,F);T=P();let B=xc(_,T),W=N.createHref(_);g.replaceState(B,"",W),v&&S&&S({action:z,location:N.location,delta:0})}function R(j){let F=f.location.origin!=="null"?f.location.origin:f.location.href,_=typeof j=="string"?j:Zl(j);return _=_.replace(/ $/,"%20"),Pe(F,"No window.location.(origin|href) available to create URL for href: "+_),new URL(_,F)}let N={get action(){return z},get location(){return o(f,g)},listen(j){if(S)throw new Error("A history only accepts one active listener");return f.addEventListener(yc,y),S=j,()=>{f.removeEventListener(yc,y),S=null}},createHref(j){return c(f,j)},createURL:R,encodeLocation(j){let F=R(j);return{pathname:F.pathname,search:F.search,hash:F.hash}},push:L,replace:A,go(j){return g.go(j)}};return N}var Sc;(function(o){o.data="data",o.deferred="deferred",o.redirect="redirect",o.error="error"})(Sc||(Sc={}));function cp(o,c,s){return s===void 0&&(s="/"),dp(o,c,s)}function dp(o,c,s,d){let f=typeof c=="string"?Kn(c):c,v=Qn(f.pathname||"/",s);if(v==null)return null;let g=Ac(o);fp(g);let z=null,S=kp(v);for(let T=0;z==null&&T{let S={relativePath:z===void 0?v.path||"":z,caseSensitive:v.caseSensitive===!0,childrenIndex:g,route:v};S.relativePath.startsWith("/")&&(Pe(S.relativePath.startsWith(d),'Absolute route path "'+S.relativePath+'" nested under path '+('"'+d+'" is not valid. An absolute child route path ')+"must start with the combined path of all its parent routes."),S.relativePath=S.relativePath.slice(d.length));let T=ln([d,S.relativePath]),P=s.concat(S);v.children&&v.children.length>0&&(Pe(v.index!==!0,"Index routes must not have child routes. Please remove "+('all child routes from route path "'+T+'".')),Ac(v.children,c,P,T)),!(v.path==null&&!v.index)&&c.push({path:T,score:xp(T,v.index),routesMeta:P})};return o.forEach((v,g)=>{var z;if(v.path===""||!((z=v.path)!=null&&z.includes("?")))f(v,g);else for(let S of $c(v.path))f(v,g,S)}),c}function $c(o){let c=o.split("/");if(c.length===0)return[];let[s,...d]=c,f=s.endsWith("?"),v=s.replace(/\?$/,"");if(d.length===0)return f?[v,""]:[v];let g=$c(d.join("/")),z=[];return z.push(...g.map(S=>S===""?v:[v,S].join("/"))),f&&z.push(...g),z.map(S=>o.startsWith("/")&&S===""?"/":S)}function fp(o){o.sort((c,s)=>c.score!==s.score?s.score-c.score:Sp(c.routesMeta.map(d=>d.childrenIndex),s.routesMeta.map(d=>d.childrenIndex)))}const pp=/^:[\w-]+$/,mp=3,hp=2,gp=1,vp=10,yp=-2,wc=o=>o==="*";function xp(o,c){let s=o.split("/"),d=s.length;return s.some(wc)&&(d+=yp),c&&(d+=hp),s.filter(f=>!wc(f)).reduce((f,v)=>f+(pp.test(v)?mp:v===""?gp:vp),d)}function Sp(o,c){return o.length===c.length&&o.slice(0,-1).every((d,f)=>d===c[f])?o[o.length-1]-c[c.length-1]:0}function wp(o,c,s){let{routesMeta:d}=o,f={},v="/",g=[];for(let z=0;z{let{paramName:L,isOptional:A}=P;if(L==="*"){let N=z[y]||"";g=v.slice(0,v.length-N.length).replace(/(.)\/+$/,"$1")}const R=z[y];return A&&!R?T[L]=void 0:T[L]=(R||"").replace(/%2F/g,"/"),T},{}),pathname:v,pathnameBase:g,pattern:o}}function jp(o,c,s){c===void 0&&(c=!1),s===void 0&&(s=!0),da(o==="*"||!o.endsWith("*")||o.endsWith("/*"),'Route path "'+o+'" will be treated as if it were '+('"'+o.replace(/\*$/,"/*")+'" because the `*` character must ')+"always follow a `/` in the pattern. To get rid of this warning, "+('please change the route path to "'+o.replace(/\*$/,"/*")+'".'));let d=[],f="^"+o.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(g,z,S)=>(d.push({paramName:z,isOptional:S!=null}),S?"/?([^\\/]+)?":"/([^\\/]+)"));return o.endsWith("*")?(d.push({paramName:"*"}),f+=o==="*"||o==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):s?f+="\\/*$":o!==""&&o!=="/"&&(f+="(?:(?=\\/|$))"),[new RegExp(f,c?void 0:"i"),d]}function kp(o){try{return o.split("/").map(c=>decodeURIComponent(c).replace(/\//g,"%2F")).join("/")}catch(c){return da(!1,'The URL path "'+o+'" could not be decoded because it is is a malformed URL segment. This is probably due to a bad percent '+("encoding ("+c+").")),o}}function Qn(o,c){if(c==="/")return o;if(!o.toLowerCase().startsWith(c.toLowerCase()))return null;let s=c.endsWith("/")?c.length-1:c.length,d=o.charAt(s);return d&&d!=="/"?null:o.slice(s)||"/"}const _p=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,Cp=o=>_p.test(o);function Np(o,c){c===void 0&&(c="/");let{pathname:s,search:d="",hash:f=""}=typeof o=="string"?Kn(o):o,v;if(s)if(Cp(s))v=s;else{if(s.includes("//")){let g=s;s=Uc(s),da(!1,"Pathnames cannot have embedded double slashes - normalizing "+(g+" -> "+s))}s.startsWith("/")?v=jc(s.substring(1),"/"):v=jc(s,c)}else v=c;return{pathname:v,search:Pp(d),hash:Tp(f)}}function jc(o,c){let s=c.replace(/\/+$/,"").split("/");return o.split("/").forEach(f=>{f===".."?s.length>1&&s.pop():f!=="."&&s.push(f)}),s.length>1?s.join("/"):"/"}function la(o,c,s,d){return"Cannot include a '"+o+"' character in a manually specified "+("`to."+c+"` field ["+JSON.stringify(d)+"]. Please separate it out to the ")+("`to."+s+"` field. Alternatively you may provide the full path as ")+'a string in and the router will parse it for you.'}function Ep(o){return o.filter((c,s)=>s===0||c.route.path&&c.route.path.length>0)}function fa(o,c){let s=Ep(o);return c?s.map((d,f)=>f===s.length-1?d.pathname:d.pathnameBase):s.map(d=>d.pathnameBase)}function pa(o,c,s,d){d===void 0&&(d=!1);let f;typeof o=="string"?f=Kn(o):(f=Br({},o),Pe(!f.pathname||!f.pathname.includes("?"),la("?","pathname","search",f)),Pe(!f.pathname||!f.pathname.includes("#"),la("#","pathname","hash",f)),Pe(!f.search||!f.search.includes("#"),la("#","search","hash",f)));let v=o===""||f.pathname==="",g=v?"/":f.pathname,z;if(g==null)z=s;else{let y=c.length-1;if(!d&&g.startsWith("..")){let L=g.split("/");for(;L[0]==="..";)L.shift(),y-=1;f.pathname=L.join("/")}z=y>=0?c[y]:"/"}let S=Np(f,z),T=g&&g!=="/"&&g.endsWith("/"),P=(v||g===".")&&s.endsWith("/");return!S.pathname.endsWith("/")&&(T||P)&&(S.pathname+="/"),S}const Uc=o=>o.replace(/\/\/+/g,"/"),ln=o=>Uc(o.join("/")),zp=o=>o.replace(/\/+$/,"").replace(/^\/*/,"/"),Pp=o=>!o||o==="?"?"":o.startsWith("?")?o:"?"+o,Tp=o=>!o||o==="#"?"":o.startsWith("#")?o:"#"+o;function bp(o){return o!=null&&typeof o.status=="number"&&typeof o.statusText=="string"&&typeof o.internal=="boolean"&&"data"in o}const Vc=["post","put","patch","delete"];new Set(Vc);const Rp=["get",...Vc];new Set(Rp);/** + * React Router v6.30.4 + * + * Copyright (c) Remix Software Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE.md file in the root directory of this source tree. + * + * @license MIT + */function Wr(){return Wr=Object.assign?Object.assign.bind():function(o){for(var c=1;c{z.current=!0}),m.useCallback(function(T,P){if(P===void 0&&(P={}),!z.current)return;if(typeof T=="number"){d.go(T);return}let y=pa(T,JSON.parse(g),v,P.relative==="path");o==null&&c!=="/"&&(y.pathname=y.pathname==="/"?c:ln([c,y.pathname])),(P.replace?d.replace:d.push)(y,P.state,P)},[c,d,g,v,o])}function ma(){let{matches:o}=m.useContext(Wt),c=o[o.length-1];return c?c.params:{}}function li(o,c){let{relative:s}=c===void 0?{}:c,{future:d}=m.useContext(Bt),{matches:f}=m.useContext(Wt),{pathname:v}=xn(),g=JSON.stringify(fa(f,d.v7_relativeSplatPath));return m.useMemo(()=>pa(o,JSON.parse(g),v,s==="path"),[o,g,v,s])}function Fp(o,c){return Bp(o,c)}function Bp(o,c,s,d){Yn()||Pe(!1);let{navigator:f}=m.useContext(Bt),{matches:v}=m.useContext(Wt),g=v[v.length-1],z=g?g.params:{};g&&g.pathname;let S=g?g.pathnameBase:"/";g&&g.route;let T=xn(),P;if(c){var y;let j=typeof c=="string"?Kn(c):c;S==="/"||(y=j.pathname)!=null&&y.startsWith(S)||Pe(!1),P=j}else P=T;let L=P.pathname||"/",A=L;if(S!=="/"){let j=S.replace(/^\//,"").split("/");A="/"+L.replace(/^\//,"").split("/").slice(j.length).join("/")}let R=cp(o,{pathname:A}),N=Ap(R&&R.map(j=>Object.assign({},j,{params:Object.assign({},z,j.params),pathname:ln([S,f.encodeLocation?f.encodeLocation(j.pathname).pathname:j.pathname]),pathnameBase:j.pathnameBase==="/"?S:ln([S,f.encodeLocation?f.encodeLocation(j.pathnameBase).pathname:j.pathnameBase])})),v,s,d);return c&&N?m.createElement(ri.Provider,{value:{location:Wr({pathname:"/",search:"",hash:"",state:null,key:"default"},P),navigationType:rn.Pop}},N):N}function Wp(){let o=Hp(),c=bp(o)?o.status+" "+o.statusText:o instanceof Error?o.message:JSON.stringify(o),s=o instanceof Error?o.stack:null,f={padding:"0.5rem",backgroundColor:"rgba(200,200,200, 0.5)"};return m.createElement(m.Fragment,null,m.createElement("h2",null,"Unexpected Application Error!"),m.createElement("h3",{style:{fontStyle:"italic"}},c),s?m.createElement("pre",{style:f},s):null,null)}const Op=m.createElement(Wp,null);class Mp extends m.Component{constructor(c){super(c),this.state={location:c.location,revalidation:c.revalidation,error:c.error}}static getDerivedStateFromError(c){return{error:c}}static getDerivedStateFromProps(c,s){return s.location!==c.location||s.revalidation!=="idle"&&c.revalidation==="idle"?{error:c.error,location:c.location,revalidation:c.revalidation}:{error:c.error!==void 0?c.error:s.error,location:s.location,revalidation:c.revalidation||s.revalidation}}componentDidCatch(c,s){console.error("React Router caught the following error during render",c,s)}render(){return this.state.error!==void 0?m.createElement(Wt.Provider,{value:this.props.routeContext},m.createElement(Qc.Provider,{value:this.state.error,children:this.props.component})):this.props.children}}function Dp(o){let{routeContext:c,match:s,children:d}=o,f=m.useContext(ni);return f&&f.static&&f.staticContext&&(s.route.errorElement||s.route.ErrorBoundary)&&(f.staticContext._deepestRenderedBoundaryId=s.route.id),m.createElement(Wt.Provider,{value:c},d)}function Ap(o,c,s,d){var f;if(c===void 0&&(c=[]),s===void 0&&(s=null),d===void 0&&(d=null),o==null){var v;if(!s)return null;if(s.errors)o=s.matches;else if((v=d)!=null&&v.v7_partialHydration&&c.length===0&&!s.initialized&&s.matches.length>0)o=s.matches;else return null}let g=o,z=(f=s)==null?void 0:f.errors;if(z!=null){let P=g.findIndex(y=>y.route.id&&(z==null?void 0:z[y.route.id])!==void 0);P>=0||Pe(!1),g=g.slice(0,Math.min(g.length,P+1))}let S=!1,T=-1;if(s&&d&&d.v7_partialHydration)for(let P=0;P=0?g=g.slice(0,T+1):g=[g[0]];break}}}return g.reduceRight((P,y,L)=>{let A,R=!1,N=null,j=null;s&&(A=z&&y.route.id?z[y.route.id]:void 0,N=y.route.errorElement||Op,S&&(T<0&&L===0?(Kp("route-fallback"),R=!0,j=null):T===L&&(R=!0,j=y.route.hydrateFallbackElement||null)));let F=c.concat(g.slice(0,L+1)),_=()=>{let B;return A?B=N:R?B=j:y.route.Component?B=m.createElement(y.route.Component,null):y.route.element?B=y.route.element:B=P,m.createElement(Dp,{match:y,routeContext:{outlet:P,matches:F,isDataRoute:s!=null},children:B})};return s&&(y.route.ErrorBoundary||y.route.errorElement||L===0)?m.createElement(Mp,{location:s.location,revalidation:s.revalidation,component:N,error:A,children:_(),routeContext:{outlet:null,matches:F,isDataRoute:!0}}):_()},null)}var Yc=(function(o){return o.UseBlocker="useBlocker",o.UseRevalidator="useRevalidator",o.UseNavigateStable="useNavigate",o})(Yc||{}),Jc=(function(o){return o.UseBlocker="useBlocker",o.UseLoaderData="useLoaderData",o.UseActionData="useActionData",o.UseRouteError="useRouteError",o.UseNavigation="useNavigation",o.UseRouteLoaderData="useRouteLoaderData",o.UseMatches="useMatches",o.UseRevalidator="useRevalidator",o.UseNavigateStable="useNavigate",o.UseRouteId="useRouteId",o})(Jc||{});function $p(o){let c=m.useContext(ni);return c||Pe(!1),c}function Up(o){let c=m.useContext(Hc);return c||Pe(!1),c}function Vp(o){let c=m.useContext(Wt);return c||Pe(!1),c}function Gc(o){let c=Vp(),s=c.matches[c.matches.length-1];return s.route.id||Pe(!1),s.route.id}function Hp(){var o;let c=m.useContext(Qc),s=Up(),d=Gc();return c!==void 0?c:(o=s.errors)==null?void 0:o[d]}function Qp(){let{router:o}=$p(Yc.UseNavigateStable),c=Gc(Jc.UseNavigateStable),s=m.useRef(!1);return Kc(()=>{s.current=!0}),m.useCallback(function(f,v){v===void 0&&(v={}),s.current&&(typeof f=="number"?o.navigate(f):o.navigate(f,Wr({fromRouteId:c},v)))},[o,c])}const kc={};function Kp(o,c,s){kc[o]||(kc[o]=!0)}function Yp(o,c){o==null||o.v7_startTransition,o==null||o.v7_relativeSplatPath}function Jp(o){let{to:c,replace:s,state:d,relative:f}=o;Yn()||Pe(!1);let{future:v,static:g}=m.useContext(Bt),{matches:z}=m.useContext(Wt),{pathname:S}=xn(),T=Ge(),P=pa(c,fa(z,v.v7_relativeSplatPath),S,f==="path"),y=JSON.stringify(P);return m.useEffect(()=>T(JSON.parse(y),{replace:s,state:d,relative:f}),[T,y,f,s,d]),null}function Ie(o){Pe(!1)}function Gp(o){let{basename:c="/",children:s=null,location:d,navigationType:f=rn.Pop,navigator:v,static:g=!1,future:z}=o;Yn()&&Pe(!1);let S=c.replace(/^\/*/,"/"),T=m.useMemo(()=>({basename:S,navigator:v,static:g,future:Wr({v7_relativeSplatPath:!1},z)}),[S,z,v,g]);typeof d=="string"&&(d=Kn(d));let{pathname:P="/",search:y="",hash:L="",state:A=null,key:R="default"}=d,N=m.useMemo(()=>{let j=Qn(P,S);return j==null?null:{location:{pathname:j,search:y,hash:L,state:A,key:R},navigationType:f}},[S,P,y,L,A,R,f]);return N==null?null:m.createElement(Bt.Provider,{value:T},m.createElement(ri.Provider,{children:s,value:N}))}function Xp(o){let{children:c,location:s}=o;return Fp(aa(c),s)}new Promise(()=>{});function aa(o,c){c===void 0&&(c=[]);let s=[];return m.Children.forEach(o,(d,f)=>{if(!m.isValidElement(d))return;let v=[...c,f];if(d.type===m.Fragment){s.push.apply(s,aa(d.props.children,v));return}d.type!==Ie&&Pe(!1),!d.props.index||!d.props.children||Pe(!1);let g={id:d.props.id||v.join("-"),caseSensitive:d.props.caseSensitive,element:d.props.element,Component:d.props.Component,index:d.props.index,path:d.props.path,loader:d.props.loader,action:d.props.action,errorElement:d.props.errorElement,ErrorBoundary:d.props.ErrorBoundary,hasErrorBoundary:d.props.ErrorBoundary!=null||d.props.errorElement!=null,shouldRevalidate:d.props.shouldRevalidate,handle:d.props.handle,lazy:d.props.lazy};d.props.children&&(g.children=aa(d.props.children,v)),s.push(g)}),s}/** + * React Router DOM v6.30.4 + * + * Copyright (c) Remix Software Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE.md file in the root directory of this source tree. + * + * @license MIT + */function ei(){return ei=Object.assign?Object.assign.bind():function(o){for(var c=1;c{T&&_c?_c(()=>S(y)):S(y)},[S,T]);return m.useLayoutEffect(()=>g.listen(P),[g,P]),m.useEffect(()=>Yp(d),[d]),m.createElement(Gp,{basename:c,children:s,location:z.location,navigationType:z.action,navigator:g,future:d})}const om=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u",am=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,qc=m.forwardRef(function(c,s){let{onClick:d,relative:f,reloadDocument:v,replace:g,state:z,target:S,to:T,preventScrollReset:P,viewTransition:y}=c,L=Xc(c,em),{basename:A}=m.useContext(Bt),R,N=!1;if(typeof T=="string"&&am.test(T)&&(R=T,om))try{let B=new URL(window.location.href),W=T.startsWith("//")?new URL(B.protocol+T):new URL(T),Z=Qn(W.pathname,A);W.origin===B.origin&&Z!=null?T=Z+W.search+W.hash:N=!0}catch{}let j=Lp(T,{relative:f}),F=um(T,{replace:g,state:z,target:S,preventScrollReset:P,relative:f,viewTransition:y});function _(B){d&&d(B),B.defaultPrevented||F(B)}return m.createElement("a",ei({},L,{href:R||j,onClick:N||v?d:_,ref:s,target:S}))}),Hn=m.forwardRef(function(c,s){let{"aria-current":d="page",caseSensitive:f=!1,className:v="",end:g=!1,style:z,to:S,viewTransition:T,children:P}=c,y=Xc(c,tm),L=li(S,{relative:y.relative}),A=xn(),R=m.useContext(Hc),{navigator:N,basename:j}=m.useContext(Bt),F=R!=null&&cm(L)&&T===!0,_=N.encodeLocation?N.encodeLocation(L).pathname:L.pathname,B=A.pathname,W=R&&R.navigation&&R.navigation.location?R.navigation.location.pathname:null;f||(B=B.toLowerCase(),W=W?W.toLowerCase():null,_=_.toLowerCase()),W&&j&&(W=Qn(W,j)||W);const Z=_!=="/"&&_.endsWith("/")?_.length-1:_.length;let fe=B===_||!g&&B.startsWith(_)&&B.charAt(Z)==="/",we=W!=null&&(W===_||!g&&W.startsWith(_)&&W.charAt(_.length)==="/"),xe={isActive:fe,isPending:we,isTransitioning:F},re=fe?d:void 0,oe;typeof v=="function"?oe=v(xe):oe=[v,fe?"active":null,we?"pending":null,F?"transitioning":null].filter(Boolean).join(" ");let pe=typeof z=="function"?z(xe):z;return m.createElement(qc,ei({},y,{"aria-current":re,className:oe,ref:s,style:pe,to:S,viewTransition:T}),typeof P=="function"?P(xe):P)});var sa;(function(o){o.UseScrollRestoration="useScrollRestoration",o.UseSubmit="useSubmit",o.UseSubmitFetcher="useSubmitFetcher",o.UseFetcher="useFetcher",o.useViewTransitionState="useViewTransitionState"})(sa||(sa={}));var Cc;(function(o){o.UseFetcher="useFetcher",o.UseFetchers="useFetchers",o.UseScrollRestoration="useScrollRestoration"})(Cc||(Cc={}));function sm(o){let c=m.useContext(ni);return c||Pe(!1),c}function um(o,c){let{target:s,replace:d,state:f,preventScrollReset:v,relative:g,viewTransition:z}=c===void 0?{}:c,S=Ge(),T=xn(),P=li(o,{relative:g});return m.useCallback(y=>{if(Zp(y,s)){y.preventDefault();let L=d!==void 0?d:Zl(T)===Zl(P);S(o,{replace:L,state:f,preventScrollReset:v,relative:g,viewTransition:z})}},[T,S,P,d,f,s,o,v,g,z])}function cm(o,c){c===void 0&&(c={});let s=m.useContext(rm);s==null&&Pe(!1);let{basename:d}=sm(sa.useViewTransitionState),f=li(o,{relative:c.relative});if(!s.isTransitioning)return!1;let v=Qn(s.currentLocation.pathname,d)||s.currentLocation.pathname,g=Qn(s.nextLocation.pathname,d)||s.nextLocation.pathname;return oa(f.pathname,g)!=null||oa(f.pathname,v)!=null}const dm="/api";function fm(){return localStorage.getItem("fitness_token")}async function K(o,c={}){const s=fm(),d={"Content-Type":"application/json",...c.headers};s&&(d.Authorization=`Bearer ${s}`);const f=await fetch(`${dm}${o}`,{...c,headers:d});if(f.status===401)throw localStorage.removeItem("fitness_token"),window.location.href="/login",new Error("Unauthorized");if(!f.ok){const v=await f.json().catch(()=>({detail:"Request failed"}));throw new Error(v.detail||`HTTP ${f.status}`)}return f.json()}const Y={login:(o,c)=>K("/auth/login",{method:"POST",body:JSON.stringify({username:o,password:c})}),register:(o,c,s)=>K("/auth/register",{method:"POST",body:JSON.stringify({username:o,password:c,display_name:s,timezone:Intl.DateTimeFormat().resolvedOptions().timeZone})}),me:()=>K("/auth/me"),onboardingStatus:()=>K("/onboarding/status"),savePhase1:o=>K("/onboarding/phase1",{method:"POST",body:JSON.stringify(o)}),savePhase2:o=>K("/onboarding/phase2",{method:"POST",body:JSON.stringify(o)}),getRecommendations:()=>K("/onboarding/recommendations"),today:()=>K("/points/today"),week:o=>K(`/points/week${o?`?start=${o}`:""}`),getRules:()=>K("/points/rules"),updateRule:(o,c)=>K(`/points/rules/${o}`,{method:"PATCH",body:JSON.stringify(c)}),getTargets:()=>K("/points/targets"),updateTargets:o=>K("/points/targets",{method:"PATCH",body:JSON.stringify(o)}),listActivities:o=>K(`/activities${o?`?date=${o}`:""}`),logActivity:o=>K("/activities",{method:"POST",body:JSON.stringify(o)}),deleteActivity:o=>K(`/activities/${o}`,{method:"DELETE"}),listFood:o=>K(`/food${o?`?date=${o}`:""}`),logFood:o=>K("/food",{method:"POST",body:JSON.stringify(o)}),deleteFood:o=>K(`/food/${o}`,{method:"DELETE"}),scanBarcode:o=>K(`/food/scan/${o}`),searchFood:o=>K(`/food/search?q=${encodeURIComponent(o)}`),nutritionToday:()=>K("/nutrition/today"),getNutritionGoals:()=>K("/nutrition/goals"),updateNutritionGoals:o=>K("/nutrition/goals",{method:"PATCH",body:JSON.stringify(o)}),startFast:o=>K("/nutrition/fasts",{method:"POST",body:JSON.stringify(o)}),endFast:(o,c)=>K(`/nutrition/fasts/${o}`,{method:"PATCH",body:JSON.stringify(c||{})}),listFasts:o=>K(`/nutrition/fasts${o?`?limit=${o}`:""}`),getActiveFast:()=>K("/nutrition/fasts/active"),deleteFast:o=>K(`/nutrition/fasts/${o}`,{method:"DELETE"}),logElectrolytes:o=>K("/nutrition/electrolytes",{method:"POST",body:JSON.stringify(o)}),getElectrolytesToday:()=>K("/nutrition/electrolytes/today"),listRewards:()=>K("/rewards"),createReward:o=>K("/rewards",{method:"POST",body:JSON.stringify(o)}),redeemReward:(o,c)=>K(`/rewards/${o}/redeem`,{method:"POST",body:JSON.stringify({date:c})}),listPrograms:()=>K("/programs"),createProgram:o=>K("/programs",{method:"POST",body:JSON.stringify(o)}),getProgram:o=>K(`/programs/${o}`),searchCatalog:o=>K(`/programs/catalog${o?`?q=${encodeURIComponent(o)}`:""}`),getCatalogProgram:o=>K(`/programs/catalog/${o}`),researchProgram:o=>K("/programs/research",{method:"POST",body:JSON.stringify({name:o})}),adoptProgram:(o,c)=>K(`/programs/catalog/${o}/adopt`,{method:"POST",body:JSON.stringify({start_date:c})}),getProgramSchedule:o=>K(`/programs/${o}/schedule`),getWorkoutDetail:(o,c)=>K(`/programs/${o}/workout/${c}`),completeWorkout:(o,c,s)=>K(`/programs/${o}/workout/${c}/complete`,{method:"POST",body:JSON.stringify(s)}),getProgramProgress:o=>K(`/programs/${o}/progress`),getThread:()=>K("/support/thread"),sendSupportMessage:o=>K("/support/messages",{method:"POST",body:JSON.stringify({body:o})}),getUnreadCount:()=>K("/support/unread"),listSupportThreads:()=>K("/support/admin/threads"),getAdminThread:o=>K(`/support/admin/threads/${o}`),replySupportThread:(o,c)=>K(`/support/admin/threads/${o}/reply`,{method:"POST",body:JSON.stringify({body:c})}),integrationStatus:()=>K("/integrations"),stravaAuth:()=>K("/integrations/strava/auth"),stravaSync:()=>K("/integrations/strava/sync",{method:"POST"}),polarAuth:()=>K("/integrations/polar/auth"),polarSync:()=>K("/integrations/polar/sync",{method:"POST"}),disconnect:o=>K(`/integrations/${o}`,{method:"DELETE"}),listMealPlans:()=>K("/meal-plans"),createMealPlan:o=>K("/meal-plans",{method:"POST",body:JSON.stringify(o)}),getMealPlanToday:()=>K("/meal-plans/today"),getMealPlan:o=>K("/meal-plans/"+o),updateMealPlanStatus:(o,c)=>K("/meal-plans/"+o,{method:"PATCH",body:JSON.stringify({status:c})}),logMealCompliance:o=>K("/meal-plans/compliance",{method:"POST",body:JSON.stringify(o)}),getMealPlanProgress:o=>K("/meal-plans/"+o+"/progress"),fullIntegrationStatus:()=>K("/integrations/status"),listProviders:()=>K("/integrations/providers"),configureIntegration:(o,c)=>K("/integrations/configure",{method:"POST",body:JSON.stringify({provider:o,credentials:c})}),getAIStatus:()=>K("/ai/status"),getResourceRecommendations:()=>K("/onboarding/recommendations"),agentConfig:()=>K("/agent/config")};function pm(o){localStorage.setItem("fitness_token",o)}function mm(){localStorage.removeItem("fitness_token")}function ti(){return!!localStorage.getItem("fitness_token")}function hm(){const[o,c]=m.useState("login"),[s,d]=m.useState(""),[f,v]=m.useState(""),[g,z]=m.useState(""),[S,T]=m.useState(""),[P,y]=m.useState(!1),L=Ge();async function A(R){R.preventDefault(),T(""),y(!0);try{const N=o==="login"?await Y.login(s,f):await Y.register(s,f,g||s);pm(N.access_token);const j=await Y.onboardingStatus();L(j.phase1_completed?"/":"/onboarding")}catch(N){T(N.message)}finally{y(!1)}}return l.jsx("div",{style:{minHeight:"100dvh",display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center",padding:"24px",background:"linear-gradient(170deg, var(--accent-light) 0%, var(--accent) 30%, var(--accent-dark) 60%, var(--text) 100%)"},children:l.jsxs("div",{style:{width:"100%",maxWidth:"380px"},children:[l.jsxs("div",{style:{textAlign:"center",marginBottom:"32px",color:"#fff"},children:[l.jsx("div",{style:{fontSize:"3.2rem",marginBottom:"8px",filter:"drop-shadow(0 4px 12px rgba(0,0,0,0.2))"},children:"💪"}),l.jsx("h1",{style:{fontFamily:"var(--font-display)",fontSize:"2.2rem",fontWeight:900,letterSpacing:"-0.03em"},children:"Diligence"}),l.jsx("p",{style:{opacity:.8,marginTop:"4px",fontSize:"0.95rem",fontWeight:500},children:"Your fitness. Your data. Your rewards."})]}),l.jsxs("div",{style:{background:"var(--card)",borderRadius:"var(--r-lg)",padding:"28px",boxShadow:"0 20px 60px rgba(0,0,0,0.3)"},children:[l.jsx("div",{style:{display:"grid",gridTemplateColumns:"1fr 1fr",gap:"4px",background:"rgba(0,0,0,0.04)",borderRadius:"var(--r)",padding:"4px",marginBottom:"24px"},children:["login","register"].map(R=>l.jsx("button",{type:"button",onClick:()=>c(R),style:{borderRadius:"var(--r-sm)",padding:"10px",fontWeight:700,fontSize:"0.85rem",background:o===R?"var(--accent)":"transparent",color:o===R?"#fff":"var(--text-2)",boxShadow:o===R?"var(--shadow-accent)":"none"},children:R==="login"?"Sign In":"Sign Up"},R))}),S&&l.jsx("div",{className:"error-msg",children:S}),l.jsxs("form",{onSubmit:A,children:[l.jsxs("div",{className:"form-group",children:[l.jsx("label",{className:"form-label",children:"Username"}),l.jsx("input",{value:s,onChange:R=>d(R.target.value),required:!0,autoComplete:"username"})]}),l.jsxs("div",{className:"form-group",children:[l.jsx("label",{className:"form-label",children:"Password"}),l.jsx("input",{type:"password",value:f,onChange:R=>v(R.target.value),required:!0,autoComplete:"current-password"})]}),o==="register"&&l.jsxs("div",{className:"form-group",children:[l.jsx("label",{className:"form-label",children:"Display Name"}),l.jsx("input",{value:g,onChange:R=>z(R.target.value),placeholder:"What should we call you?"})]}),l.jsx("button",{type:"submit",className:"btn-primary btn-full",disabled:P,style:{padding:"14px",fontSize:"0.95rem",marginTop:"8px",borderRadius:"var(--r)"},children:P?"...":o==="login"?"Sign In":"Create Account"})]})]})]})})}function Jl({text:o,children:c}){const[s,d]=m.useState(!1);return l.jsxs("span",{style:{position:"relative",display:"inline-flex",alignItems:"center"},children:[c,l.jsx("span",{onMouseEnter:()=>d(!0),onMouseLeave:()=>d(!1),onClick:f=>{f.stopPropagation(),d(v=>!v)},style:{display:"inline-flex",alignItems:"center",justifyContent:"center",width:"18px",height:"18px",borderRadius:"50%",marginLeft:"6px",background:"rgba(0,0,0,0.06)",color:"var(--text-3)",cursor:"help",fontSize:"0.7rem",fontWeight:800,flexShrink:0},children:"?"}),s&&l.jsx("span",{style:{position:"absolute",bottom:"calc(100% + 8px)",left:"50%",transform:"translateX(-50%)",background:"var(--text)",color:"#fff",padding:"10px 14px",borderRadius:"var(--r-sm)",fontSize:"0.78rem",lineHeight:1.45,fontWeight:500,width:"240px",boxShadow:"var(--shadow-3)",zIndex:50,pointerEvents:"none"},children:o})]})}function gm(){var _,B;const[o,c]=m.useState(null),[s,d]=m.useState(null),[f,v]=m.useState(!0),[g,z]=m.useState(null),S=Ge();m.useEffect(()=>{T()},[]);async function T(){try{const[W,Z]=await Promise.all([Y.today(),Y.integrationStatus()]);c(W),d(Z)}catch(W){console.error(W)}finally{v(!1)}}async function P(W){z(W);try{const Z=await(W==="strava"?Y.stravaSync:Y.polarSync)();await T(),Z.imported>0?alert(`Synced ${Z.imported} activities from ${W}!`):alert(`No new activities found on ${W}.`)}catch(Z){alert(`Sync failed: ${Z.message}`)}finally{z(null)}}async function y(W){try{const Z=await(W==="strava"?Y.stravaAuth:Y.polarAuth)();window.location.href=Z.auth_url}catch(Z){alert(`Connect failed: ${Z.message}`)}}if(f)return l.jsx("div",{className:"page",children:l.jsx("div",{className:"loading",children:"Loading..."})});if(!o)return l.jsx("div",{className:"page",children:l.jsx("div",{className:"error-msg",children:"Failed to load"})});const L=o.daily_minimum>0?Math.min(100,Math.round(o.points_earned/o.daily_minimum*100)):0,A=o.weekly_target>0?Math.min(100,Math.round(o.week_points/o.weekly_target*100)):0,R=new Set(o.activities_today.map(W=>W.category)),N=(_=s==null?void 0:s.strava)==null?void 0:_.connected,j=(B=s==null?void 0:s.polar)==null?void 0:B.connected,F=[{key:"workout",label:"Workout",icon:"💪",pts:50,color:"var(--accent)",tip:"Any exercise session — running, weights, yoga, etc. Logged manually or synced from Strava/Polar."},{key:"food_log",label:"Food logged",icon:"🥗",pts:30,color:"var(--green)",tip:"Log what you eat. Barcode scan or manual entry. Building food awareness is a key habit."},{key:"steps_target",label:"Steps target",icon:"👟",pts:20,color:"#2979FF",tip:"Hit your daily step goal. Steps are the foundation of an active lifestyle."},{key:"screen_free",label:"Screen-free",icon:"📖",pts:"20/hr",color:"#7C4DFF",tip:"Time away from screens — reading, walking, hobbies. Points scale with hours logged."},{key:"daily_checkin",label:"Check-in",icon:"✅",pts:10,color:"#00BCD4",tip:"Just show up and check in. The easiest points — consistency matters more than intensity."}];return l.jsxs("div",{className:"page",children:[o.program_name&&l.jsxs("div",{onClick:()=>o.program_id&&S(`/programs/${o.program_id}`),style:{background:"var(--accent-bg)",borderRadius:"var(--r)",padding:"12px 16px",marginBottom:"14px",display:"flex",alignItems:"center",gap:"12px",border:"1px solid rgba(41,121,255,0.1)",cursor:o.program_id?"pointer":"default"},children:[l.jsx("div",{style:{fontSize:"1.1rem"},children:"📋"}),l.jsxs("div",{style:{flex:1},children:[l.jsx("div",{style:{fontSize:"0.78rem",fontWeight:700,color:"var(--accent)"},children:o.program_name}),l.jsx("div",{className:"progress-bar",style:{height:"5px",marginTop:"4px"},children:l.jsx("div",{className:"progress-bar-fill",style:{width:`${Math.round(o.program_day/o.program_total_days*100)}%`,background:"var(--accent)"}})})]}),l.jsxs("div",{style:{fontFamily:"var(--font-display)",fontWeight:800,fontSize:"0.85rem",color:"var(--accent)",whiteSpace:"nowrap"},children:[o.program_day,"/",o.program_total_days]})]}),l.jsxs("div",{className:`gate-banner ${o.gate_passed?"gate-earned":"gate-locked"}`,children:[l.jsx("div",{className:"section-label",style:{marginBottom:"4px"},children:l.jsx(Jl,{text:`Hit ${o.daily_minimum} points to unlock your rewards for today. Points reset weekly. This is your daily "gate" — earn first, enjoy second.`,children:"Today"})}),l.jsxs("div",{className:"gate-pts",children:[l.jsx("span",{style:{color:o.gate_passed?"var(--green)":"var(--accent)"},children:o.points_earned}),l.jsxs("span",{style:{fontFamily:"var(--font)",fontSize:"1rem",fontWeight:600,color:"var(--text-3)"},children:["/",o.daily_minimum]})]}),l.jsx("div",{className:"progress-bar",style:{marginTop:"12px",height:"12px"},children:l.jsx("div",{className:"progress-bar-fill",style:{width:`${L}%`,background:o.gate_passed?"linear-gradient(90deg, #00C853, #69F0AE)":"linear-gradient(90deg, var(--accent), var(--accent-light))"}})}),l.jsx("div",{style:{marginTop:"12px",fontSize:"0.88rem",fontWeight:700},children:o.gate_passed?l.jsx("span",{style:{color:"var(--green-dark)"},children:"✨ Rewards unlocked!"}):l.jsxs("span",{style:{color:"var(--text-2)"},children:["🔒 ",o.points_remaining," more to unlock"]})})]}),l.jsxs("div",{style:{display:"grid",gridTemplateColumns:"1fr 1fr",gap:"10px",marginBottom:"14px"},children:[l.jsx("button",{className:"btn-primary btn-full",onClick:()=>S("/log"),style:{padding:"14px",fontSize:"0.9rem"},children:"+ Log Activity"}),l.jsx("button",{className:"btn-outline btn-full",onClick:()=>S("/food"),style:{padding:"14px",fontSize:"0.9rem"},children:"🍽️ Log Food"})]}),l.jsxs("div",{className:"card",children:[l.jsx("div",{className:"section-label",children:"Activities"}),F.map(W=>{const Z=R.has(W.key);return l.jsxs("div",{className:"checklist-item",style:{opacity:Z?1:.6},children:[l.jsx("span",{style:{width:"32px",height:"32px",borderRadius:"8px",background:Z?W.color:"rgba(0,0,0,0.04)",display:"flex",alignItems:"center",justifyContent:"center",fontSize:"0.9rem",transition:"all 0.3s"},children:Z?"✓":W.icon}),l.jsx("span",{style:{fontWeight:600,fontSize:"0.88rem"},children:l.jsx(Jl,{text:W.tip,children:W.label})}),l.jsxs("span",{className:"checklist-points",children:["+",W.pts]})]},W.key)})]}),l.jsxs("div",{className:"card",style:{background:"var(--accent-bg)"},children:[l.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:"10px"},children:[l.jsx("div",{className:"section-label",style:{margin:0},children:l.jsx(Jl,{text:`Earn ${o.weekly_target} total points across the week for a bonus. Consistent daily effort beats one big day.`,children:"This Week"})}),l.jsx(qc,{to:"/week",style:{fontSize:"0.8rem"},children:"Details →"})]}),l.jsxs("div",{style:{display:"flex",alignItems:"baseline",gap:"4px",marginBottom:"8px"},children:[l.jsx("span",{style:{fontFamily:"var(--font-display)",fontWeight:900,fontSize:"1.4rem"},children:o.week_points}),l.jsxs("span",{style:{fontSize:"0.85rem",color:"var(--text-3)",fontWeight:600},children:["/ ",o.weekly_target," pts"]})]}),l.jsx("div",{className:"progress-bar",children:l.jsx("div",{className:"progress-bar-fill",style:{width:`${A}%`,background:"linear-gradient(90deg, #7C4DFF, #B388FF)"}})})]}),o.gate_passed&&o.rewards_available.length>0&&l.jsxs("div",{className:"card",style:{background:"var(--green-bg)",border:"2px solid rgba(0,200,83,0.15)"},children:[l.jsx("div",{className:"section-label",style:{color:"var(--green-dark)"},children:"🎮 Rewards Available"}),o.rewards_available.map(W=>l.jsxs("div",{className:"reward-card",children:[l.jsxs("div",{children:[l.jsx("div",{style:{fontWeight:700},children:W.name}),l.jsxs("div",{style:{fontSize:"0.78rem",color:"var(--text-3)",fontWeight:600},children:[W.point_cost," pts"]})]}),l.jsx("button",{className:"btn-success btn-sm",onClick:async()=>{try{await Y.redeemReward(W.id),await T()}catch(Z){alert(Z.message)}},children:"Redeem"})]},W.id))]}),l.jsxs("div",{className:"card",children:[l.jsx("div",{className:"section-label",children:l.jsx(Jl,{text:"Connect your fitness trackers to automatically import workouts. Go to Settings → Integrations to connect.",children:"Integrations"})}),l.jsxs("div",{style:{display:"grid",gridTemplateColumns:"1fr 1fr",gap:"8px"},children:[N?l.jsx("button",{className:"btn-outline btn-sm btn-full",onClick:()=>P("strava"),disabled:g==="strava",children:g==="strava"?"⏳ Syncing...":"🔄 Sync Strava"}):l.jsx("button",{className:"btn-ghost btn-sm btn-full",onClick:()=>y("strava"),style:{border:"2px dashed var(--divider)"},children:"🔗 Connect Strava"}),j?l.jsx("button",{className:"btn-outline btn-sm btn-full",onClick:()=>P("polar"),disabled:g==="polar",children:g==="polar"?"⏳ Syncing...":"🔄 Sync Polar"}):l.jsx("button",{className:"btn-ghost btn-sm btn-full",onClick:()=>y("polar"),style:{border:"2px dashed var(--divider)"},children:"🔗 Connect Polar"})]})]})]})}const Nc=[{value:"workout",label:"Workout",icon:"💪",desc:"Any exercise session",color:"var(--accent)"},{value:"steps_target",label:"Steps",icon:"👟",desc:"Hit your daily goal",color:"#2979FF"},{value:"screen_free",label:"Screen-Free",icon:"📖",desc:"Reading, outdoors",color:"#7C4DFF"},{value:"daily_checkin",label:"Check-in",icon:"✅",desc:"Just show up",color:"#00BCD4"}];function vm(){const[o,c]=m.useState(""),[s,d]=m.useState(""),[f,v]=m.useState(""),[g,z]=m.useState(""),[S,T]=m.useState(!1),[P,y]=m.useState(""),[L,A]=m.useState(null),[R,N]=m.useState(null),[j,F]=m.useState([]),[_,B]=m.useState(null),W=Ge();m.useEffect(()=>{Z()},[]);async function Z(){try{const oe=(await Y.listPrograms()||[]).find(ve=>ve.status==="active"&&ve.catalog_id);if(!oe)return;A(oe);const pe=await Y.getProgramSchedule(oe.id);pe.today_workout&&!pe.today_workout.rest_day&&!pe.today_workout.completed&&N(pe.today_workout);const Ne=(pe.schedule||[]).filter(ve=>{var Te;return ve.week_number===pe.current_week&&!ve.rest_day&&!ve.completed&&ve.id!==((Te=pe.today_workout)==null?void 0:Te.id)}).slice(0,3);F(Ne)}catch(re){console.warn("Could not load active program:",re.message)}}async function fe(re){if(!(!L||!re||_)){B(re.id);try{const oe=await Y.completeWorkout(L.id,re.id,{});let Ne=`+${oe.total_points||oe.points_earned} points!`;oe.weekly_bonus>0&&(Ne+=" (week bonus!)"),oe.completion_bonus>0&&(Ne+=" (program complete!)"),y(Ne),setTimeout(()=>W("/"),1500)}catch(oe){alert(oe.message),B(null)}}}async function we(re){var oe;if(re.preventDefault(),!!o){T(!0);try{const pe=new Date().toISOString().split("T")[0],Ne=await Y.logActivity({category:o,title:s||((oe=Nc.find(ve=>ve.value===o))==null?void 0:oe.label),description:g||null,duration_minutes:f?parseInt(f):null,activity_date:pe});y(`+${Ne.points_earned} points!`),c(""),d(""),v(""),z(""),setTimeout(()=>W("/"),1200)}catch(pe){alert(pe.message)}finally{T(!1)}}}const xe=o==="workout"&&L;return l.jsxs("div",{className:"page",children:[l.jsx("h1",{className:"page-title",children:"Log Activity"}),P&&l.jsxs("div",{style:{textAlign:"center",padding:"24px",marginBottom:"14px",background:"var(--green-bg)",borderRadius:"var(--r-lg)",border:"2px solid rgba(0,200,83,0.15)"},children:[l.jsx("div",{style:{fontSize:"2rem",marginBottom:"4px"},children:"🎉"}),l.jsx("div",{style:{fontFamily:"var(--font-display)",fontWeight:900,fontSize:"1.3rem",color:"var(--green-dark)"},children:P})]}),l.jsxs("div",{style:{marginBottom:"18px"},children:[l.jsx("div",{className:"section-label",children:"What did you do?"}),l.jsx("div",{className:"option-grid",children:Nc.map(re=>l.jsxs("div",{className:`option-btn ${o===re.value?"selected":""}`,onClick:()=>c(re.value),style:o===re.value?{borderColor:re.color,background:re.color+"0A"}:{},children:[l.jsx("div",{style:{fontSize:"1.5rem",marginBottom:"6px"},children:re.icon}),l.jsx("div",{style:{fontWeight:800,fontSize:"0.85rem"},children:re.label}),l.jsx("div",{style:{fontSize:"0.72rem",color:"var(--text-3)",marginTop:"2px",fontWeight:400},children:re.desc})]},re.value))})]}),xe&&l.jsxs("div",{style:{animation:"fadeUp 0.25s ease-out",marginBottom:"18px"},children:[l.jsxs("div",{onClick:()=>W(`/programs/${L.id}`),style:{background:"var(--accent-bg)",borderRadius:"var(--r-sm)",padding:"10px 14px",marginBottom:"12px",display:"flex",alignItems:"center",justifyContent:"space-between",cursor:"pointer",border:"1px solid rgba(41,121,255,0.15)"},children:[l.jsxs("div",{children:[l.jsx("div",{style:{fontSize:"0.7rem",fontWeight:700,color:"var(--accent)",textTransform:"uppercase",letterSpacing:"0.05em"},children:"Active Program"}),l.jsx("div",{style:{fontWeight:800,fontSize:"0.92rem",color:"var(--text-1)"},children:L.name})]}),l.jsxs("div",{style:{fontSize:"0.78rem",color:"var(--accent)",fontWeight:700},children:["Week ",L.current_week||1," →"]})]}),R&&l.jsx(Ec,{workout:R,featured:!0,completing:_===R.id,onComplete:()=>fe(R)}),j.length>0&&l.jsxs(l.Fragment,{children:[l.jsx("div",{className:"section-label",style:{marginTop:"14px",marginBottom:"8px"},children:R?"Or another from this week":"Workouts this week"}),j.map(re=>l.jsx(Ec,{workout:re,completing:_===re.id,onComplete:()=>fe(re)},re.id))]}),!R&&j.length===0&&l.jsx("div",{style:{padding:"14px",borderRadius:"var(--r-sm)",background:"var(--green-bg)",color:"var(--green-dark)",fontSize:"0.85rem",textAlign:"center",fontWeight:600},children:"✓ All workouts for this week are complete. Log a freeform workout below or rest up."}),l.jsx("div",{style:{textAlign:"center",color:"var(--text-3)",fontSize:"0.78rem",margin:"14px 0 6px",textTransform:"uppercase",letterSpacing:"0.06em",fontWeight:700},children:"— or log freeform —"})]}),o&&l.jsxs("form",{onSubmit:we,className:"card",style:{animation:"fadeUp 0.25s ease-out"},children:[l.jsxs("div",{className:"form-group",children:[l.jsx("label",{className:"form-label",children:"Title (optional)"}),l.jsx("input",{value:s,onChange:re=>d(re.target.value),placeholder:"e.g. Morning run, evening yoga"})]}),(o==="workout"||o==="screen_free")&&l.jsxs("div",{className:"form-group",children:[l.jsx("label",{className:"form-label",children:"Duration (minutes)"}),l.jsx("input",{type:"number",value:f,onChange:re=>v(re.target.value),placeholder:"30"})]}),l.jsxs("div",{className:"form-group",children:[l.jsx("label",{className:"form-label",children:"Notes (optional)"}),l.jsx("textarea",{value:g,onChange:re=>z(re.target.value),rows:2,placeholder:"How did it go?"})]}),l.jsx("button",{type:"submit",className:"btn-primary btn-full",disabled:S,style:{borderRadius:"var(--r)"},children:S?"Saving...":"Log Activity"})]})]})}function Ec({workout:o,featured:c,completing:s,onComplete:d}){return l.jsxs("div",{style:{background:"var(--card)",borderRadius:"var(--r)",padding:"14px",marginBottom:"10px",boxShadow:"var(--shadow-1)",border:c?"2px solid var(--accent-glow)":"1px solid var(--divider)"},children:[c&&l.jsx("div",{style:{fontSize:"0.65rem",fontWeight:800,color:"var(--accent)",textTransform:"uppercase",letterSpacing:"0.06em",marginBottom:"4px"},children:"⭐ Today"}),l.jsx("div",{style:{fontFamily:"var(--font-display)",fontWeight:800,fontSize:"1rem",marginBottom:"2px"},children:o.workout_name||`Day ${o.day_number}`}),l.jsxs("div",{style:{fontSize:"0.74rem",color:"var(--text-3)",marginBottom:"10px"},children:["Week ",o.week_number," · Day ",o.day_number," · ",(o.exercises||[]).length," exercises"]}),l.jsx("div",{style:{background:"var(--bg-warm)",borderRadius:"var(--r-sm)",padding:"8px 10px",marginBottom:"10px"},children:(o.exercises||[]).slice(0,4).map((f,v)=>l.jsxs("div",{style:{display:"flex",justifyContent:"space-between",fontSize:"0.76rem",padding:"2px 0",color:"var(--text-2)"},children:[l.jsx("span",{children:f.name}),l.jsx("span",{style:{color:"var(--text-3)",fontWeight:600},children:f.sets&&f.reps&&`${f.sets}×${f.reps}`})]},v))}),l.jsx("button",{onClick:d,disabled:s,style:{width:"100%",background:s?"var(--text-3)":"var(--green)",color:"#fff",padding:"11px",fontWeight:800,fontSize:"0.88rem",boxShadow:s?"none":"var(--shadow-green)"},children:s?"Logging...":"✓ Complete — 75 pts"})]})}const zc=["breakfast","lunch","dinner","snack"];function ym(){const[o,c]=m.useState("log"),[s,d]=m.useState("lunch"),[f,v]=m.useState(""),[g,z]=m.useState(""),[S,T]=m.useState(""),[P,y]=m.useState(""),[L,A]=m.useState(""),[R,N]=m.useState(""),[j,F]=m.useState("1"),[_,B]=m.useState(""),[W,Z]=m.useState(""),[fe,we]=m.useState([]),[xe,re]=m.useState(null),[oe,pe]=m.useState(!1),[Ne,ve]=m.useState("");m.useEffect(()=>{Te()},[]);async function Te(){try{const I=new Date().toISOString().split("T")[0],V=await Y.listFood(I);re(V)}catch(I){console.error(I)}}function Me(I){v(I.product_name||""),z(I.brand||""),T(I.calories_100g?String(Math.round(I.calories_100g)):""),y(I.protein_100g?String(Math.round(I.protein_100g)):""),A(I.carbs_100g?String(Math.round(I.carbs_100g)):""),N(I.fat_100g?String(Math.round(I.fat_100g)):""),c("log")}async function Be(){if(_.trim()){pe(!0);try{const I=await Y.scanBarcode(_.trim());Me(I)}catch{alert("Product not found. Try manual entry.")}finally{pe(!1)}}}async function Se(){if(W.trim()){pe(!0);try{const I=await Y.searchFood(W);we(I.results||[])}catch(I){alert(I.message)}finally{pe(!1)}}}async function H(I){if(I.preventDefault(),!!f.trim()){pe(!0);try{const V=new Date().toISOString().split("T")[0];await Y.logFood({meal_type:s,food_name:f,brand:g||null,calories:S?parseFloat(S)*parseFloat(j||1):null,protein_g:P?parseFloat(P)*parseFloat(j||1):null,carbs_g:L?parseFloat(L)*parseFloat(j||1):null,fat_g:R?parseFloat(R)*parseFloat(j||1):null,servings:parseFloat(j||1),food_date:V}),ve("Food logged!"),v(""),z(""),T(""),y(""),A(""),N(""),F("1"),await Te(),setTimeout(()=>ve(""),2e3)}catch(V){alert(V.message)}finally{pe(!1)}}}return l.jsxs("div",{className:"page",children:[l.jsx("h1",{className:"page-title",children:"Food Log"}),Ne&&l.jsx("div",{style:{padding:"12px",background:"var(--green-bg)",borderRadius:"var(--radius-sm)",color:"var(--green-dark)",textAlign:"center",marginBottom:"12px",fontWeight:600},children:Ne}),l.jsxs("div",{style:{display:"flex",gap:"8px",marginBottom:"16px"},children:[l.jsx("button",{className:o==="log"?"btn-primary btn-sm":"btn-outline btn-sm",onClick:()=>c("log"),children:"Manual"}),l.jsx("button",{className:o==="scan"?"btn-primary btn-sm":"btn-outline btn-sm",onClick:()=>c("scan"),children:"📷 Scan"}),l.jsx("button",{className:o==="search"?"btn-primary btn-sm":"btn-outline btn-sm",onClick:()=>c("search"),children:"🔍 Search"})]}),o==="scan"&&l.jsxs("div",{className:"card",children:[l.jsxs("div",{className:"form-group",children:[l.jsx("label",{className:"form-label",children:"Barcode number"}),l.jsx("input",{value:_,onChange:I=>B(I.target.value),placeholder:"Enter or scan barcode",inputMode:"numeric"})]}),l.jsx("button",{className:"btn-primary btn-full",onClick:Be,disabled:oe,children:oe?"Looking up...":"Lookup"}),l.jsx("p",{style:{fontSize:"0.8rem",color:"var(--text-3)",marginTop:"8px",textAlign:"center"},children:"Powered by Open Food Facts (4M+ products)"})]}),o==="search"&&l.jsxs("div",{className:"card",children:[l.jsxs("div",{style:{display:"flex",gap:"8px",marginBottom:"12px"},children:[l.jsx("input",{value:W,onChange:I=>Z(I.target.value),placeholder:"Search foods...",onKeyDown:I=>I.key==="Enter"&&Se()}),l.jsx("button",{className:"btn-primary btn-sm",onClick:Se,disabled:oe,children:"Go"})]}),fe.map((I,V)=>l.jsxs("div",{style:{padding:"10px 0",borderBottom:"1px solid var(--divider)",cursor:"pointer"},onClick:()=>Me(I),children:[l.jsx("div",{style:{fontWeight:600,fontSize:"0.9rem"},children:I.product_name||"Unknown"}),l.jsxs("div",{style:{fontSize:"0.8rem",color:"var(--text-3)"},children:[I.brand&&`${I.brand} · `,I.calories_100g?`${Math.round(I.calories_100g)} cal/100g`:""]})]},V))]}),o==="log"&&l.jsx("form",{onSubmit:H,children:l.jsxs("div",{className:"card",children:[l.jsxs("div",{className:"form-group",children:[l.jsx("label",{className:"form-label",children:"Meal"}),l.jsx("div",{style:{display:"flex",gap:"6px"},children:zc.map(I=>l.jsx("button",{type:"button",className:s===I?"btn-primary btn-sm":"btn-outline btn-sm",onClick:()=>d(I),children:I.charAt(0).toUpperCase()+I.slice(1)},I))})]}),l.jsxs("div",{className:"form-group",children:[l.jsx("label",{className:"form-label",children:"Food name"}),l.jsx("input",{value:f,onChange:I=>v(I.target.value),placeholder:"e.g. Grilled chicken breast",required:!0})]}),l.jsxs("div",{className:"form-group",children:[l.jsx("label",{className:"form-label",children:"Brand (optional)"}),l.jsx("input",{value:g,onChange:I=>z(I.target.value),placeholder:""})]}),l.jsxs("div",{style:{display:"grid",gridTemplateColumns:"1fr 1fr",gap:"10px"},children:[l.jsxs("div",{className:"form-group",children:[l.jsx("label",{className:"form-label",children:"Calories"}),l.jsx("input",{type:"number",value:S,onChange:I=>T(I.target.value),placeholder:"per serving"})]}),l.jsxs("div",{className:"form-group",children:[l.jsx("label",{className:"form-label",children:"Servings"}),l.jsx("input",{type:"number",step:"0.5",value:j,onChange:I=>F(I.target.value)})]}),l.jsxs("div",{className:"form-group",children:[l.jsx("label",{className:"form-label",children:"Protein (g)"}),l.jsx("input",{type:"number",value:P,onChange:I=>y(I.target.value)})]}),l.jsxs("div",{className:"form-group",children:[l.jsx("label",{className:"form-label",children:"Carbs (g)"}),l.jsx("input",{type:"number",value:L,onChange:I=>A(I.target.value)})]}),l.jsxs("div",{className:"form-group",children:[l.jsx("label",{className:"form-label",children:"Fat (g)"}),l.jsx("input",{type:"number",value:R,onChange:I=>N(I.target.value)})]})]}),l.jsx("button",{type:"submit",className:"btn-primary btn-full",disabled:oe,children:oe?"Saving...":"Log Food"})]})}),xe&&l.jsxs("div",{className:"card",style:{marginTop:"8px"},children:[l.jsxs("div",{style:{fontFamily:"var(--font-display)",fontWeight:800,marginBottom:"10px"},children:["Today: ",Math.round(xe.total_calories)," cal"]}),zc.map(I=>{const V=xe.meals[I]||[];return V.length===0?null:l.jsxs("div",{className:"meal-section",children:[l.jsx("div",{className:"meal-title",children:I}),V.map(w=>l.jsxs("div",{style:{display:"flex",justifyContent:"space-between",padding:"4px 0",fontSize:"0.9rem"},children:[l.jsx("span",{children:w.food_name}),l.jsx("span",{style:{color:"var(--text-3)"},children:w.calories?`${Math.round(w.calories)} cal`:""})]},w.id))]},I)})]})]})}const Pc=[{label:"16:8 Daily",hours:16,type:"daily"},{label:"18:6",hours:18,type:"daily"},{label:"20:4",hours:20,type:"daily"},{label:"24h Weekly",hours:24,type:"weekly_24"},{label:"48h",hours:48,type:"long_48"},{label:"72h Kickoff",hours:72,type:"long_72"}];function Gl({label:o,value:c,target:s,unit:d,color:f,inverse:v}){const g=s>0?Math.min(100,Math.round(c/s*100)):0,z=v?c<=s:c>=s*.85;return l.jsxs("div",{style:{marginBottom:"14px"},children:[l.jsxs("div",{style:{display:"flex",justifyContent:"space-between",marginBottom:"4px",fontSize:"0.85rem"},children:[l.jsx("span",{style:{fontWeight:700},children:o}),l.jsxs("span",{style:{color:z?"var(--green-dark)":"var(--text-3)",fontWeight:600},children:[Math.round(c),v?"":` / ${s}`," ",d,v&&` / ${s} cap`]})]}),l.jsx("div",{className:"progress-bar",style:{height:"8px"},children:l.jsx("div",{className:"progress-bar-fill",style:{width:`${g}%`,background:v?c<=s?"var(--green)":"var(--red)":f}})})]})}function xm({fast:o}){const[,c]=m.useState(0);m.useEffect(()=>{const z=setInterval(()=>c(S=>S+1),6e4);return()=>clearInterval(z)},[]);const s=(new Date-new Date(o.started_at))/1e3/3600,d=Math.min(100,s/o.target_hours*100),f=s>=o.target_hours,v=Math.floor(s),g=Math.floor((s-v)*60);return l.jsxs("div",{children:[l.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"baseline",marginBottom:"8px"},children:[l.jsxs("span",{style:{fontFamily:"var(--font-display)",fontWeight:900,fontSize:"1.6rem"},children:[v,"h ",g,"m"]}),l.jsxs("span",{style:{fontSize:"0.85rem",color:"var(--text-3)",fontWeight:600},children:["/ ",o.target_hours,"h target"]})]}),l.jsx("div",{className:"progress-bar",style:{height:"12px"},children:l.jsx("div",{className:"progress-bar-fill",style:{width:`${d}%`,background:f?"linear-gradient(90deg, #00C853, #69F0AE)":"linear-gradient(90deg, #7C4DFF, #B388FF)"}})}),f&&l.jsx("div",{style:{marginTop:"8px",fontSize:"0.9rem",color:"var(--green-dark)",fontWeight:700},children:"✓ Target reached — you can end now or push further"})]})}function Sm(){const[o,c]=m.useState(null),[s,d]=m.useState(null),[f,v]=m.useState(!0),[g,z]=m.useState(Pc[0]),[S,T]=m.useState(!1),P=Ge();m.useEffect(()=>{y()},[]);async function y(){try{const[B,W]=await Promise.all([Y.nutritionToday(),Y.getElectrolytesToday()]);c(B),d(W)}catch(B){console.error(B)}finally{v(!1)}}async function L(){T(!0);try{await Y.startFast({target_hours:g.hours,fast_type:g.type}),await y()}catch(B){alert(B.message)}finally{T(!1)}}async function A(){if(confirm("End this fast now?")){T(!0);try{const B=await Y.endFast(o.active_fast.id);alert(`Fast ended. +${B.points_awarded} pts earned.`),await y()}catch(B){alert(B.message)}finally{T(!1)}}}async function R(B){const W={morning:{sodium_mg:2300,potassium_mg:800,magnesium_mg:0,notes:"Morning drink"},midday:{sodium_mg:2300,potassium_mg:800,magnesium_mg:0,notes:"Midday drink"},mag_pm:{sodium_mg:0,potassium_mg:0,magnesium_mg:400,notes:"Magnesium glycinate PM"}};try{await Y.logElectrolytes(W[B]),await y()}catch(Z){alert(Z.message)}}if(f)return l.jsx("div",{className:"page",children:l.jsx("div",{className:"loading",children:"Loading..."})});if(!o)return l.jsx("div",{className:"page",children:l.jsx("div",{className:"error-msg",children:"Failed to load"})});const N=o.macros,j=o.targets,F=o.compliance,_=o.eating_window;return l.jsxs("div",{className:"page",children:[l.jsx("h1",{className:"page-title",children:"Nutrition"}),l.jsxs("div",{className:`gate-banner ${F.compliant_day?"gate-earned":"gate-locked"}`,children:[l.jsx("div",{className:"section-label",style:{marginBottom:"4px"},children:"Today's Keto Day"}),l.jsx("div",{style:{fontFamily:"var(--font-display)",fontWeight:900,fontSize:"1.4rem",color:F.compliant_day?"var(--green)":"var(--accent)"},children:F.compliant_day?"✓ Compliant":"⏳ In progress"}),l.jsxs("div",{style:{fontSize:"0.82rem",color:"var(--text-2)",marginTop:"6px"},children:[F.carb_ok?"✓":"✗"," Net carbs under cap  · ",F.protein_ok?"✓":"✗"," Protein target"]})]}),l.jsxs("div",{className:"card",children:[l.jsx("div",{className:"section-label",children:"Fasting"}),o.active_fast?l.jsxs("div",{children:[l.jsxs("div",{style:{fontSize:"0.8rem",color:"var(--text-3)",marginBottom:"8px"},children:[o.active_fast.fast_type.replace("_"," ")," fast in progress"]}),l.jsx(xm,{fast:o.active_fast}),l.jsx("button",{className:"btn-outline btn-full",onClick:A,disabled:S,style:{marginTop:"14px"},children:"End Fast"})]}):l.jsxs("div",{children:[l.jsxs("div",{style:{marginBottom:"10px",fontSize:"0.85rem",color:"var(--text-2)"},children:["Eating window: ",l.jsx("strong",{children:_.display}),_.in_window_now?l.jsx("span",{style:{color:"var(--green-dark)",marginLeft:"8px"},children:"● open"}):l.jsx("span",{style:{color:"var(--text-3)",marginLeft:"8px"},children:"○ closed"})]}),l.jsx("div",{style:{display:"grid",gridTemplateColumns:"repeat(3, 1fr)",gap:"6px",marginBottom:"12px"},children:Pc.map(B=>l.jsx("button",{className:g.hours===B.hours?"btn-primary btn-sm":"btn-outline btn-sm",onClick:()=>z(B),children:B.label},B.hours))}),l.jsxs("button",{className:"btn-primary btn-full",onClick:L,disabled:S,children:["Start ",g.label," Fast"]})]})]}),l.jsxs("div",{className:"card",children:[l.jsx("div",{className:"section-label",children:"Today's Macros"}),l.jsx(Gl,{label:"Net carbs",value:N.net_carbs_g,target:j.net_carbs_cap,unit:"g",inverse:!0}),l.jsx(Gl,{label:"Protein",value:N.protein_g,target:j.protein_g,unit:"g",color:"var(--accent)"}),l.jsx(Gl,{label:"Fat",value:N.fat_g,target:j.fat_g,unit:"g",color:"#FFA726"}),l.jsx(Gl,{label:"Calories",value:N.calories,target:j.calories,unit:"kcal",color:"#2979FF"}),l.jsx("button",{className:"btn-outline btn-full btn-sm",onClick:()=>P("/food"),style:{marginTop:"8px"},children:"+ Log Food"})]}),s&&l.jsxs("div",{className:"card",children:[l.jsx("div",{className:"section-label",children:"Electrolytes Today"}),l.jsxs("div",{style:{display:"grid",gridTemplateColumns:"repeat(3, 1fr)",gap:"10px",marginBottom:"12px"},children:[l.jsxs("div",{style:{textAlign:"center"},children:[l.jsx("div",{style:{fontSize:"0.72rem",color:"var(--text-3)",fontWeight:700},children:"SODIUM"}),l.jsx("div",{style:{fontFamily:"var(--font-display)",fontWeight:900,fontSize:"1.1rem"},children:s.sodium_mg}),l.jsxs("div",{style:{fontSize:"0.7rem",color:"var(--text-3)"},children:["/ ",s.targets.sodium_mg,"mg"]})]}),l.jsxs("div",{style:{textAlign:"center"},children:[l.jsx("div",{style:{fontSize:"0.72rem",color:"var(--text-3)",fontWeight:700},children:"POTASSIUM"}),l.jsx("div",{style:{fontFamily:"var(--font-display)",fontWeight:900,fontSize:"1.1rem"},children:s.potassium_mg}),l.jsxs("div",{style:{fontSize:"0.7rem",color:"var(--text-3)"},children:["/ ",s.targets.potassium_mg,"mg"]})]}),l.jsxs("div",{style:{textAlign:"center"},children:[l.jsx("div",{style:{fontSize:"0.72rem",color:"var(--text-3)",fontWeight:700},children:"MAGNESIUM"}),l.jsx("div",{style:{fontFamily:"var(--font-display)",fontWeight:900,fontSize:"1.1rem"},children:s.magnesium_mg}),l.jsxs("div",{style:{fontSize:"0.7rem",color:"var(--text-3)"},children:["/ ",s.targets.magnesium_mg,"mg"]})]})]}),l.jsxs("div",{style:{display:"grid",gridTemplateColumns:"repeat(3, 1fr)",gap:"6px"},children:[l.jsx("button",{className:"btn-outline btn-sm",onClick:()=>R("morning"),children:"+ Morning"}),l.jsx("button",{className:"btn-outline btn-sm",onClick:()=>R("midday"),children:"+ Midday"}),l.jsx("button",{className:"btn-outline btn-sm",onClick:()=>R("mag_pm"),children:"+ Mag PM"})]})]})]})}function wm(){const[o,c]=m.useState([]),[s,d]=m.useState(""),[f,v]=m.useState("100"),[g,z]=m.useState(null),[S,T]=m.useState(!0);m.useEffect(()=>{P()},[]);async function P(){try{const[A,R]=await Promise.all([Y.listRewards(),Y.today()]);c(A),z(R)}catch(A){console.error(A)}finally{T(!1)}}async function y(A){if(A.preventDefault(),!!s.trim())try{await Y.createReward({name:s,point_cost:parseInt(f)||100}),d(""),v("100"),await P()}catch(R){alert(R.message)}}async function L(A){try{await Y.redeemReward(A),await P()}catch(R){alert(R.message)}}return S?l.jsx("div",{className:"page",children:l.jsx("div",{className:"loading",children:"Loading..."})}):l.jsxs("div",{className:"page",children:[l.jsx("h1",{className:"page-title",children:"🎮 Rewards"}),g&&l.jsxs("div",{className:`gate-banner ${g.gate_passed?"gate-earned":"gate-locked"}`,style:{padding:"18px",marginBottom:"14px"},children:[l.jsx("div",{style:{fontWeight:800,fontSize:"1rem",fontFamily:"var(--font-display)"},children:g.gate_passed?l.jsx("span",{style:{color:"var(--green-dark)"},children:"✨ Rewards Unlocked"}):l.jsxs("span",{style:{color:"var(--text-2)"},children:["🔒 Earn ",g.points_remaining," more pts"]})}),l.jsxs("div",{style:{fontSize:"0.82rem",color:"var(--text-3)",fontWeight:600,marginTop:"2px"},children:[g.points_earned," / ",g.daily_minimum," pts today"]})]}),l.jsxs("div",{className:"card",children:[l.jsx("div",{className:"section-label",children:"Your Rewards"}),o.length===0&&l.jsx("div",{style:{textAlign:"center",color:"var(--text-3)",padding:"20px",fontWeight:500},children:"No rewards yet — add one below!"}),o.map(A=>l.jsxs("div",{className:"reward-card",children:[l.jsxs("div",{children:[l.jsx("div",{style:{fontWeight:700,fontSize:"0.95rem"},children:A.name}),l.jsxs("div",{style:{fontSize:"0.78rem",color:"var(--text-3)",fontWeight:600},children:[A.point_cost," pts"]})]}),l.jsx("button",{className:g!=null&&g.gate_passed?"btn-success btn-sm":"btn-outline btn-sm",disabled:!(g!=null&&g.gate_passed),onClick:()=>L(A.id),style:{opacity:g!=null&&g.gate_passed?1:.4},children:g!=null&&g.gate_passed?"Redeem":"Locked"})]},A.id))]}),l.jsxs("div",{className:"card",children:[l.jsx("div",{className:"section-label",children:"Add New Reward"}),l.jsxs("form",{onSubmit:y,children:[l.jsxs("div",{style:{display:"grid",gridTemplateColumns:"2fr 1fr",gap:"10px",marginBottom:"10px"},children:[l.jsx("input",{value:s,onChange:A=>d(A.target.value),placeholder:"e.g. 1hr gaming",required:!0}),l.jsx("input",{type:"number",value:f,onChange:A=>v(A.target.value),placeholder:"pts"})]}),l.jsx("button",{type:"submit",className:"btn-primary btn-full btn-sm",children:"Add Reward"})]})]})]})}function jm(){const[o,c]=m.useState(null),[s,d]=m.useState([]),[f,v]=m.useState(null),[g,z]=m.useState(null),[S,T]=m.useState(!0),P=Ge();m.useEffect(()=>{y()},[]);async function y(){try{const[j,F,_,B]=await Promise.all([Y.me(),Y.getRules(),Y.getTargets(),Y.integrationStatus()]);c(j),d(F),v(_),z(B)}catch(j){console.error(j)}finally{T(!1)}}async function L(j,F){try{await Y.updateRule(j,{points:parseInt(F)})}catch(_){alert(_.message)}}async function A(j,F){const _={[j]:parseInt(F)};try{await Y.updateTargets(_),v({...f,..._})}catch(B){alert(B.message)}}async function R(j){try{const F=await(j==="strava"?Y.stravaAuth:Y.polarAuth)();window.location.href=F.auth_url}catch(F){alert(F.message)}}async function N(j){if(confirm(`Disconnect ${j}?`))try{await Y.disconnect(j),await y()}catch(F){alert(F.message)}}return S?l.jsx("div",{className:"page",children:l.jsx("div",{className:"loading",children:"Loading..."})}):l.jsxs("div",{className:"page",children:[l.jsx("h1",{className:"page-title",children:"Settings"}),o&&l.jsxs("div",{className:"card",style:{display:"flex",alignItems:"center",gap:"14px"},children:[l.jsx("div",{style:{width:"48px",height:"48px",borderRadius:"14px",background:"linear-gradient(135deg, var(--accent), var(--accent-light))",display:"flex",alignItems:"center",justifyContent:"center",color:"#fff",fontFamily:"var(--font-display)",fontWeight:900,fontSize:"1.2rem"},children:o.display_name.charAt(0).toUpperCase()}),l.jsxs("div",{children:[l.jsx("div",{style:{fontWeight:700,fontSize:"1rem"},children:o.display_name}),l.jsxs("div",{style:{fontSize:"0.82rem",color:"var(--text-3)",fontWeight:500},children:["@",o.username]})]})]}),l.jsxs("div",{className:"card",children:[l.jsx("div",{className:"section-label",children:"Features"}),l.jsxs("div",{onClick:()=>P("/meal-plan"),style:{display:"flex",alignItems:"center",justifyContent:"space-between",padding:"14px 0",borderBottom:"1px solid var(--divider)",cursor:"pointer"},children:[l.jsxs("div",{style:{display:"flex",alignItems:"center",gap:12},children:[l.jsx("span",{style:{fontSize:"1.2rem"},children:"🍽️"}),l.jsxs("div",{children:[l.jsx("div",{style:{fontWeight:700,fontSize:"0.95rem"},children:"Meal Plans"}),l.jsx("div",{style:{fontSize:"0.78rem",color:"var(--text-3)",fontWeight:500},children:"AI-generated plans with compliance tracking"})]})]}),l.jsx("span",{style:{color:"var(--text-3)",fontSize:"1.1rem"},children:"›"})]}),l.jsxs("div",{onClick:()=>P("/settings/integrations"),style:{display:"flex",alignItems:"center",justifyContent:"space-between",padding:"14px 0",borderBottom:"1px solid var(--divider)",cursor:"pointer"},children:[l.jsxs("div",{style:{display:"flex",alignItems:"center",gap:12},children:[l.jsx("span",{style:{fontSize:"1.2rem"},children:"🔗"}),l.jsxs("div",{children:[l.jsx("div",{style:{fontWeight:700,fontSize:"0.95rem"},children:"All Integrations"}),l.jsx("div",{style:{fontSize:"0.78rem",color:"var(--text-3)",fontWeight:500},children:"Strava, Polar, Garmin, Fitbit, USDA, and more"})]})]}),l.jsx("span",{style:{color:"var(--text-3)",fontSize:"1.1rem"},children:"›"})]}),l.jsxs("div",{onClick:()=>P("/rewards"),style:{display:"flex",alignItems:"center",justifyContent:"space-between",padding:"14px 0",borderBottom:"1px solid var(--divider)",cursor:"pointer"},children:[l.jsxs("div",{style:{display:"flex",alignItems:"center",gap:12},children:[l.jsx("span",{style:{fontSize:"1.2rem"},children:"🎮"}),l.jsxs("div",{children:[l.jsx("div",{style:{fontWeight:700,fontSize:"0.95rem"},children:"Rewards"}),l.jsx("div",{style:{fontSize:"0.78rem",color:"var(--text-3)",fontWeight:500},children:"Configure and redeem your rewards"})]})]}),l.jsx("span",{style:{color:"var(--text-3)",fontSize:"1.1rem"},children:"›"})]}),l.jsxs("div",{onClick:()=>P("/week"),style:{display:"flex",alignItems:"center",justifyContent:"space-between",padding:"14px 0",cursor:"pointer"},children:[l.jsxs("div",{style:{display:"flex",alignItems:"center",gap:12},children:[l.jsx("span",{style:{fontSize:"1.2rem"},children:"📊"}),l.jsxs("div",{children:[l.jsx("div",{style:{fontWeight:700,fontSize:"0.95rem"},children:"Week View"}),l.jsx("div",{style:{fontSize:"0.78rem",color:"var(--text-3)",fontWeight:500},children:"Weekly progress and day-by-day breakdown"})]})]}),l.jsx("span",{style:{color:"var(--text-3)",fontSize:"1.1rem"},children:"›"})]})]}),l.jsxs("div",{className:"card",children:[l.jsx("div",{className:"section-label",children:"Point Rules"}),s.map(j=>l.jsxs("div",{style:{display:"flex",alignItems:"center",gap:"10px",padding:"10px 0",borderBottom:"1px solid var(--divider)"},children:[l.jsx("div",{style:{flex:1,fontSize:"0.88rem",fontWeight:600},children:j.description}),l.jsx("input",{type:"number",style:{width:"64px",textAlign:"center",padding:"8px",fontWeight:700},defaultValue:j.points,onBlur:F=>L(j.id,F.target.value)}),l.jsx("span",{style:{fontSize:"0.78rem",color:"var(--text-3)",fontWeight:600},children:"pts"})]},j.id))]}),f&&l.jsxs("div",{className:"card",children:[l.jsx("div",{className:"section-label",children:"Daily Targets"}),l.jsxs("div",{className:"form-group",children:[l.jsx("label",{className:"form-label",children:"Daily minimum (unlock rewards)"}),l.jsx("input",{type:"number",defaultValue:f.daily_minimum_pts,onBlur:j=>A("daily_minimum_pts",j.target.value)})]}),l.jsxs("div",{className:"form-group",children:[l.jsx("label",{className:"form-label",children:"Weekly target"}),l.jsx("input",{type:"number",defaultValue:f.weekly_target_pts,onBlur:j=>A("weekly_target_pts",j.target.value)})]}),l.jsxs("div",{className:"form-group",children:[l.jsx("label",{className:"form-label",children:"Weekly bonus"}),l.jsx("input",{type:"number",defaultValue:f.weekly_bonus_pts,onBlur:j=>A("weekly_bonus_pts",j.target.value)})]})]}),l.jsxs("div",{className:"card",children:[l.jsx("div",{className:"section-label",children:"Integrations"}),["strava","polar"].map(j=>{const F=(g==null?void 0:g[j])||{connected:!1};return l.jsxs("div",{style:{display:"flex",alignItems:"center",justifyContent:"space-between",padding:"12px 0",borderBottom:"1px solid var(--divider)"},children:[l.jsxs("div",{children:[l.jsx("div",{style:{fontWeight:700,textTransform:"capitalize",fontSize:"0.95rem"},children:j}),l.jsx("div",{style:{fontSize:"0.78rem",color:F.connected?"var(--green-dark)":"var(--text-3)",fontWeight:600},children:F.connected?"● Connected":"Not connected"})]}),F.connected?l.jsx("button",{className:"btn-outline btn-sm",onClick:()=>N(j),children:"Disconnect"}):l.jsx("button",{className:"btn-primary btn-sm",onClick:()=>R(j),children:"Connect"})]},j)}),l.jsx("div",{onClick:()=>P("/settings/integrations"),style:{padding:"12px 0",textAlign:"center",cursor:"pointer",color:"var(--accent)",fontSize:"0.88rem",fontWeight:600},children:"Configure all 11 providers →"})]}),l.jsx("button",{className:"btn-danger btn-full",style:{marginTop:"10px"},onClick:()=>{mm(),P("/login")},children:"Sign Out"})]})}function Ft({text:o,children:c}){const[s,d]=m.useState(!1);return l.jsxs("span",{style:{position:"relative",display:"inline-flex",alignItems:"center"},children:[c,l.jsx("span",{onMouseEnter:()=>d(!0),onMouseLeave:()=>d(!1),onClick:f=>{f.stopPropagation(),d(v=>!v)},style:{display:"inline-flex",alignItems:"center",justifyContent:"center",width:"18px",height:"18px",borderRadius:"50%",marginLeft:"6px",background:"rgba(0,0,0,0.06)",color:"var(--text-3)",cursor:"help",fontSize:"0.7rem",fontWeight:800,flexShrink:0},children:"?"}),s&&l.jsx("span",{style:{position:"absolute",bottom:"calc(100% + 8px)",left:"50%",transform:"translateX(-50%)",background:"var(--text)",color:"#fff",padding:"10px 14px",borderRadius:"var(--r-sm)",fontSize:"0.78rem",lineHeight:1.45,fontWeight:500,width:"240px",boxShadow:"var(--shadow-3)",zIndex:50,pointerEvents:"none"},children:o})]})}const km=[{value:"lose_weight",label:"Lose Weight",icon:"⚖️",tip:"Focus on caloric deficit through cardio and portion-controlled nutrition."},{value:"build_strength",label:"Build Strength",icon:"💪",tip:"Progressive overload training — gradually increasing weight or resistance."},{value:"get_active",label:"Get More Active",icon:"🏃",tip:"Building a consistent movement habit. Walking counts!"},{value:"feel_better",label:"Feel Better Overall",icon:"😊",tip:"Mind-body balance — stress relief, sleep quality, energy levels."}],_m=[{value:"precontemplation",label:"I'm not exercising and haven't thought about starting",tip:"Pre-contemplation: No intention to act. We'll start with awareness and gentle nudges."},{value:"contemplation",label:"I've been thinking about getting more active but haven't started",tip:"Contemplation: Weighing pros and cons. We'll help tip the balance toward action."},{value:"preparation",label:"I do some exercise but not consistently",tip:"Preparation: Ready to commit. We'll help you build a sustainable routine."},{value:"action",label:"I've been exercising regularly for a few months",tip:"Action: Building the habit. We'll help you stay consistent and avoid burnout."},{value:"maintenance",label:"I've been exercising regularly for 6+ months",tip:"Maintenance: Solid habit. We'll help you progress and keep things interesting."}],Cm=["Walking","Hiking","Running","Cycling","Swimming","Bodyweight","Weights","Yoga","Martial Arts","Dance","Team Sports","Pilates","Rowing","Jump Rope","Stretching"],Nm=[{value:"bicycle",label:"🚲 Bicycle"},{value:"pool",label:"🏊 Pool"},{value:"free_weights",label:"🏋️ Free Weights"},{value:"squat_rack",label:"🦵 Squat Rack"},{value:"bench_press",label:"💺 Bench Press"},{value:"machines",label:"⚙️ Machines"},{value:"resistance_bands",label:"🔗 Resistance Bands"},{value:"pull_up_bar",label:"🔩 Pull-up Bar"},{value:"kettlebell",label:"🔔 Kettlebell"},{value:"jump_rope",label:"⏩ Jump Rope"},{value:"yoga_mat",label:"🧘 Yoga Mat"},{value:"treadmill",label:"🏃 Treadmill"},{value:"stationary_bike",label:"🚴 Stationary Bike"},{value:"rowing_machine",label:"🚣 Rowing Machine"}],Em=[{key:"ext",label:'"People important to me say I should exercise"',tip:"External regulation: exercising because others push you to. Least self-determined motivation."},{key:"intro",label:'"I feel bad about myself when I skip exercise"',tip:"Introjected regulation: guilt or obligation as a driver. Better than external, but still fragile."},{key:"ident",label:'"I value what exercise does for my health"',tip:"Identified regulation: you see exercise as personally important. A strong, durable motivator."},{key:"intr",label:'"I find exercise enjoyable and satisfying"',tip:"Intrinsic motivation: you exercise because it's fun. The most sustainable form of motivation."},{key:"amot",label:`"I don't really see why I should bother"`,tip:"Amotivation: no perceived reason to exercise. If this is high, we'll start with very small wins."}];function zm(){const[o,c]=m.useState(0),[s,d]=m.useState(""),[f,v]=m.useState(""),[g,z]=m.useState(""),[S,T]=m.useState(""),[P,y]=m.useState(""),[L,A]=m.useState(""),[R,N]=m.useState({heart:!1,joints:!1,meds:!1}),[j,F]=m.useState({ext:0,intro:0,ident:0,intr:0,amot:0}),[_,B]=m.useState([]),[W,Z]=m.useState([]),[fe,we]=m.useState(3),[xe,re]=m.useState(30),[oe,pe]=m.useState([{name:"",cost:100}]),[Ne,ve]=m.useState([]),[Te,Me]=m.useState(!1),Be=Ge(),Se=8;function H({label:b,value:ee,onChange:ie,tip:de}){return l.jsxs("div",{style:{marginBottom:"16px"},children:[l.jsx("div",{style:{fontSize:"0.9rem",marginBottom:"6px"},children:de?l.jsx(Ft,{text:de,children:b}):b}),l.jsx("div",{className:"likert-row",children:[1,2,3,4,5].map(ye=>l.jsx("button",{type:"button",className:`likert-btn ${ee===ye?"selected":""}`,onClick:()=>ie(ye),children:ye},ye))}),l.jsxs("div",{className:"likert-labels",children:[l.jsx("span",{children:"Not at all"}),l.jsx("span",{children:"Very true"})]})]})}async function I(){Me(!0);try{await Y.savePhase1({primary_goal:s,ttm_stage:f}),c(2)}catch(b){alert(b.message)}finally{Me(!1)}}async function V(){Me(!0);try{let b="none";const ee=["squat_rack","bench_press","machines"],ie=["free_weights","resistance_bands","pull_up_bar","kettlebell","yoga_mat","jump_rope"];W.some(ye=>ee.includes(ye))?b="full_gym":W.some(ye=>ie.includes(ye))&&(b="basic_home"),await Y.savePhase2({age:g?parseInt(g):null,height_cm:S?parseFloat(S):null,weight_kg:P?parseFloat(P):null,gender:L||null,parq_heart_condition:R.heart,parq_joint_issues:R.joints,parq_medications:R.meds,motivation_external:j.ext||null,motivation_introjected:j.intro||null,motivation_identified:j.ident||null,motivation_intrinsic:j.intr||null,motivation_amotivation:j.amot||null,activity_preferences:_.map(ye=>ye.toLowerCase().replace(/ /g,"_")),equipment_access:b,equipment_list:W,days_per_week:fe,minutes_per_session:xe});for(const ye of oe)ye.name.trim()&&await Y.createReward({name:ye.name,point_cost:ye.cost||100});const de=await Y.getRecommendations();ve(de.recommendations||[]),c(7)}catch(b){alert(b.message)}finally{Me(!1)}}async function w(b){try{const ee=new Date,ie=new Date(ee);ie.setDate(ee.getDate()+((8-ee.getDay())%7||7));const de=ie.toISOString().split("T")[0];await Y.createProgram({name:b.name,source:b.source,source_url:b.url,start_date:de,duration_days:b.duration_days||90}),Be("/")}catch(ee){alert(ee.message)}}function O(b){B(ee=>ee.includes(b)?ee.filter(ie=>ie!==b):[...ee,b])}function ae(b){Z(ee=>ee.includes(b)?ee.filter(ie=>ie!==b):[...ee,b])}const ue=Math.round((o+1)/Se*100),se={fontFamily:"var(--font-display)",fontWeight:900,letterSpacing:"-0.02em",marginBottom:"10px",fontSize:"1.4rem"};return l.jsxs("div",{className:"page",style:{paddingTop:"40px"},children:[l.jsx("div",{className:"progress-bar",style:{marginBottom:"24px"},children:l.jsx("div",{className:"progress-bar-fill",style:{width:`${ue}%`,background:"var(--accent)"}})}),o===0&&l.jsxs("div",{children:[l.jsx("h2",{style:se,children:l.jsx(Ft,{text:"Your primary goal shapes which programs we recommend, how we structure your points, and what success looks like for you.",children:"What matters most to you?"})}),l.jsx("p",{style:{color:"var(--text-3)",marginBottom:"20px",fontSize:"0.9rem"},children:"Pick one — you can change this anytime."}),l.jsx("div",{className:"option-grid",children:km.map(b=>l.jsxs("div",{className:`option-btn ${s===b.value?"selected":""}`,onClick:()=>d(b.value),children:[l.jsx("div",{style:{fontSize:"1.5rem",marginBottom:"6px"},children:b.icon}),l.jsx(Ft,{text:b.tip,children:b.label})]},b.value))}),l.jsx("button",{className:"btn-primary btn-full",style:{marginTop:"20px"},disabled:!s,onClick:()=>c(1),children:"Continue"})]}),o===1&&l.jsxs("div",{children:[l.jsx("h2",{style:se,children:l.jsx(Ft,{text:"Based on the Transtheoretical Model (Stages of Change). This helps us match you with the right intensity — pushing too hard too early is the #1 reason people quit.",children:"Where are you now?"})}),l.jsx("p",{style:{color:"var(--text-3)",marginBottom:"20px",fontSize:"0.9rem"},children:"Be honest — there's no wrong answer."}),_m.map(b=>l.jsx("div",{className:`option-btn ${f===b.value?"selected":""}`,style:{textAlign:"left",marginBottom:"10px"},onClick:()=>v(b.value),children:l.jsx(Ft,{text:b.tip,children:b.label})},b.value)),l.jsx("button",{className:"btn-primary btn-full",style:{marginTop:"16px"},disabled:!f||Te,onClick:I,children:Te?"...":"Continue"})]}),o===2&&l.jsxs("div",{children:[l.jsx("h2",{style:se,children:l.jsx(Ft,{text:"Based on the PAR-Q+ (Physical Activity Readiness Questionnaire). A standard pre-exercise safety screening used by fitness professionals worldwide.",children:"Quick health check"})}),l.jsx("p",{style:{color:"var(--text-3)",marginBottom:"20px",fontSize:"0.9rem"},children:"For your safety — this takes 10 seconds."}),[{key:"heart",label:"I have a heart condition or high blood pressure"},{key:"joints",label:"I have bone or joint problems that could worsen with exercise"},{key:"meds",label:"I take prescription medications for a chronic condition"}].map(b=>l.jsxs("label",{style:{display:"flex",gap:"12px",padding:"12px 0",cursor:"pointer",borderBottom:"1px solid var(--divider)"},children:[l.jsx("input",{type:"checkbox",checked:R[b.key],onChange:ee=>N({...R,[b.key]:ee.target.checked}),style:{width:"auto"}}),l.jsx("span",{style:{fontSize:"0.9rem"},children:b.label})]},b.key)),(R.heart||R.joints||R.meds)&&l.jsx("div",{style:{marginTop:"12px",padding:"12px",background:"#FFF3E0",borderRadius:"var(--r-sm)",fontSize:"0.85rem",color:"#E65100"},children:"⚠️ We recommend checking with your doctor before starting. This won't stop you — just be mindful."}),l.jsx("button",{className:"btn-primary btn-full",style:{marginTop:"20px"},onClick:()=>c(3),children:"Continue"})]}),o===3&&l.jsxs("div",{children:[l.jsx("h2",{style:se,children:"About you"}),l.jsx("p",{style:{color:"var(--text-3)",marginBottom:"20px",fontSize:"0.9rem"},children:"Optional — helps estimate nutrition needs."}),l.jsxs("div",{className:"form-group",children:[l.jsx("label",{className:"form-label",children:"Age"}),l.jsx("input",{type:"number",value:g,onChange:b=>z(b.target.value),placeholder:"e.g. 35"})]}),l.jsxs("div",{style:{display:"grid",gridTemplateColumns:"1fr 1fr",gap:"12px"},children:[l.jsxs("div",{className:"form-group",children:[l.jsx("label",{className:"form-label",children:"Height (cm)"}),l.jsx("input",{type:"number",value:S,onChange:b=>T(b.target.value),placeholder:"175"})]}),l.jsxs("div",{className:"form-group",children:[l.jsx("label",{className:"form-label",children:"Weight (kg)"}),l.jsx("input",{type:"number",value:P,onChange:b=>y(b.target.value),placeholder:"80"})]})]}),l.jsxs("div",{className:"form-group",children:[l.jsx("label",{className:"form-label",children:"Gender"}),l.jsxs("select",{value:L,onChange:b=>A(b.target.value),children:[l.jsx("option",{value:"",children:"Prefer not to say"}),l.jsx("option",{value:"male",children:"Male"}),l.jsx("option",{value:"female",children:"Female"})]})]}),l.jsxs("div",{style:{display:"flex",gap:"10px"},children:[l.jsx("button",{className:"btn-outline btn-full",onClick:()=>c(4),children:"Skip"}),l.jsx("button",{className:"btn-primary btn-full",onClick:()=>c(4),children:"Continue"})]})]}),o===4&&l.jsxs("div",{children:[l.jsx("h2",{style:se,children:l.jsx(Ft,{text:"Based on BREQ-2 (Behavioural Regulation in Exercise Questionnaire). Measures your motivation type from external pressure to intrinsic enjoyment. Your Relative Autonomy Index (RAI) score helps us calibrate how much we push vs. encourage.",children:"What drives you?"})}),l.jsx("p",{style:{color:"var(--text-3)",marginBottom:"20px",fontSize:"0.9rem"},children:"Rate each honestly — helps us calibrate your experience."}),Em.map(b=>l.jsx(H,{label:b.label,tip:b.tip,value:j[b.key],onChange:ee=>F({...j,[b.key]:ee})},b.key)),l.jsx("button",{className:"btn-primary btn-full",onClick:()=>c(5),children:"Continue"})]}),o===5&&l.jsxs("div",{children:[l.jsx("h2",{style:se,children:"What sounds fun?"}),l.jsx("p",{style:{color:"var(--text-3)",marginBottom:"16px",fontSize:"0.9rem"},children:"Pick all that interest you."}),l.jsx("div",{className:"chip-grid",style:{marginBottom:"24px"},children:Cm.map(b=>l.jsx("div",{className:`chip ${_.includes(b)?"selected":""}`,onClick:()=>O(b),children:b},b))}),l.jsxs("div",{className:"form-group",children:[l.jsx("label",{className:"form-label",children:l.jsx(Ft,{text:"Select everything you have access to. This helps us recommend programs that match your available equipment — no point suggesting barbell work if you don't have a rack.",children:"Equipment you have access to"})}),l.jsx("div",{className:"chip-grid",children:Nm.map(b=>l.jsx("div",{className:`chip ${W.includes(b.value)?"selected":""}`,onClick:()=>ae(b.value),children:b.label},b.value))}),W.length===0&&l.jsx("p",{style:{color:"var(--text-3)",fontSize:"0.8rem",marginTop:"8px",fontStyle:"italic"},children:"No selection = bodyweight only. That's perfectly fine!"})]}),l.jsxs("div",{style:{display:"grid",gridTemplateColumns:"1fr 1fr",gap:"12px",marginTop:"16px",marginBottom:"16px"},children:[l.jsxs("div",{className:"form-group",children:[l.jsx("label",{className:"form-label",children:"Days/week"}),l.jsx("select",{value:fe,onChange:b=>we(parseInt(b.target.value)),children:[1,2,3,4,5,6,7].map(b=>l.jsx("option",{value:b,children:b},b))})]}),l.jsxs("div",{className:"form-group",children:[l.jsx("label",{className:"form-label",children:"Minutes/session"}),l.jsx("select",{value:xe,onChange:b=>re(parseInt(b.target.value)),children:[15,20,30,45,60,90].map(b=>l.jsx("option",{value:b,children:b},b))})]})]}),l.jsx("button",{className:"btn-primary btn-full",onClick:()=>c(6),children:"Continue"})]}),o===6&&l.jsxs("div",{children:[l.jsx("h2",{style:se,children:l.jsx(Ft,{text:"The reward gate is the core mechanic: you can't access your rewards until you hit your daily point minimum. This creates a behavioral contract — earn first, enjoy second.",children:"Define your rewards"})}),l.jsx("p",{style:{color:"var(--text-3)",marginBottom:"20px",fontSize:"0.9rem"},children:"What guilty pleasures do you want to earn?"}),oe.map((b,ee)=>l.jsxs("div",{style:{display:"grid",gridTemplateColumns:"2fr 1fr",gap:"10px",marginBottom:"12px"},children:[l.jsx("input",{placeholder:"e.g. 1hr gaming",value:b.name,onChange:ie=>{const de=[...oe];de[ee]={...b,name:ie.target.value},pe(de)}}),l.jsx("input",{type:"number",placeholder:"100",value:b.cost,onChange:ie=>{const de=[...oe];de[ee]={...b,cost:parseInt(ie.target.value)||0},pe(de)}})]},ee)),l.jsx("button",{className:"btn-outline btn-sm",style:{marginBottom:"20px"},onClick:()=>pe([...oe,{name:"",cost:100}]),children:"+ Add another reward"}),l.jsx("button",{className:"btn-primary btn-full",disabled:Te,onClick:V,children:Te?"Saving...":"See Recommendations"})]}),o===7&&l.jsxs("div",{children:[l.jsx("h2",{style:se,children:"Recommended for you"}),l.jsx("p",{style:{color:"var(--text-3)",marginBottom:"20px",fontSize:"0.9rem"},children:"Pick a program and commit to 90 days."}),Ne.map(b=>l.jsxs("div",{className:"card",children:[l.jsx("div",{style:{fontWeight:700,marginBottom:"4px"},children:b.name}),l.jsxs("div",{style:{fontSize:"0.85rem",color:"var(--text-3)",marginBottom:"8px"},children:[b.difficulty," • ",b.duration_days?`${b.duration_days} days`:"Ongoing"," • ",b.source]}),l.jsx("p",{style:{fontSize:"0.9rem",marginBottom:"12px"},children:b.description}),l.jsxs("div",{style:{display:"flex",gap:"8px"},children:[l.jsx("a",{href:b.url,target:"_blank",rel:"noopener noreferrer",className:"btn-outline btn-sm",style:{display:"inline-block"},children:"View ↗"}),l.jsx("button",{className:"btn-primary btn-sm",onClick:()=>w(b),children:"Select & Commit"})]})]},b.id)),l.jsx("button",{className:"btn-outline btn-full",style:{marginTop:"8px"},onClick:()=>Be("/"),children:"Skip for now"})]})]})}function Pm(){const[o,c]=m.useState(null),[s,d]=m.useState(!0);m.useEffect(()=>{f()},[]);async function f(){try{c(await Y.week())}catch(S){console.error(S)}finally{d(!1)}}if(s)return l.jsx("div",{className:"page",children:l.jsx("div",{className:"loading",children:"Loading..."})});if(!o)return l.jsx("div",{className:"page",children:l.jsx("div",{className:"error-msg",children:"Failed to load"})});const v=["Mon","Tue","Wed","Thu","Fri","Sat","Sun"],g=o.weekly_target>0?Math.min(100,Math.round(o.total_points_earned/o.weekly_target*100)):0,z=new Date().toISOString().split("T")[0];return l.jsxs("div",{className:"page",children:[l.jsx("h1",{className:"page-title",children:"This Week"}),l.jsxs("div",{className:"card",style:{textAlign:"center",background:"var(--accent-bg)"},children:[l.jsx("div",{className:"section-label",children:"Weekly Total"}),l.jsx("div",{style:{fontFamily:"var(--font-display)",fontSize:"2.6rem",fontWeight:900,letterSpacing:"-0.03em"},children:o.total_points_earned}),l.jsxs("div",{style:{color:"var(--text-3)",fontWeight:600,fontSize:"0.88rem",marginBottom:"12px"},children:["/ ",o.weekly_target," pts"]}),l.jsx("div",{className:"progress-bar",style:{height:"12px"},children:l.jsx("div",{className:"progress-bar-fill",style:{width:`${g}%`,background:o.hit_weekly_target?"linear-gradient(90deg, #00C853, #69F0AE)":"linear-gradient(90deg, #7C4DFF, #B388FF)"}})}),o.hit_weekly_target&&l.jsxs("div",{style:{marginTop:"10px",color:"var(--green-dark)",fontWeight:700,fontSize:"0.9rem"},children:["🏆 Target hit! +",o.weekly_bonus_earned," bonus"]}),l.jsxs("div",{style:{display:"flex",justifyContent:"center",gap:"28px",marginTop:"14px"},children:[l.jsxs("div",{children:[l.jsx("div",{style:{fontFamily:"var(--font-display)",fontWeight:900,fontSize:"1.3rem"},children:o.active_days}),l.jsx("div",{style:{fontSize:"0.72rem",color:"var(--text-3)",fontWeight:600},children:"active days"})]}),l.jsxs("div",{children:[l.jsx("div",{style:{fontFamily:"var(--font-display)",fontWeight:900,fontSize:"1.3rem"},children:o.gate_passed_days}),l.jsx("div",{style:{fontSize:"0.72rem",color:"var(--text-3)",fontWeight:600},children:"rewards earned"})]})]})]}),l.jsxs("div",{className:"card",children:[l.jsx("div",{className:"section-label",children:"Daily Breakdown"}),o.daily_breakdown.map((S,T)=>{const P=S.daily_minimum>0?Math.min(100,Math.round(S.points_earned/S.daily_minimum*100)):0,y=S.date===z;return l.jsxs("div",{style:{display:"flex",alignItems:"center",gap:"10px",padding:"10px 0",borderBottom:T<6?"1px solid var(--divider)":"none"},children:[l.jsx("div",{style:{width:"38px",fontWeight:y?800:600,fontSize:"0.85rem",color:y?"var(--accent)":"var(--text)"},children:v[T]}),l.jsx("div",{style:{flex:1},children:l.jsx("div",{className:"progress-bar",style:{height:"8px"},children:l.jsx("div",{className:"progress-bar-fill",style:{width:`${P}%`,background:S.gate_passed?"var(--green)":S.points_earned>0?"var(--amber)":"transparent"}})})}),l.jsx("div",{style:{width:"46px",textAlign:"right",fontFamily:"var(--font-display)",fontWeight:800,fontSize:"0.9rem"},children:S.points_earned}),l.jsx("div",{style:{width:"22px",textAlign:"center",fontSize:"0.9rem"},children:S.gate_passed?"✅":S.points_earned>0?"🟡":"⬜"})]},S.date)})]})]})}function Tm(){const o=Ge();return l.jsxs("div",{className:"page",style:{maxWidth:600,margin:"0 auto",padding:"40px 20px",textAlign:"center"},children:[l.jsx("div",{style:{fontSize:"3rem",marginBottom:16},children:"💪"}),l.jsx("h1",{style:{fontSize:"1.8rem",fontWeight:700,marginBottom:8},children:"Earn Before You Spend"}),l.jsx("p",{style:{color:"var(--text-2)",fontSize:"1rem",lineHeight:1.6,marginBottom:32},children:"This app runs on a simple idea: you earn points by doing healthy things (workouts, logging meals, hitting step goals), and you spend points on guilty pleasures you configure yourself."}),l.jsxs("div",{style:{background:"var(--surface-2)",borderRadius:"var(--r-md)",padding:24,marginBottom:24,textAlign:"left"},children:[l.jsxs("div",{style:{display:"flex",gap:16,marginBottom:16},children:[l.jsx("span",{style:{fontSize:"1.5rem"},children:"🔒"}),l.jsxs("div",{children:[l.jsx("strong",{children:"Daily Gate"}),l.jsx("p",{style:{color:"var(--text-2)",margin:"4px 0 0",fontSize:"0.9rem"},children:"Until you earn enough points today, your rewards stay locked. Hit your target, and the gate opens."})]})]}),l.jsxs("div",{style:{display:"flex",gap:16,marginBottom:16},children:[l.jsx("span",{style:{fontSize:"1.5rem"},children:"🎮"}),l.jsxs("div",{children:[l.jsx("strong",{children:"Your Rules"}),l.jsx("p",{style:{color:"var(--text-2)",margin:"4px 0 0",fontSize:"0.9rem"},children:"You set the rewards — gaming time, takeout, screen time, whatever you want. You hold yourself accountable."})]})]}),l.jsxs("div",{style:{display:"flex",gap:16},children:[l.jsx("span",{style:{fontSize:"1.5rem"},children:"🤖"}),l.jsxs("div",{children:[l.jsx("strong",{children:"AI Agent (Optional)"}),l.jsx("p",{style:{color:"var(--text-2)",margin:"4px 0 0",fontSize:"0.9rem"},children:"Connect an AI agent to log activities by voice, get nudged when you're behind, and have a fitness companion that knows your goals."})]})]})]}),l.jsx("button",{onClick:()=>o("/register"),style:{background:"var(--accent)",color:"#fff",border:"none",borderRadius:"var(--r-sm)",padding:"14px 32px",fontSize:"1rem",fontWeight:600,cursor:"pointer",width:"100%"},children:"Get Started"}),l.jsx("p",{style:{color:"var(--text-3)",fontSize:"0.8rem",marginTop:16},children:"Points reset weekly. Each week is a fresh start."})]})}const Tc={connected:"var(--green)",configured:"var(--amber)",not_configured:"var(--text-3)"},bm={connected:"Connected",configured:"Configured",not_configured:"Not configured"};function Rm(){const[o,c]=m.useState({}),[s,d]=m.useState({}),[f,v]=m.useState(!0),[g,z]=m.useState(null),[S,T]=m.useState({}),[P,y]=m.useState(!1);m.useEffect(()=>{L()},[]);async function L(){try{const[N,j]=await Promise.all([Y.listProviders(),Y.fullIntegrationStatus()]);c(N),d(j)}catch(N){console.error(N)}finally{v(!1)}}function A(N){var F;z(N);const j=((F=o[N])==null?void 0:F.fields)||[];T(Object.fromEntries(j.map(_=>[_,""])))}async function R(N){y(!0);try{await Y.configureIntegration(N,S),z(null),await L()}catch(j){alert(`Failed: ${j.message}`)}finally{y(!1)}}return f?l.jsx("div",{className:"page",children:l.jsx("div",{className:"loading",children:"Loading..."})}):l.jsxs("div",{className:"page",style:{maxWidth:700,margin:"0 auto"},children:[l.jsx("h1",{style:{fontSize:"1.4rem",fontWeight:700,marginBottom:24},children:"Integrations"}),l.jsx("p",{style:{color:"var(--text-2)",marginBottom:24,fontSize:"0.9rem"},children:"Configure your fitness devices and services. Credentials are encrypted and stored locally — never sent to any external server."}),Object.entries(o).map(([N,j])=>l.jsxs("div",{style:{background:"var(--surface-2)",borderRadius:"var(--r-md)",padding:20,marginBottom:12,border:s[N]==="connected"?"2px solid var(--accent)":"1px solid var(--border)"},children:[l.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:8},children:[l.jsx("strong",{style:{fontSize:"1rem"},children:j.name}),l.jsx("span",{style:{fontSize:"0.75rem",padding:"3px 10px",borderRadius:20,fontWeight:600,background:Tc[s[N]||"not_configured"]+"22",color:Tc[s[N]||"not_configured"]},children:bm[s[N]||"not_configured"]})]}),l.jsx("p",{style:{color:"var(--text-2)",fontSize:"0.85rem",margin:"0 0 12px"},children:j.help_text}),j.help_url&&l.jsx("a",{href:j.help_url,target:"_blank",rel:"noopener noreferrer",style:{fontSize:"0.8rem",color:"var(--accent)"},children:"Developer portal →"}),g===N?l.jsxs("div",{style:{marginTop:12,padding:16,background:"var(--surface)",borderRadius:"var(--r-sm)"},children:[j.fields.map(F=>l.jsxs("div",{style:{marginBottom:10},children:[l.jsx("label",{style:{fontSize:"0.8rem",fontWeight:600,display:"block",marginBottom:4},children:F.replace(/_/g," ")}),l.jsx("input",{type:F.includes("secret")||F.includes("token")||F.includes("key")?"password":"text",value:S[F]||"",onChange:_=>T({...S,[F]:_.target.value}),style:{width:"100%",padding:"8px 12px",borderRadius:"var(--r-sm)",border:"1px solid var(--border)",fontSize:"0.9rem",boxSizing:"border-box"},placeholder:`Enter ${F.replace(/_/g," ")}`})]},F)),l.jsxs("div",{style:{display:"flex",gap:8,marginTop:8},children:[l.jsx("button",{onClick:()=>R(N),disabled:P,style:{background:"var(--accent)",color:"#fff",border:"none",padding:"8px 20px",borderRadius:"var(--r-sm)",cursor:"pointer",fontWeight:600},children:P?"Saving...":"Save"}),l.jsx("button",{onClick:()=>z(null),style:{background:"transparent",color:"var(--text-2)",border:"1px solid var(--border)",padding:"8px 20px",borderRadius:"var(--r-sm)",cursor:"pointer"},children:"Cancel"})]})]}):l.jsx("button",{onClick:()=>A(N),style:{marginTop:8,background:"transparent",color:"var(--accent)",border:"1px solid var(--accent)",padding:"6px 16px",borderRadius:"var(--r-sm)",cursor:"pointer",fontSize:"0.85rem",fontWeight:600},children:s[N]==="not_configured"?"Configure":"Reconfigure"})]},N))]})}const Lm={breakfast:"🌅",lunch:"🌞",dinner:"🌙",snack:"🍎"};function Im(){var P;const[o,c]=m.useState(null),[s,d]=m.useState([]),[f,v]=m.useState(!0),[g,z]=m.useState({});m.useEffect(()=>{S()},[]);async function S(){try{const[y,L]=await Promise.all([Y.getMealPlanToday(),Y.listMealPlans()]);c(y),d(L)}catch(y){console.error(y)}finally{v(!1)}}async function T(y,L){try{await Y.logMealCompliance({plan_item_id:y,status:L}),z(A=>({...A,[y]:L}))}catch(A){alert(`Failed: ${A.message}`)}}return f?l.jsx("div",{className:"page",children:l.jsx("div",{className:"loading",children:"Loading..."})}):l.jsxs("div",{className:"page",style:{maxWidth:600,margin:"0 auto"},children:[l.jsx("h1",{style:{fontSize:"1.4rem",fontWeight:700,marginBottom:8},children:"Meal Plan"}),o!=null&&o.active_plan?l.jsxs(l.Fragment,{children:[l.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:20,color:"var(--text-2)",fontSize:"0.9rem"},children:[l.jsx("span",{children:o.active_plan}),l.jsxs("span",{style:{background:"var(--accent)",color:"#fff",padding:"3px 10px",borderRadius:20,fontSize:"0.75rem",fontWeight:600},children:["Day ",o.day," / ",o.duration_days]})]}),o.daily_calories&&l.jsxs("div",{style:{background:"var(--surface-2)",borderRadius:"var(--r-sm)",padding:12,marginBottom:16,textAlign:"center",fontSize:"0.85rem",color:"var(--text-2)"},children:["Target: ",o.daily_calories," cal · ",o.diet_type||"balanced"]}),((P=o.meals)==null?void 0:P.length)>0?o.meals.map(y=>{const L=g[y.id];return l.jsxs("div",{style:{background:"var(--surface-2)",borderRadius:"var(--r-md)",padding:16,marginBottom:10,borderLeft:L==="followed"?"4px solid var(--green)":L==="skipped"?"4px solid var(--red)":L==="substituted"?"4px solid var(--amber)":"4px solid transparent"},children:[l.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"flex-start"},children:[l.jsxs("div",{children:[l.jsxs("div",{style:{fontSize:"0.75rem",color:"var(--text-3)",textTransform:"uppercase",marginBottom:4},children:[Lm[y.meal_type]||"🍽️"," ",y.meal_type]}),l.jsx("div",{style:{fontWeight:600,marginBottom:4},children:y.food_name}),y.description&&l.jsx("div",{style:{fontSize:"0.85rem",color:"var(--text-2)"},children:y.description})]}),y.calories&&l.jsxs("div",{style:{fontSize:"0.85rem",fontWeight:600,color:"var(--text-2)",flexShrink:0,marginLeft:12},children:[y.calories," cal"]})]}),y.protein_g&&l.jsxs("div",{style:{fontSize:"0.75rem",color:"var(--text-3)",marginTop:6},children:["P: ",y.protein_g,"g · C: ",y.carbs_g,"g · F: ",y.fat_g,"g"]}),!L&&l.jsx("div",{style:{display:"flex",gap:6,marginTop:10},children:["followed","substituted","skipped"].map(A=>l.jsxs("button",{onClick:()=>T(y.id,A),style:{background:"transparent",border:"1px solid var(--border)",padding:"4px 12px",borderRadius:"var(--r-sm)",fontSize:"0.75rem",cursor:"pointer",color:"var(--text-2)",textTransform:"capitalize"},children:[A==="followed"?"✅":A==="skipped"?"⏭️":"🔄"," ",A]},A))})]},y.id)}):l.jsx("p",{style:{color:"var(--text-2)",textAlign:"center"},children:o.message||"No meals for today."})]}):l.jsxs("div",{style:{background:"var(--surface-2)",borderRadius:"var(--r-md)",padding:32,textAlign:"center",color:"var(--text-2)"},children:[l.jsx("div",{style:{fontSize:"2rem",marginBottom:12},children:"🍽️"}),l.jsx("p",{style:{marginBottom:8},children:"No active meal plan."}),l.jsx("p",{style:{fontSize:"0.85rem"},children:'Ask your AI agent to create one: "Generate a 7-day keto meal plan for me"'})]}),s.length>0&&l.jsxs("div",{style:{marginTop:32},children:[l.jsx("h2",{style:{fontSize:"1.1rem",fontWeight:600,marginBottom:12},children:"All Plans"}),s.map(y=>l.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",padding:"10px 0",borderBottom:"1px solid var(--border)"},children:[l.jsxs("div",{children:[l.jsx("div",{style:{fontWeight:500},children:y.name}),l.jsxs("div",{style:{fontSize:"0.8rem",color:"var(--text-3)"},children:[y.diet_type," · ",y.duration_days," days · started ",y.start_date]})]}),l.jsx("span",{style:{fontSize:"0.75rem",padding:"2px 8px",borderRadius:12,background:y.status==="active"?"var(--green)22":"var(--text-3)22",color:y.status==="active"?"var(--green)":"var(--text-3)",fontWeight:600},children:y.status})]},y.id))]})]})}const bc={beginner:"var(--green)",intermediate:"var(--accent)",advanced:"var(--accent-light)"},Fm={strength:"🏋️",cardio:"🏃",flexibility:"🧘",hybrid:"⚡"},Rc={pending:{label:"Queued",color:"var(--amber)"},crawling:{label:"Fetching...",color:"var(--accent)"},extracting:{label:"Analyzing...",color:"var(--accent-light)"},ready:{label:"Ready",color:"var(--green)"},failed:{label:"Failed",color:"var(--red)"}};function Bm(){const[o,c]=m.useState(""),[s,d]=m.useState([]),[f,v]=m.useState([]),[g,z]=m.useState(!0),[S,T]=m.useState(!1),[P,y]=m.useState(null),L=Ge();m.useEffect(()=>{A()},[]);async function A(){try{const[_,B]=await Promise.all([Y.searchCatalog(""),Y.listPrograms()]);d(_),v(B)}catch(_){console.error(_)}finally{z(!1)}}async function R(_){if(_.preventDefault(),!!o.trim())try{const B=await Y.searchCatalog(o);d(B)}catch(B){console.error(B)}}async function N(){if(o.trim()){T(!0),y(null);try{const _=await Y.researchProgram(o);_.already_exists?y({type:"info",text:`"${_.name}" is already in the catalog.`}):y({type:"success",text:_.message||"Program queued for research!"}),await A()}catch(_){y({type:"error",text:_.message})}finally{T(!1)}}}async function j(_){const B=new Date().toISOString().slice(0,10);try{await Y.adoptProgram(_,B),y({type:"success",text:"Program started! Check your dashboard."}),await A()}catch(W){y({type:"error",text:W.message})}}function F(_){return f.some(B=>B.status==="active"&&B.catalog_id===_)}return g?l.jsx("div",{style:{padding:"2rem",textAlign:"center",color:"var(--text-3)"},children:"Loading..."}):l.jsxs("div",{style:{padding:"1rem",paddingBottom:"96px",maxWidth:"600px",margin:"0 auto"},children:[l.jsx("h1",{style:{fontFamily:"var(--font-display)",fontSize:"1.6rem",fontWeight:900,marginBottom:"0.5rem"},children:"Programs"}),l.jsx("p",{style:{color:"var(--text-3)",fontSize:"0.85rem",marginBottom:"1.5rem"},children:"Find a program or tell us what you're doing — we'll set it up for you."}),l.jsxs("form",{onSubmit:R,style:{display:"flex",gap:"0.5rem",marginBottom:"1rem"},children:[l.jsx("input",{type:"text",value:o,onChange:_=>c(_.target.value),placeholder:'e.g. "StrongLifts 5x5" or "Couch to 5K"',style:{flex:1,padding:"12px 16px",borderRadius:"var(--r)",border:"1px solid var(--divider)",fontFamily:"var(--font)",fontSize:"0.9rem",background:"var(--card)"}}),l.jsx("button",{type:"submit",style:{background:"var(--accent)",color:"#fff",padding:"12px 18px",boxShadow:"var(--shadow-1)"},children:"Search"})]}),l.jsx("button",{onClick:N,disabled:S||!o.trim(),style:{width:"100%",background:S?"var(--text-3)":"var(--accent)",color:"#fff",padding:"14px",marginBottom:"1rem",boxShadow:S?"none":"var(--shadow-accent)",opacity:o.trim()?1:.5},children:S?"Researching...":`Research "${o||"..."}" for me`}),P&&l.jsx("div",{style:{padding:"12px 16px",borderRadius:"var(--r-sm)",marginBottom:"1rem",fontSize:"0.85rem",fontWeight:600,background:P.type==="error"?"var(--red-ghost)":P.type==="success"?"var(--green-bg)":"var(--accent-bg)",color:P.type==="error"?"var(--red)":P.type==="success"?"var(--green-dark)":"var(--accent)"},children:P.text}),f.filter(_=>_.status==="active").length>0&&l.jsxs(l.Fragment,{children:[l.jsx("h2",{style:{fontFamily:"var(--font-display)",fontSize:"1.1rem",fontWeight:800,marginBottom:"0.75rem"},children:"Your Active Programs"}),f.filter(_=>_.status==="active").map(_=>l.jsxs("div",{onClick:()=>L(`/programs/${_.id}`),style:{background:"var(--card)",borderRadius:"var(--r)",padding:"16px",marginBottom:"0.75rem",boxShadow:"var(--shadow-1)",cursor:"pointer",border:"2px solid var(--accent-glow)"},children:[l.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center"},children:[l.jsx("strong",{style:{fontFamily:"var(--font-display)",fontSize:"1rem"},children:_.name}),l.jsxs("span",{style:{fontSize:"0.8rem",color:"var(--accent)",fontWeight:700},children:["Day ",_.current_day,"/",_.total_days]})]}),l.jsx("div",{style:{marginTop:"8px",height:"6px",borderRadius:"3px",background:"var(--divider)"},children:l.jsx("div",{style:{height:"100%",borderRadius:"3px",background:"var(--accent)",width:`${Math.min(100,_.current_day/_.total_days*100)}%`,transition:"width 0.3s ease"}})})]},_.id))]}),l.jsx("h2",{style:{fontFamily:"var(--font-display)",fontSize:"1.1rem",fontWeight:800,marginTop:"1.5rem",marginBottom:"0.75rem"},children:"Program Catalog"}),s.length===0&&l.jsx("p",{style:{color:"var(--text-3)",fontSize:"0.85rem",textAlign:"center",padding:"2rem 0"},children:"No programs found. Try searching or researching a program above."}),s.map(_=>{const B=Rc[_.crawl_status]||Rc.pending,W=F(_.id);return l.jsxs("div",{style:{background:"var(--card)",borderRadius:"var(--r)",padding:"16px",marginBottom:"0.75rem",boxShadow:"var(--shadow-1)"},children:[l.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"flex-start"},children:[l.jsxs("div",{children:[l.jsxs("strong",{style:{fontFamily:"var(--font-display)",fontSize:"1rem"},children:[Fm[_.category]||"📋"," ",_.name]}),_.description&&l.jsx("p",{style:{color:"var(--text-2)",fontSize:"0.82rem",marginTop:"4px"},children:_.description})]}),l.jsx("span",{style:{fontSize:"0.7rem",fontWeight:700,padding:"3px 8px",borderRadius:"var(--r-full)",color:"#fff",background:B.color,whiteSpace:"nowrap"},children:B.label})]}),l.jsxs("div",{style:{display:"flex",gap:"0.5rem",flexWrap:"wrap",marginTop:"10px"},children:[_.duration_weeks&&l.jsxs("span",{style:Xl,children:[_.duration_weeks," weeks"]}),_.frequency_per_week&&l.jsxs("span",{style:Xl,children:[_.frequency_per_week,"x/week"]}),_.difficulty&&l.jsx("span",{style:{...Xl,color:bc[_.difficulty]||"var(--text-3)",background:`${bc[_.difficulty]||"var(--text-3)"}11`},children:_.difficulty}),(_.equipment||[]).slice(0,3).map(Z=>l.jsx("span",{style:Xl,children:Z},Z))]}),_.crawl_status==="ready"&&!W&&l.jsx("button",{onClick:()=>j(_.id),style:{marginTop:"12px",width:"100%",background:"var(--green)",color:"#fff",padding:"10px",fontSize:"0.85rem",boxShadow:"var(--shadow-green)"},children:"Start Program"}),W&&l.jsx("div",{style:{marginTop:"12px",textAlign:"center",fontSize:"0.82rem",color:"var(--green-dark)",fontWeight:700},children:"✓ Active"}),_.crawl_status==="ready"&&l.jsx("button",{onClick:()=>L(`/catalog/${_.id}`),style:{marginTop:W?"4px":"8px",width:"100%",background:"transparent",color:"var(--accent)",padding:"8px",fontSize:"0.82rem",border:"1px solid var(--divider)"},children:"View Details"})]},_.id)})]})}const Xl={fontSize:"0.72rem",fontWeight:600,padding:"3px 10px",borderRadius:"var(--r-full)",background:"var(--divider)",color:"var(--text-2)"};function Wm(){const{id:o}=ma(),c=Ge(),[s,d]=m.useState(null),[f,v]=m.useState(null),[g,z]=m.useState(!0),[S,T]=m.useState(null),[P,y]=m.useState(null),[L,A]=m.useState(null);m.useEffect(()=>{R()},[o]);async function R(){try{const[F,_]=await Promise.all([Y.getProgramSchedule(o),Y.getProgramProgress(o)]);d(F),v(_)}catch(F){console.error(F)}finally{z(!1)}}async function N(F){T(F);try{const _=await Y.completeWorkout(o,F,{});A(_),y(null),await R()}catch(_){alert(_.message)}finally{T(null)}}if(g)return l.jsx("div",{style:{padding:"2rem",textAlign:"center",color:"var(--text-3)"},children:"Loading..."});if(!s)return l.jsx("div",{style:{padding:"2rem",textAlign:"center"},children:"Program not found"});const j=f?f.completion_pct:0;return l.jsxs("div",{style:{padding:"1rem",paddingBottom:"96px",maxWidth:"600px",margin:"0 auto"},children:[l.jsx("button",{onClick:()=>c("/programs"),style:{background:"transparent",color:"var(--text-3)",padding:"8px 0",fontSize:"0.85rem",marginBottom:"0.5rem"},children:"← Programs"}),l.jsxs("div",{style:{background:"var(--card)",borderRadius:"var(--r-lg)",padding:"20px",boxShadow:"var(--shadow-2)",marginBottom:"1.5rem"},children:[l.jsx("h1",{style:{fontFamily:"var(--font-display)",fontSize:"1.5rem",fontWeight:900},children:s.program_name}),l.jsxs("div",{style:{display:"flex",gap:"1.5rem",marginTop:"10px",fontSize:"0.82rem",color:"var(--text-2)"},children:[l.jsxs("span",{children:["Week ",s.current_week]}),l.jsxs("span",{children:[(f==null?void 0:f.completed_workouts)||0,"/",(f==null?void 0:f.total_workouts)||0," workouts"]}),l.jsxs("span",{style:{color:"var(--accent)",fontWeight:700},children:[j,"%"]})]}),l.jsx("div",{style:{marginTop:"12px",height:"8px",borderRadius:"4px",background:"var(--divider)"},children:l.jsx("div",{style:{height:"100%",borderRadius:"4px",background:j>=100?"var(--green)":"var(--accent)",width:`${Math.min(100,j)}%`,transition:"width 0.4s ease"}})}),s.progression_rules&&l.jsxs("p",{style:{marginTop:"12px",fontSize:"0.8rem",color:"var(--text-3)",lineHeight:1.5,borderTop:"1px solid var(--divider)",paddingTop:"12px"},children:["📈 ",s.progression_rules]})]}),L&&l.jsxs("div",{style:{background:"var(--green-bg)",borderRadius:"var(--r)",padding:"16px",marginBottom:"1rem",textAlign:"center"},children:[l.jsx("div",{style:{fontSize:"1.8rem",marginBottom:"4px"},children:"🎉"}),l.jsxs("strong",{style:{color:"var(--green-dark)"},children:["+",L.total_points," points!"]}),l.jsxs("div",{style:{fontSize:"0.8rem",color:"var(--text-2)",marginTop:"4px"},children:["Workout: +",L.points_earned,L.weekly_bonus>0&&` • Week bonus: +${L.weekly_bonus}`,L.completion_bonus>0&&` • Program complete: +${L.completion_bonus}!`]}),l.jsx("button",{onClick:()=>A(null),style:{marginTop:"10px",background:"transparent",color:"var(--green-dark)",fontSize:"0.8rem",padding:"6px 16px",border:"1px solid var(--green)"},children:"Dismiss"})]}),s.today_workout&&!s.today_workout.completed&&!s.today_workout.rest_day&&l.jsxs("div",{style:{background:"var(--card)",borderRadius:"var(--r)",padding:"16px",marginBottom:"1.5rem",boxShadow:"var(--shadow-2)",border:"2px solid var(--accent-glow)"},children:[l.jsx("div",{style:{fontSize:"0.72rem",fontWeight:700,color:"var(--accent)",textTransform:"uppercase",letterSpacing:"0.05em",marginBottom:"6px"},children:"Today's Workout"}),l.jsx("strong",{style:{fontFamily:"var(--font-display)",fontSize:"1.1rem"},children:s.today_workout.workout_name||`Day ${s.today_workout.day_number}`}),l.jsx("div",{style:{marginTop:"12px"},children:(s.today_workout.exercises||[]).map((F,_)=>l.jsx(Lc,{exercise:F},_))}),l.jsx("button",{onClick:()=>N(s.today_workout.id),disabled:S===s.today_workout.id,style:{marginTop:"14px",width:"100%",padding:"14px",background:S?"var(--text-3)":"var(--green)",color:"#fff",fontSize:"0.95rem",fontWeight:800,boxShadow:"var(--shadow-green)"},children:S===s.today_workout.id?"Logging...":"✓ Complete Workout — 75 pts"})]}),l.jsx("h2",{style:{fontFamily:"var(--font-display)",fontSize:"1.1rem",fontWeight:800,marginBottom:"0.75rem"},children:"Full Schedule"}),Om(s.schedule).map(([F,_])=>l.jsxs("div",{style:{marginBottom:"1.25rem"},children:[l.jsxs("div",{style:{fontSize:"0.78rem",fontWeight:700,color:"var(--text-3)",textTransform:"uppercase",letterSpacing:"0.04em",marginBottom:"0.5rem",display:"flex",alignItems:"center",gap:"0.5rem"},children:["Week ",F,F===s.current_week&&l.jsx("span",{style:{fontSize:"0.65rem",background:"var(--accent)",color:"#fff",padding:"2px 8px",borderRadius:"var(--r-full)"},children:"Current"})]}),_.map(B=>l.jsxs("div",{style:{background:"var(--card)",borderRadius:"var(--r-sm)",padding:"12px 14px",marginBottom:"0.4rem",boxShadow:"var(--shadow-1)",opacity:B.completed?.7:1,display:"flex",justifyContent:"space-between",alignItems:"center"},children:[l.jsxs("div",{children:[l.jsx("span",{style:{fontSize:"0.85rem",fontWeight:600},children:B.rest_day?"😴 Rest Day":B.workout_name||`Day ${B.day_number}`}),!B.rest_day&&l.jsxs("span",{style:{fontSize:"0.75rem",color:"var(--text-3)",marginLeft:"8px"},children:[(B.exercises||[]).length," exercises"]})]}),l.jsx("div",{children:B.completed?l.jsx("span",{style:{color:"var(--green)",fontSize:"0.85rem",fontWeight:700},children:"✓"}):B.rest_day?null:l.jsx("button",{onClick:()=>y(B),style:{background:"var(--green-bg)",color:"var(--green-dark)",padding:"6px 12px",fontSize:"0.75rem",fontWeight:700},children:"Do it"})})]},B.id))]},F)),P&&l.jsx("div",{style:{position:"fixed",inset:0,background:"rgba(0,0,0,0.5)",display:"flex",alignItems:"flex-end",justifyContent:"center",zIndex:100},onClick:()=>y(null),children:l.jsxs("div",{style:{background:"var(--card)",borderRadius:"var(--r-lg) var(--r-lg) 0 0",padding:"24px 20px",width:"100%",maxWidth:"600px",maxHeight:"80vh",overflow:"auto"},onClick:F=>F.stopPropagation(),children:[l.jsx("h3",{style:{fontFamily:"var(--font-display)",fontSize:"1.2rem",fontWeight:800,marginBottom:"4px"},children:P.workout_name||`Week ${P.week_number}, Day ${P.day_number}`}),l.jsx("p",{style:{color:"var(--text-3)",fontSize:"0.82rem",marginBottom:"16px"},children:'Complete the exercises below and tap "Done" when finished.'}),(P.exercises||[]).map((F,_)=>l.jsx(Lc,{exercise:F,detailed:!0},_)),P.notes&&l.jsxs("p",{style:{marginTop:"12px",fontSize:"0.8rem",color:"var(--text-2)",background:"var(--accent-bg)",padding:"10px 12px",borderRadius:"var(--r-sm)"},children:["💡 ",P.notes]}),l.jsxs("div",{style:{display:"flex",gap:"0.5rem",marginTop:"20px"},children:[l.jsx("button",{onClick:()=>y(null),style:{flex:1,background:"var(--divider)",color:"var(--text-2)",padding:"14px"},children:"Cancel"}),l.jsx("button",{onClick:()=>N(P.id),disabled:S===P.id,style:{flex:2,background:"var(--green)",color:"#fff",padding:"14px",fontWeight:800,boxShadow:"var(--shadow-green)"},children:S===P.id?"Logging...":"✓ Done — 75 pts"})]})]})})]})}function Lc({exercise:o,detailed:c}){return l.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",padding:c?"10px 0":"6px 0",borderBottom:"1px solid var(--divider)"},children:[l.jsxs("div",{children:[l.jsx("span",{style:{fontWeight:600,fontSize:c?"0.9rem":"0.82rem"},children:o.name}),c&&o.notes&&l.jsx("div",{style:{fontSize:"0.75rem",color:"var(--text-3)",marginTop:"2px"},children:o.notes}),c&&o.weight_instruction&&l.jsxs("div",{style:{fontSize:"0.75rem",color:"var(--accent)",marginTop:"2px"},children:["🏋️ ",o.weight_instruction]})]}),l.jsxs("div",{style:{textAlign:"right",fontSize:"0.8rem",color:"var(--text-2)",fontWeight:600,whiteSpace:"nowrap"},children:[o.sets&&o.reps&&`${o.sets}×${o.reps}`,o.rest_seconds&&c&&l.jsx("div",{style:{fontSize:"0.7rem",color:"var(--text-3)"},children:o.rest_seconds>=60?`${Math.floor(o.rest_seconds/60)}m rest`:`${o.rest_seconds}s rest`})]})]})}function Om(o){const c={};for(const s of o||[]){const d=s.week_number;c[d]||(c[d]=[]),c[d].push(s)}return Object.entries(c).sort(([s],[d])=>Number(s)-Number(d))}const Ic={beginner:"var(--green)",intermediate:"var(--accent)",advanced:"var(--accent-light)"},Mm={strength:"🏋️",cardio:"🏃",flexibility:"🧘",hybrid:"⚡"},Fc={pending:{label:"Queued",color:"var(--amber)"},crawling:{label:"Fetching...",color:"var(--accent)"},extracting:{label:"Analyzing...",color:"var(--accent-light)"},ready:{label:"Ready",color:"var(--green)"},failed:{label:"Failed",color:"var(--red)"}};function Dm(){const{id:o}=ma(),c=Ge(),[s,d]=m.useState(null),[f,v]=m.useState([]),[g,z]=m.useState(!0),[S,T]=m.useState(!1),[P,y]=m.useState(null);m.useEffect(()=>{L()},[o]);async function L(){z(!0);try{const[_,B]=await Promise.all([Y.getCatalogProgram(o),Y.listPrograms()]);d(_),v(B)}catch(_){y({type:"error",text:_.message||"Could not load program"})}finally{z(!1)}}async function A(){T(!0),y(null);const _=new Date().toISOString().slice(0,10);try{const B=await Y.adoptProgram(o,_);y({type:"success",text:"Program started! Redirecting…"}),setTimeout(()=>c(`/programs/${B.id}`),800)}catch(B){y({type:"error",text:B.message}),T(!1)}}if(g)return l.jsx("div",{style:{padding:"2rem",textAlign:"center",color:"var(--text-3)"},children:"Loading..."});if(!s)return l.jsxs("div",{style:{padding:"2rem",textAlign:"center"},children:[l.jsx("p",{style:{color:"var(--text-2)",marginBottom:"1rem"},children:"Program not found."}),l.jsx("button",{onClick:()=>c("/programs"),style:{background:"var(--accent)",color:"#fff",padding:"10px 20px"},children:"← Back to Programs"})]});const R=f.some(_=>_.status==="active"&&_.catalog_id===s.id),N=f.find(_=>_.status==="active"&&_.catalog_id===s.id),j=Fc[s.crawl_status]||Fc.pending,F=Am(s.workouts);return l.jsxs("div",{style:{padding:"1rem",paddingBottom:"96px",maxWidth:"600px",margin:"0 auto"},children:[l.jsx("button",{onClick:()=>c("/programs"),style:{background:"transparent",color:"var(--text-3)",padding:"8px 0",fontSize:"0.85rem",marginBottom:"0.5rem"},children:"← Programs"}),l.jsxs("div",{style:{background:"var(--card)",borderRadius:"var(--r-lg)",padding:"20px",boxShadow:"var(--shadow-2)",marginBottom:"1rem"},children:[l.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"flex-start",gap:"1rem"},children:[l.jsxs("h1",{style:{fontFamily:"var(--font-display)",fontSize:"1.5rem",fontWeight:900,lineHeight:1.2},children:[Mm[s.category]||"📋"," ",s.name]}),l.jsx("span",{style:{fontSize:"0.7rem",fontWeight:700,padding:"4px 10px",borderRadius:"var(--r-full)",color:"#fff",background:j.color,whiteSpace:"nowrap"},children:j.label})]}),s.description&&l.jsx("p",{style:{color:"var(--text-2)",fontSize:"0.88rem",marginTop:"12px",lineHeight:1.5},children:s.description}),l.jsxs("div",{style:{display:"flex",gap:"0.5rem",flexWrap:"wrap",marginTop:"14px"},children:[s.duration_weeks&&l.jsxs("span",{style:ql,children:[s.duration_weeks," weeks"]}),s.frequency_per_week&&l.jsxs("span",{style:ql,children:[s.frequency_per_week,"x/week"]}),s.difficulty&&l.jsx("span",{style:{...ql,color:Ic[s.difficulty]||"var(--text-3)",background:`${Ic[s.difficulty]||"var(--text-3)"}15`},children:s.difficulty}),(s.equipment||[]).map(_=>l.jsx("span",{style:ql,children:_},_))]}),s.source_url&&l.jsx("a",{href:s.source_url,target:"_blank",rel:"noopener noreferrer",style:{display:"inline-block",marginTop:"14px",fontSize:"0.78rem",color:"var(--accent)",textDecoration:"none"},children:"🔗 Original source ↗"})]}),P&&l.jsx("div",{style:{padding:"12px 16px",borderRadius:"var(--r-sm)",marginBottom:"1rem",fontSize:"0.85rem",fontWeight:600,background:P.type==="error"?"var(--red-ghost)":"var(--green-bg)",color:P.type==="error"?"var(--red)":"var(--green-dark)"},children:P.text}),s.crawl_status==="ready"&&!R&&l.jsx("button",{onClick:A,disabled:S,style:{width:"100%",background:S?"var(--text-3)":"var(--green)",color:"#fff",padding:"14px",fontSize:"0.95rem",fontWeight:800,marginBottom:"1.25rem",boxShadow:S?"none":"var(--shadow-green)"},children:S?"Starting...":"Start This Program"}),R&&N&&l.jsx("button",{onClick:()=>c(`/programs/${N.id}`),style:{width:"100%",background:"var(--accent)",color:"#fff",padding:"14px",fontSize:"0.95rem",fontWeight:800,marginBottom:"1.25rem",boxShadow:"var(--shadow-accent)"},children:"✓ Active — Open Your Program"}),s.crawl_status!=="ready"&&l.jsx("div",{style:{padding:"14px",borderRadius:"var(--r)",background:"var(--accent-bg)",color:"var(--accent)",fontSize:"0.85rem",marginBottom:"1.25rem",textAlign:"center"},children:s.crawl_status==="failed"?`Crawl failed: ${s.crawl_error||"unknown error"}`:"Program is being researched. Check back soon."}),s.progression_rules&&l.jsxs("div",{style:{background:"var(--card)",borderRadius:"var(--r)",padding:"16px",marginBottom:"1.25rem",boxShadow:"var(--shadow-1)"},children:[l.jsx("div",{style:{fontSize:"0.72rem",fontWeight:700,color:"var(--accent)",textTransform:"uppercase",letterSpacing:"0.05em",marginBottom:"8px"},children:"📈 Progression"}),l.jsx("p",{style:{fontSize:"0.85rem",color:"var(--text-2)",lineHeight:1.5},children:s.progression_rules})]}),F.length>0&&l.jsxs(l.Fragment,{children:[l.jsx("h2",{style:{fontFamily:"var(--font-display)",fontSize:"1.1rem",fontWeight:800,marginBottom:"0.75rem"},children:"Workouts"}),F.map(([_,B])=>l.jsxs("div",{style:{marginBottom:"1.25rem"},children:[l.jsxs("div",{style:{fontSize:"0.78rem",fontWeight:700,color:"var(--text-3)",textTransform:"uppercase",letterSpacing:"0.04em",marginBottom:"0.5rem"},children:["Week ",_]}),B.map(W=>l.jsxs("div",{style:{background:"var(--card)",borderRadius:"var(--r-sm)",padding:"14px",marginBottom:"0.5rem",boxShadow:"var(--shadow-1)"},children:[l.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:W.rest_day?0:"8px"},children:[l.jsx("strong",{style:{fontSize:"0.92rem",fontFamily:"var(--font-display)"},children:W.rest_day?"😴 Rest Day":W.workout_name||`Day ${W.day_number}`}),!W.rest_day&&l.jsxs("span",{style:{fontSize:"0.72rem",color:"var(--text-3)",fontWeight:600},children:[(W.exercises||[]).length," exercises"]})]}),!W.rest_day&&(W.exercises||[]).map((Z,fe)=>l.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",padding:"6px 0",borderTop:fe===0?"1px solid var(--divider)":"none",borderBottom:fed.day_number-f.day_number);return Object.entries(c).sort(([s],[d])=>Number(s)-Number(d))}function $m(){const[o,c]=m.useState([]),[s,d]=m.useState(!0),[f,v]=m.useState(!1),[g,z]=m.useState(""),[S,T]=m.useState(null),P=m.useRef(null),y=Ge();m.useEffect(()=>{L()},[]),m.useEffect(()=>{var N;(N=P.current)==null||N.scrollIntoView({behavior:"smooth"})},[o]);async function L(){try{const N=await Y.getThread();c(N.messages||[])}catch(N){console.error(N)}finally{d(!1)}}async function A(N){N.preventDefault();const j=g.trim();if(!(!j||f)){v(!0),T(null);try{const F=await Y.sendSupportMessage(j);z(""),T(F.message),await L()}catch(F){T(F.message)}finally{v(!1)}}}function R(N){const j=new Date(N),_=new Date-j;return _<6e4?"Just now":_<36e5?`${Math.floor(_/6e4)}m ago`:_<864e5?`${Math.floor(_/36e5)}h ago`:j.toLocaleDateString(void 0,{month:"short",day:"numeric"})+" "+j.toLocaleTimeString(void 0,{hour:"2-digit",minute:"2-digit"})}return s?l.jsx("div",{style:{padding:"2rem",textAlign:"center",color:"var(--text-3)"},children:"Loading..."}):l.jsxs("div",{style:{display:"flex",flexDirection:"column",height:"calc(100dvh - 64px)",maxWidth:"600px",margin:"0 auto"},children:[l.jsxs("div",{style:{padding:"16px",borderBottom:"1px solid var(--divider)",display:"flex",alignItems:"center",gap:"12px"},children:[l.jsx("button",{onClick:()=>y("/"),style:{background:"transparent",color:"var(--text-3)",padding:"4px 0",fontSize:"1.2rem"},children:"←"}),l.jsxs("div",{children:[l.jsx("h1",{style:{fontFamily:"var(--font-display)",fontSize:"1.2rem",fontWeight:800,margin:0},children:"Support"}),l.jsx("p",{style:{color:"var(--text-3)",fontSize:"0.75rem",margin:0},children:"Ask a question — we'll get back to you"})]})]}),l.jsxs("div",{style:{flex:1,overflow:"auto",padding:"16px",display:"flex",flexDirection:"column",gap:"12px"},children:[o.length===0&&l.jsxs("div",{style:{textAlign:"center",color:"var(--text-3)",fontSize:"0.85rem",marginTop:"2rem"},children:[l.jsx("div",{style:{fontSize:"2rem",marginBottom:"8px"},children:"💬"}),"No messages yet. Ask anything about your workouts, program, or the app."]}),o.map(N=>l.jsx("div",{style:{display:"flex",justifyContent:N.sender==="user"?"flex-end":"flex-start"},children:l.jsxs("div",{style:{maxWidth:"80%",padding:"10px 14px",borderRadius:N.sender==="user"?"var(--r) var(--r) 4px var(--r)":"var(--r) var(--r) var(--r) 4px",background:N.sender==="user"?"var(--accent)":"var(--card)",color:N.sender==="user"?"#fff":"var(--text)",boxShadow:"var(--shadow-1)",fontSize:"0.88rem",lineHeight:1.5},children:[l.jsx("div",{children:N.body}),l.jsxs("div",{style:{fontSize:"0.68rem",marginTop:"4px",opacity:.7,textAlign:"right"},children:[R(N.created_at),N.sender==="user"&&N.read_at&&" ✓"]})]})},N.id)),S&&l.jsx("div",{style:{textAlign:"center",fontSize:"0.8rem",color:"var(--green-dark)",padding:"8px",background:"var(--green-bg)",borderRadius:"var(--r-sm)"},children:S}),l.jsx("div",{ref:P})]}),l.jsxs("form",{onSubmit:A,style:{padding:"12px 16px",borderTop:"1px solid var(--divider)",display:"flex",gap:"8px",background:"var(--card)"},children:[l.jsx("input",{type:"text",value:g,onChange:N=>z(N.target.value),placeholder:"Type your question...",maxLength:2e3,style:{flex:1,padding:"12px 14px",borderRadius:"var(--r-full)",border:"1px solid var(--divider)",fontFamily:"var(--font)",fontSize:"0.88rem",background:"var(--bg)"}}),l.jsx("button",{type:"submit",disabled:!g.trim()||f,style:{background:f?"var(--text-3)":"var(--accent)",color:"#fff",borderRadius:"var(--r-full)",width:"44px",height:"44px",padding:0,display:"flex",alignItems:"center",justifyContent:"center",fontSize:"1.1rem",boxShadow:"var(--shadow-accent)",opacity:g.trim()?1:.5},children:"↑"})]})]})}function Bc(){const{threadId:o}=ma();return o?l.jsx(Vm,{threadId:o}):l.jsx(Um,{})}function Um(){const[o,c]=m.useState([]),[s,d]=m.useState(!0),[f,v]=m.useState(null),g=Ge();m.useEffect(()=>{z()},[]);async function z(){try{const S=await Y.listSupportThreads();c(S)}catch(S){v(S.message)}finally{d(!1)}}return s?l.jsx("div",{style:{padding:"2rem",textAlign:"center",color:"var(--text-3)"},children:"Loading..."}):f?l.jsx("div",{style:{padding:"2rem",textAlign:"center",color:"var(--red)"},children:f}):l.jsxs("div",{style:{padding:"1rem",paddingBottom:"96px",maxWidth:"600px",margin:"0 auto"},children:[l.jsxs("div",{style:{display:"flex",alignItems:"center",gap:"12px",marginBottom:"1.5rem"},children:[l.jsx("button",{onClick:()=>g("/"),style:{background:"transparent",color:"var(--text-3)",padding:"4px 0",fontSize:"1.2rem"},children:"←"}),l.jsx("h1",{style:{fontFamily:"var(--font-display)",fontSize:"1.4rem",fontWeight:900,margin:0},children:"Support Inbox"})]}),o.length===0&&l.jsx("p",{style:{textAlign:"center",color:"var(--text-3)",fontSize:"0.85rem",marginTop:"3rem"},children:"No support conversations yet."}),o.map(S=>l.jsxs("div",{onClick:()=>g(`/support/admin/${S.id}`),style:{background:"var(--card)",borderRadius:"var(--r)",padding:"14px 16px",marginBottom:"0.6rem",boxShadow:"var(--shadow-1)",cursor:"pointer",border:S.unread_admin>0?"2px solid var(--accent-glow)":"1px solid var(--card-border)"},children:[l.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center"},children:[l.jsx("strong",{style:{fontFamily:"var(--font-display)",fontSize:"0.95rem"},children:S.user_name}),l.jsxs("div",{style:{display:"flex",alignItems:"center",gap:"8px"},children:[S.unread_admin>0&&l.jsx("span",{style:{background:"var(--accent)",color:"#fff",fontSize:"0.65rem",fontWeight:800,width:"20px",height:"20px",borderRadius:"50%",display:"flex",alignItems:"center",justifyContent:"center"},children:S.unread_admin}),l.jsxs("span",{style:{fontSize:"0.72rem",color:"var(--text-3)"},children:[S.message_count," msgs"]})]})]}),S.last_message&&l.jsxs("p",{style:{color:"var(--text-2)",fontSize:"0.82rem",marginTop:"4px",whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis"},children:[l.jsx("span",{style:{color:"var(--text-3)",fontWeight:600},children:S.last_message.sender==="admin"?"You: ":""}),S.last_message.body]})]},S.id))]})}function Vm({threadId:o}){const[c,s]=m.useState(null),[d,f]=m.useState(!0),[v,g]=m.useState(!1),[z,S]=m.useState(""),T=m.useRef(null),P=Ge();m.useEffect(()=>{y()},[o]),m.useEffect(()=>{var N;(N=T.current)==null||N.scrollIntoView({behavior:"smooth"})},[c]);async function y(){try{const N=await Y.getAdminThread(o);s(N)}catch(N){console.error(N)}finally{f(!1)}}async function L(N){N.preventDefault();const j=z.trim();if(!(!j||v)){g(!0);try{await Y.replySupportThread(o,j),S(""),await y()}catch(F){alert(F.message)}finally{g(!1)}}}function A(N){const j=new Date(N);return j.toLocaleDateString(void 0,{month:"short",day:"numeric"})+" "+j.toLocaleTimeString(void 0,{hour:"2-digit",minute:"2-digit"})}if(d)return l.jsx("div",{style:{padding:"2rem",textAlign:"center",color:"var(--text-3)"},children:"Loading..."});if(!c)return l.jsx("div",{style:{padding:"2rem",textAlign:"center"},children:"Thread not found"});const R=c.latest_context||{};return l.jsxs("div",{style:{display:"flex",flexDirection:"column",height:"calc(100dvh - 64px)",maxWidth:"600px",margin:"0 auto"},children:[l.jsxs("div",{style:{padding:"14px 16px",borderBottom:"1px solid var(--divider)"},children:[l.jsxs("div",{style:{display:"flex",alignItems:"center",gap:"12px"},children:[l.jsx("button",{onClick:()=>P("/support/admin"),style:{background:"transparent",color:"var(--text-3)",padding:"4px 0",fontSize:"1.2rem"},children:"←"}),l.jsx("h2",{style:{fontFamily:"var(--font-display)",fontSize:"1.1rem",fontWeight:800,margin:0},children:c.user_name})]}),R.program_name&&l.jsxs("div",{style:{display:"flex",gap:"0.5rem",flexWrap:"wrap",marginTop:"8px",marginLeft:"36px"},children:[l.jsx("span",{style:Fr,children:R.program_name}),R.program_day&&l.jsxs("span",{style:Fr,children:["Day ",R.program_day,"/",R.program_total_days]}),R.program_completion_pct!==void 0&&l.jsxs("span",{style:Fr,children:[R.program_completion_pct,"%"]}),l.jsxs("span",{style:{...Fr,background:R.gate_passed?"var(--green-bg)":"var(--red-ghost)",color:R.gate_passed?"var(--green-dark)":"var(--red)"},children:[R.points_today,"/",R.daily_target," pts ",R.gate_passed?"✓":"✗"]}),R.last_workout&&l.jsxs("span",{style:Fr,children:["Last: ",R.last_workout]})]})]}),l.jsxs("div",{style:{flex:1,overflow:"auto",padding:"16px",display:"flex",flexDirection:"column",gap:"12px"},children:[(c.messages||[]).map(N=>l.jsx("div",{style:{display:"flex",justifyContent:N.sender==="admin"?"flex-end":"flex-start"},children:l.jsxs("div",{style:{maxWidth:"80%",padding:"10px 14px",borderRadius:N.sender==="admin"?"var(--r) var(--r) 4px var(--r)":"var(--r) var(--r) var(--r) 4px",background:N.sender==="admin"?"var(--accent)":"var(--card)",color:N.sender==="admin"?"#fff":"var(--text)",boxShadow:"var(--shadow-1)",fontSize:"0.88rem",lineHeight:1.5},children:[l.jsx("div",{children:N.body}),l.jsxs("div",{style:{fontSize:"0.68rem",marginTop:"4px",opacity:.7,textAlign:"right"},children:[A(N.created_at),N.read_at&&" ✓"]})]})},N.id)),l.jsx("div",{ref:T})]}),l.jsxs("form",{onSubmit:L,style:{padding:"12px 16px",borderTop:"1px solid var(--divider)",display:"flex",gap:"8px",background:"var(--card)"},children:[l.jsx("input",{type:"text",value:z,onChange:N=>S(N.target.value),placeholder:"Reply...",style:{flex:1,padding:"12px 14px",borderRadius:"var(--r-full)",border:"1px solid var(--divider)",fontFamily:"var(--font)",fontSize:"0.88rem",background:"var(--bg)"}}),l.jsx("button",{type:"submit",disabled:!z.trim()||v,style:{background:v?"var(--text-3)":"var(--accent)",color:"#fff",borderRadius:"var(--r-full)",width:"44px",height:"44px",padding:0,display:"flex",alignItems:"center",justifyContent:"center",fontSize:"1.1rem",opacity:z.trim()?1:.5},children:"↑"})]})]})}const Fr={fontSize:"0.68rem",fontWeight:600,padding:"2px 8px",borderRadius:"var(--r-full)",background:"var(--divider)",color:"var(--text-2)"};function Hm(){const[o,c]=m.useState([]),[s,d]=m.useState(""),[f,v]=m.useState(!1),[g,z]=m.useState(null),S=m.useRef(null);m.useEffect(()=>{Y.getAIStatus().then(z).catch(()=>z({configured:!1}))},[]),m.useEffect(()=>{var y;(y=S.current)==null||y.scrollIntoView({behavior:"smooth"})},[o]);const T=async()=>{const y=s.trim();if(!y||f)return;const L={role:"user",content:y};c(A=>[...A,L]),d(""),v(!0),c(A=>[...A,{role:"assistant",content:""}]);try{const A=o.map(B=>({role:B.role,content:B.content})),R=localStorage.getItem("fitness_token"),j=(await fetch("/api/ai/chat",{method:"POST",headers:{"Content-Type":"application/json",...R?{Authorization:`Bearer ${R}`}:{}},body:JSON.stringify({message:y,history:A})})).body.getReader(),F=new TextDecoder;let _="";for(;;){const{done:B,value:W}=await j.read();if(B)break;_+=F.decode(W,{stream:!0});const Z=_.split(` +`);_=Z.pop()||"";for(const fe of Z)if(fe.startsWith("data: ")&&fe.trim()!=="data: [DONE]")try{const{text:we}=JSON.parse(fe.slice(6));we&&c(xe=>{const re=[...xe],oe=re[re.length-1];return re[re.length-1]={...oe,content:oe.content+we},re})}catch{}}}catch{c(R=>{const N=[...R];return N[N.length-1]={role:"assistant",content:"Connection error. Check that the backend is running and an AI provider is configured in Settings → Integrations."},N})}v(!1)},P=["What should I eat today?","Log my 30-minute run","How am I doing this week?","Create a meal plan for me"];return g&&!g.configured?l.jsxs("div",{className:"page",children:[l.jsx("h1",{className:"page-title",children:"AI Coach"}),l.jsxs("div",{className:"card",style:{textAlign:"center",padding:"32px 20px"},children:[l.jsx("div",{style:{fontSize:"2rem",marginBottom:"12px"},children:"🤖"}),l.jsx("h3",{style:{marginBottom:"8px"},children:"No AI provider connected"}),l.jsx("p",{style:{color:"var(--text-2)",fontSize:"0.9rem",marginBottom:"16px"},children:"Connect OpenAI, OpenRouter, Claude, Ollama, or any other provider to start chatting with your AI fitness coach."}),l.jsx("a",{href:"/settings/integrations",className:"btn-primary",style:{display:"inline-block",padding:"12px 24px",borderRadius:"var(--r)",background:"var(--accent)",color:"var(--text-inv)",textDecoration:"none",fontWeight:600,fontSize:"0.88rem"},children:"Configure AI Provider"})]})]}):l.jsxs("div",{className:"page",style:{display:"flex",flexDirection:"column",paddingBottom:"96px"},children:[l.jsxs("div",{style:{display:"flex",alignItems:"center",justifyContent:"space-between",marginBottom:"12px"},children:[l.jsx("h1",{className:"page-title",style:{marginBottom:0},children:"AI Coach"}),(g==null?void 0:g.provider)&&l.jsxs("span",{style:{fontFamily:"var(--font-mono)",fontSize:"0.7rem",color:"var(--text-3)",background:"var(--accent-bg)",padding:"4px 10px",borderRadius:"var(--r-full)"},children:[g.provider," · ",g.model]})]}),l.jsxs("div",{style:{flex:1,overflowY:"auto",marginBottom:"12px"},children:[o.length===0&&l.jsxs("div",{style:{textAlign:"center",padding:"24px 0"},children:[l.jsx("p",{style:{color:"var(--text-3)",fontSize:"0.9rem",marginBottom:"16px"},children:"Ask me anything about your fitness, nutrition, or program."}),l.jsx("div",{style:{display:"flex",flexWrap:"wrap",gap:"8px",justifyContent:"center"},children:P.map(y=>l.jsx("button",{className:"btn-outline btn-sm",onClick:()=>{d(y)},style:{fontSize:"0.8rem"},children:y},y))})]}),o.map((y,L)=>l.jsx("div",{style:{display:"flex",justifyContent:y.role==="user"?"flex-end":"flex-start",marginBottom:"10px"},children:l.jsx("div",{style:{maxWidth:"85%",padding:"10px 14px",borderRadius:y.role==="user"?"var(--r-lg) var(--r-lg) var(--r-sm) var(--r-lg)":"var(--r-lg) var(--r-lg) var(--r-lg) var(--r-sm)",background:y.role==="user"?"var(--accent)":"var(--card)",color:y.role==="user"?"var(--text-inv)":"var(--text)",border:y.role==="user"?"none":"1px solid var(--card-border)",fontSize:"0.9rem",lineHeight:"1.5",whiteSpace:"pre-wrap"},children:y.content||(f&&L===o.length-1?"...":"")})},L)),l.jsx("div",{ref:S})]}),l.jsxs("div",{style:{display:"flex",gap:"8px",position:"sticky",bottom:"72px",background:"var(--bg)",padding:"8px 0"},children:[l.jsx("input",{value:s,onChange:y=>d(y.target.value),onKeyDown:y=>y.key==="Enter"&&!y.shiftKey&&T(),placeholder:f?"Thinking...":"Ask your AI coach...",disabled:f,style:{flex:1}}),l.jsx("button",{onClick:T,disabled:f||!s.trim(),className:"btn-primary",style:{padding:"12px 18px",minWidth:"auto"},children:f?"···":"→"})]})]})}function ua({text:o,label:c}){const[s,d]=m.useState(!1),f=async()=>{try{await navigator.clipboard.writeText(o),d(!0),setTimeout(()=>d(!1),2e3)}catch{}};return l.jsx("button",{onClick:f,className:"btn-outline btn-sm",style:{fontSize:"0.75rem",padding:"6px 12px",whiteSpace:"nowrap"},children:s?"Copied":c||"Copy"})}function Wc({children:o,copyText:c}){return l.jsxs("div",{style:{background:"var(--bg)",border:"1px solid var(--divider)",borderRadius:"var(--r-sm)",padding:"14px 16px",fontFamily:"var(--font-mono)",fontSize:"0.78rem",lineHeight:1.6,overflowX:"auto",position:"relative",whiteSpace:"pre"},children:[c&&l.jsx("div",{style:{position:"absolute",top:"8px",right:"8px"},children:l.jsx(ua,{text:c})}),o]})}function Qm(){const[o,c]=m.useState(null),[s,d]=m.useState(!0),[f,v]=m.useState(!1);if(m.useEffect(()=>{Y.agentConfig().then(c).catch(console.error).finally(()=>d(!1))},[]),s)return l.jsx("div",{className:"page",children:l.jsx("div",{className:"loading",children:"Loading..."})});if(!o)return l.jsx("div",{className:"page",children:l.jsx("div",{className:"error-msg",children:"Failed to load agent config"})});const g=o.api_token?o.api_token.slice(0,8)+"…"+o.api_token.slice(-4):null,z=JSON.stringify({mcpServers:{diligence:{url:o.mcp_url,...o.api_token?{headers:{Authorization:`Bearer ${o.api_token}`}}:{}}}},null,2);return l.jsxs("div",{className:"page",children:[l.jsxs("div",{style:{marginBottom:"20px"},children:[l.jsx("h1",{style:{fontFamily:"var(--font-display)",fontWeight:800,fontSize:"1.3rem",marginBottom:"4px"},children:"Connect Your AI Agent"}),l.jsx("p",{style:{color:"var(--text-3)",fontSize:"0.85rem"},children:"Use any MCP-compatible AI to log workouts, track food, and manage your fitness."})]}),l.jsxs("div",{className:"card",children:[l.jsx("div",{className:"section-label",children:"Connection"}),l.jsxs("div",{style:{marginBottom:"14px"},children:[l.jsx("div",{style:{fontSize:"0.75rem",color:"var(--text-3)",fontWeight:600,marginBottom:"4px"},children:"MCP Endpoint"}),l.jsxs("div",{style:{display:"flex",alignItems:"center",gap:"8px",background:"var(--bg)",borderRadius:"var(--r-sm)",padding:"10px 12px",border:"1px solid var(--divider)"},children:[l.jsx("code",{style:{flex:1,fontFamily:"var(--font-mono)",fontSize:"0.82rem",color:"var(--accent)",wordBreak:"break-all"},children:o.mcp_url}),l.jsx(ua,{text:o.mcp_url})]})]}),o.api_token&&l.jsxs("div",{style:{marginBottom:"14px"},children:[l.jsx("div",{style:{fontSize:"0.75rem",color:"var(--text-3)",fontWeight:600,marginBottom:"4px"},children:"API Token"}),l.jsxs("div",{style:{display:"flex",alignItems:"center",gap:"8px",background:"var(--bg)",borderRadius:"var(--r-sm)",padding:"10px 12px",border:"1px solid var(--divider)"},children:[l.jsx("code",{onClick:()=>v(!f),style:{flex:1,fontFamily:"var(--font-mono)",fontSize:"0.82rem",cursor:"pointer",wordBreak:"break-all"},children:f?o.api_token:g}),l.jsx(ua,{text:o.api_token})]}),l.jsx("div",{style:{fontSize:"0.7rem",color:"var(--text-3)",marginTop:"4px"},children:"Tap token to reveal. Only visible to admins."})]}),!o.api_token&&o.api_token_set&&l.jsx("div",{style:{fontSize:"0.8rem",color:"var(--text-3)",fontStyle:"italic",padding:"8px 0"},children:"API token is set but only visible to admin users."}),l.jsxs("div",{style:{display:"flex",gap:"12px",flexWrap:"wrap",fontSize:"0.78rem",color:"var(--text-3)"},children:[l.jsxs("span",{children:[o.tools_count," tools available"]}),l.jsx("span",{style:{color:"var(--divider)"},children:"|"}),l.jsx("span",{children:o.deployment==="local"?"Local (SQLite)":"Docker (PostgreSQL)"})]})]}),l.jsxs("div",{className:"card",children:[l.jsx("div",{className:"section-label",children:"Claude Desktop"}),l.jsx("p",{style:{fontSize:"0.82rem",color:"var(--text-2)",marginBottom:"12px"},children:"Add this to your Claude Desktop MCP config:"}),l.jsx(Wc,{copyText:z,children:z}),l.jsxs("p",{style:{fontSize:"0.75rem",color:"var(--text-3)",marginTop:"10px"},children:["On macOS: ",l.jsx("code",{style:{fontFamily:"var(--font-mono)",fontSize:"0.72rem"},children:"~/Library/Application Support/Claude/claude_desktop_config.json"})]})]}),l.jsxs("div",{className:"card",children:[l.jsx("div",{className:"section-label",children:"Claude Code"}),l.jsx("p",{style:{fontSize:"0.82rem",color:"var(--text-2)",marginBottom:"12px"},children:"Add the MCP server from your terminal:"}),l.jsx(Wc,{copyText:`claude mcp add diligence --transport sse ${o.mcp_url}`,children:`claude mcp add diligence --transport sse ${o.mcp_url}`})]}),l.jsxs("div",{className:"card",children:[l.jsx("div",{className:"section-label",children:"What Your Agent Can Do"}),l.jsx("div",{style:{display:"grid",gap:"8px"},children:[["Log workouts","Track any activity and earn points automatically"],["Track food","Search 400K+ foods, log meals with full macros"],["Manage meal plans","Create plans, track compliance, adjust portions"],["Check progress","Daily points, weekly summary, program status"],["Redeem rewards","Spend earned points on rewards you set up"]].map(([S,T])=>l.jsxs("div",{style:{display:"flex",gap:"10px",alignItems:"flex-start",padding:"8px 0",borderBottom:"1px solid var(--divider)"},children:[l.jsx("span",{style:{color:"var(--green)",fontSize:"0.9rem",marginTop:"1px"},children:"✓"}),l.jsxs("div",{children:[l.jsx("div",{style:{fontWeight:700,fontSize:"0.85rem"},children:S}),l.jsx("div",{style:{fontSize:"0.78rem",color:"var(--text-3)"},children:T})]})]},S))})]})]})}function Ue({children:o}){return ti()?o:l.jsx(Jp,{to:"/login"})}function Km(){const[o,c]=m.useState(0),s=Ge(),d=xn(),f=["/login","/onboarding","/support"].some(v=>d.pathname.startsWith(v));return m.useEffect(()=>{if(f||!ti())return;Y.getUnreadCount().then(g=>c(g.unread||0)).catch(()=>{});const v=setInterval(()=>{Y.getUnreadCount().then(g=>c(g.unread||0)).catch(()=>{})},6e4);return()=>clearInterval(v)},[d.pathname,f]),f?null:l.jsxs("button",{onClick:()=>s("/support"),style:{position:"fixed",top:"12px",right:"12px",zIndex:90,width:"40px",height:"40px",borderRadius:"50%",background:"var(--card)",boxShadow:"var(--shadow-2)",border:"1px solid var(--divider)",display:"flex",alignItems:"center",justifyContent:"center",fontSize:"1.1rem",padding:0,cursor:"pointer"},children:["?",o>0&&l.jsx("span",{style:{position:"absolute",top:"-4px",right:"-4px",background:"var(--red)",color:"#fff",fontSize:"0.6rem",fontWeight:800,width:"18px",height:"18px",borderRadius:"50%",display:"flex",alignItems:"center",justifyContent:"center",border:"2px solid var(--bg)"},children:o})]})}function Ym(){return l.jsxs("nav",{className:"nav-bar",children:[l.jsxs(Hn,{to:"/",className:({isActive:o})=>`nav-item ${o?"active":""}`,children:[l.jsx("span",{className:"nav-icon",children:"🏠"})," Home"]}),l.jsxs(Hn,{to:"/log",className:({isActive:o})=>`nav-item ${o?"active":""}`,children:[l.jsx("span",{className:"nav-icon",children:"💪"})," Log"]}),l.jsxs(Hn,{to:"/nutrition",className:({isActive:o})=>`nav-item ${o?"active":""}`,children:[l.jsx("span",{className:"nav-icon",children:"🥑"})," Keto"]}),l.jsxs(Hn,{to:"/programs",className:({isActive:o})=>`nav-item ${o?"active":""}`,children:[l.jsx("span",{className:"nav-icon",children:"📋"})," Programs"]}),l.jsxs(Hn,{to:"/chat",className:({isActive:o})=>`nav-item ${o?"active":""}`,children:[l.jsx("span",{className:"nav-icon",children:"🧠"})," Coach"]}),l.jsxs(Hn,{to:"/agent",className:({isActive:o})=>`nav-item ${o?"active":""}`,children:[l.jsx("span",{className:"nav-icon",children:"🔌"})," Agent"]})]})}function Jm(){return l.jsxs(l.Fragment,{children:[ti()&&l.jsx(Km,{}),l.jsxs(Xp,{children:[l.jsx(Ie,{path:"/login",element:l.jsx(hm,{})}),l.jsx(Ie,{path:"/onboarding",element:l.jsx(Ue,{children:l.jsx(zm,{})})}),l.jsx(Ie,{path:"/",element:l.jsx(Ue,{children:l.jsx(gm,{})})}),l.jsx(Ie,{path:"/log",element:l.jsx(Ue,{children:l.jsx(vm,{})})}),l.jsx(Ie,{path:"/food",element:l.jsx(Ue,{children:l.jsx(ym,{})})}),l.jsx(Ie,{path:"/nutrition",element:l.jsx(Ue,{children:l.jsx(Sm,{})})}),l.jsx(Ie,{path:"/programs",element:l.jsx(Ue,{children:l.jsx(Bm,{})})}),l.jsx(Ie,{path:"/programs/:id",element:l.jsx(Ue,{children:l.jsx(Wm,{})})}),l.jsx(Ie,{path:"/catalog/:id",element:l.jsx(Ue,{children:l.jsx(Dm,{})})}),l.jsx(Ie,{path:"/rewards",element:l.jsx(Ue,{children:l.jsx(wm,{})})}),l.jsx(Ie,{path:"/week",element:l.jsx(Ue,{children:l.jsx(Pm,{})})}),l.jsx(Ie,{path:"/settings",element:l.jsx(Ue,{children:l.jsx(jm,{})})}),l.jsx(Ie,{path:"/support",element:l.jsx(Ue,{children:l.jsx($m,{})})}),l.jsx(Ie,{path:"/support/admin",element:l.jsx(Ue,{children:l.jsx(Bc,{})})}),l.jsx(Ie,{path:"/support/admin/:threadId",element:l.jsx(Ue,{children:l.jsx(Bc,{})})}),l.jsx(Ie,{path:"/welcome",element:l.jsx(Tm,{})}),l.jsx(Ie,{path:"/settings/integrations",element:l.jsx(Ue,{children:l.jsx(Rm,{})})}),l.jsx(Ie,{path:"/meal-plan",element:l.jsx(Ue,{children:l.jsx(Im,{})})}),l.jsx(Ie,{path:"/chat",element:l.jsx(Ue,{children:l.jsx(Hm,{})})}),l.jsx(Ie,{path:"/agent",element:l.jsx(Ue,{children:l.jsx(Qm,{})})})]}),ti()&&l.jsx(Ym,{})]})}op.createRoot(document.getElementById("root")).render(l.jsx(Mc.StrictMode,{children:l.jsx(im,{children:l.jsx(Jm,{})})})); diff --git a/diligence/frontend/assets/index-ciTnvXdZ.css b/diligence/frontend/assets/index-ciTnvXdZ.css new file mode 100644 index 0000000..8afd293 --- /dev/null +++ b/diligence/frontend/assets/index-ciTnvXdZ.css @@ -0,0 +1 @@ +@import"https://fonts.googleapis.com/css2?family=Instrument+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=IBM+Plex+Mono:wght@400;500;600&display=swap";:root{--accent: #2952CC;--accent-dark: #1e3fa8;--accent-light: #4A78E0;--accent-bg: #edf1fc;--accent-glow: rgba(41, 82, 204, .18);--green: #0d7d5a;--green-dark: #065c40;--green-light: #30C090;--green-bg: rgba(13, 125, 90, .08);--red: #C62828;--red-bg: rgba(198, 40, 40, .06);--amber: #b07800;--amber-bg: rgba(176, 120, 0, .08);--text: #1a1f2e;--text-2: #3d4660;--text-3: #6b7490;--text-inv: #FFFFFF;--bg: #fafbfe;--bg-warm: linear-gradient(170deg, #f0f2fa 0%, #fafbfe 50%, #f5f7fd 100%);--card: #FFFFFF;--card-border: rgba(216, 220, 232, .6);--divider: rgba(216, 220, 232, .8);--shadow-1: 0 1px 3px rgba(26, 31, 46, .06);--shadow-2: 0 4px 16px rgba(26, 31, 46, .08);--shadow-3: 0 12px 40px rgba(26, 31, 46, .12);--shadow-accent: 0 4px 20px rgba(41, 82, 204, .2);--shadow-green: 0 4px 20px rgba(13, 125, 90, .2);--r: 8px;--r-sm: 6px;--r-lg: 12px;--r-full: 999px;--font: "Instrument Sans", -apple-system, sans-serif;--font-display: "Instrument Sans", -apple-system, sans-serif;--font-mono: "IBM Plex Mono", monospace}*,*:before,*:after{margin:0;padding:0;box-sizing:border-box}body{font-family:var(--font);background:var(--bg);color:var(--text);min-height:100dvh;-webkit-font-smoothing:antialiased;font-size:15px;line-height:1.5}h1,h2,h3{font-family:var(--font-display);letter-spacing:-.02em;line-height:1.2}a{color:var(--accent);text-decoration:none;font-weight:600}a:hover{opacity:.8}button{font-family:var(--font);cursor:pointer;border:none;border-radius:var(--r);padding:12px 24px;font-size:.88rem;font-weight:700;letter-spacing:.01em;transition:all .2s cubic-bezier(.4,0,.2,1);-webkit-tap-highlight-color:transparent}button:active{transform:scale(.97)}.btn-primary{background:var(--accent);color:var(--text-inv);box-shadow:var(--shadow-accent)}.btn-primary:hover{background:var(--accent-dark)}.btn-primary:disabled{opacity:.4;cursor:not-allowed;transform:none;box-shadow:none}.btn-success{background:var(--green);color:var(--text-inv);box-shadow:var(--shadow-green)}.btn-outline{background:var(--card);border:2px solid var(--divider);color:var(--text)}.btn-outline:hover{border-color:var(--accent);color:var(--accent)}.btn-ghost{background:transparent;color:var(--text-2);padding:10px 16px}.btn-ghost:hover{background:var(--accent-bg);color:var(--accent)}.btn-danger{background:var(--red);color:var(--text-inv)}.btn-sm{padding:8px 16px;font-size:.82rem;border-radius:var(--r-sm)}.btn-full{width:100%}input,select,textarea{font-family:var(--font);background:var(--card);color:var(--text);border:2px solid var(--divider);border-radius:var(--r);padding:12px 16px;font-size:.88rem;font-weight:500;width:100%;outline:none;transition:border-color .2s}input:focus,select:focus,textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-glow)}input::placeholder,textarea::placeholder{color:var(--text-3);font-weight:400}.card{background:var(--card);border:1px solid var(--card-border);border-radius:var(--r-lg);padding:20px;margin-bottom:14px;box-shadow:var(--shadow-1)}.page{max-width:440px;margin:0 auto;padding:16px 16px 96px;min-height:100dvh;background:var(--bg-warm)}.page-title{font-family:var(--font-display);font-size:1.5rem;font-weight:600;margin-bottom:18px}.progress-bar{width:100%;height:10px;background:#0000000d;border-radius:var(--r-full);overflow:hidden}.progress-bar-fill{height:100%;border-radius:var(--r-full);transition:width .6s cubic-bezier(.4,0,.2,1)}.status-earned{color:var(--green)}.status-locked{color:var(--red)}.section-label{font-family:var(--font-mono);font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--text-3);margin-bottom:10px}.checklist-item{display:flex;align-items:center;gap:12px;padding:11px 0;border-bottom:1px solid var(--divider);font-size:.9rem;font-weight:500}.checklist-item:last-child{border-bottom:none}.checklist-check{font-size:1.15rem;min-width:26px}.checklist-points{margin-left:auto;font-family:var(--font-mono);font-size:.78rem;font-weight:700;color:var(--text-3);background:#0000000a;padding:3px 10px;border-radius:var(--r-full)}.nav-bar{position:fixed;bottom:0;left:0;right:0;background:#ffffffeb;backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border-top:1px solid var(--divider);display:flex;justify-content:space-around;padding:4px 0 env(safe-area-inset-bottom,6px);z-index:100}.nav-item{display:flex;flex-direction:column;align-items:center;gap:1px;color:var(--text-3);font-size:.62rem;font-weight:700;letter-spacing:.03em;padding:6px 10px;text-decoration:none;transition:color .15s;border-radius:var(--r-sm);position:relative}.nav-item.active{color:var(--accent)}.nav-item.active:before{content:"";position:absolute;top:-4px;left:50%;transform:translate(-50%);width:20px;height:3px;background:var(--accent);border-radius:2px}.nav-icon{font-size:1.2rem;margin-bottom:1px}.form-group{margin-bottom:16px}.form-label{display:block;font-size:.78rem;font-weight:700;color:var(--text-2);margin-bottom:6px;letter-spacing:.02em}.option-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}.option-btn{background:var(--card);border:2px solid var(--divider);border-radius:var(--r-lg);padding:18px 12px;text-align:center;color:var(--text);font-size:.88rem;font-weight:600;cursor:pointer;transition:all .2s;box-shadow:var(--shadow-1)}.option-btn:hover{border-color:var(--accent-light);transform:translateY(-2px);box-shadow:var(--shadow-2)}.option-btn.selected{border-color:var(--accent);background:var(--accent-bg);box-shadow:0 0 0 3px var(--accent-glow)}.chip-grid{display:flex;flex-wrap:wrap;gap:8px}.chip{background:var(--card);border:2px solid var(--divider);border-radius:var(--r-full);padding:8px 18px;font-size:.84rem;font-weight:600;cursor:pointer;transition:all .2s}.chip:hover{border-color:var(--accent-light)}.chip.selected{border-color:var(--accent);background:var(--accent);color:var(--text-inv)}.reward-card{display:flex;align-items:center;justify-content:space-between;padding:14px 0;border-bottom:1px solid var(--divider)}.reward-card:last-child{border-bottom:none}.likert-row{display:flex;align-items:center;gap:10px;margin:8px 0 18px;justify-content:center}.likert-btn{width:44px;height:44px;border-radius:50%;background:var(--card);border:2px solid var(--divider);color:var(--text);font-weight:600;font-size:.88rem;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:all .2s;box-shadow:var(--shadow-1)}.likert-btn:hover{border-color:var(--accent);transform:scale(1.1)}.likert-btn.selected{border-color:transparent;background:var(--accent);color:var(--text-inv);box-shadow:var(--shadow-accent);transform:scale(1.1)}.likert-labels{display:flex;justify-content:space-between;font-size:.68rem;color:var(--text-3);font-weight:700;text-transform:uppercase;letter-spacing:.06em}.gate-banner{text-align:center;padding:28px 20px;border-radius:var(--r-lg);margin-bottom:14px;position:relative}.gate-earned{background:linear-gradient(135deg,#e8f5e9,#c8e6c9,#e0f2f1);border:2px solid rgba(0,200,83,.2)}.gate-locked{background:linear-gradient(135deg,#fff3e0,#ffe0b2,#fff8e1);border:2px solid rgba(255,87,34,.15)}.gate-pts{font-family:var(--font-mono);font-size:2.8rem;font-weight:700;letter-spacing:-.04em;line-height:1;margin:8px 0}.meal-section{margin-bottom:18px}.meal-title{font-size:.72rem;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--text-3);margin-bottom:6px;padding-bottom:6px;border-bottom:2px solid var(--divider)}.loading{display:flex;justify-content:center;padding:60px;color:var(--text-3);font-weight:600}.error-msg{background:var(--red-ghost);border:2px solid rgba(255,23,68,.15);padding:12px 16px;border-radius:var(--r);color:var(--red);font-weight:600;font-size:.88rem;margin-bottom:14px}@keyframes fadeUp{0%{opacity:0;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}@keyframes popIn{0%{opacity:0;transform:scale(.92)}to{opacity:1;transform:scale(1)}}@keyframes countUp{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}.page>.card:nth-child(1){animation:fadeUp .3s ease-out both}.page>.card:nth-child(2){animation:fadeUp .3s ease-out .06s both}.page>.card:nth-child(3){animation:fadeUp .3s ease-out .12s both}.page>.card:nth-child(4){animation:fadeUp .3s ease-out .18s both}.gate-banner{animation:popIn .35s cubic-bezier(.4,0,.2,1) both}.gate-pts{animation:countUp .4s ease-out .15s both}@media(prefers-color-scheme:dark){:root{--accent: #6B9BFF;--accent-dark: #4A78E0;--accent-light: #8BB5FF;--accent-bg: #101630;--accent-glow: rgba(107, 155, 255, .18);--green: #30C090;--green-dark: #20A070;--green-light: #50D8A8;--green-bg: rgba(48, 192, 144, .1);--red: #EF5350;--red-bg: rgba(239, 83, 80, .1);--amber: #F0B040;--amber-bg: rgba(240, 176, 64, .1);--text: #eaecf4;--text-2: #b0b8d0;--text-3: #7882a0;--text-inv: #0a0b12;--bg: #0a0b12;--bg-warm: linear-gradient(170deg, #0e1020 0%, #0a0b12 50%, #0c0e1a 100%);--card: #12131d;--card-border: rgba(34, 40, 64, .8);--divider: rgba(34, 40, 64, .8);--shadow-1: 0 1px 3px rgba(0, 0, 0, .3);--shadow-2: 0 4px 16px rgba(0, 0, 0, .4);--shadow-3: 0 12px 40px rgba(0, 0, 0, .5);--shadow-accent: 0 4px 20px rgba(107, 155, 255, .15);--shadow-green: 0 4px 20px rgba(48, 192, 144, .15)}body{background:var(--bg);color:var(--text)}.nav-bar{background:#0a0b12eb}input,select,textarea{background:var(--bg);color:var(--text)}.gate-earned{background:linear-gradient(135deg,#30c0901f,#30c0900f);border-color:#30c09040}.gate-locked{background:linear-gradient(135deg,#6b9bff1a,#6b9bff0d);border-color:#6b9bff26}.option-btn,.chip,.likert-btn{background:var(--card)}}:root[data-theme=dark]{--accent: #6B9BFF;--accent-dark: #4A78E0;--accent-light: #8BB5FF;--accent-bg: #101630;--accent-glow: rgba(107, 155, 255, .18);--green: #30C090;--green-dark: #20A070;--green-light: #50D8A8;--green-bg: rgba(48, 192, 144, .1);--red: #EF5350;--red-bg: rgba(239, 83, 80, .1);--amber: #F0B040;--amber-bg: rgba(240, 176, 64, .1);--text: #eaecf4;--text-2: #b0b8d0;--text-3: #7882a0;--text-inv: #0a0b12;--bg: #0a0b12;--bg-warm: linear-gradient(170deg, #0e1020 0%, #0a0b12 50%, #0c0e1a 100%);--card: #12131d;--card-border: rgba(34, 40, 64, .8);--divider: rgba(34, 40, 64, .8);--shadow-1: 0 1px 3px rgba(0, 0, 0, .3);--shadow-2: 0 4px 16px rgba(0, 0, 0, .4);--shadow-3: 0 12px 40px rgba(0, 0, 0, .5);--shadow-accent: 0 4px 20px rgba(107, 155, 255, .15);--shadow-green: 0 4px 20px rgba(48, 192, 144, .15)}::-webkit-scrollbar{width:4px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--divider);border-radius:4px}@media(max-width:380px){.page{padding:12px 10px 96px}.gate-pts{font-size:2.2rem}.option-grid{gap:8px}.option-btn{padding:14px 10px}} diff --git a/diligence/frontend/index.html b/diligence/frontend/index.html new file mode 100644 index 0000000..d65473a --- /dev/null +++ b/diligence/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + + + Diligence + + + + +
+ + diff --git a/diligence/frontend/llms.txt b/diligence/frontend/llms.txt new file mode 100644 index 0000000..961f6ba --- /dev/null +++ b/diligence/frontend/llms.txt @@ -0,0 +1,31 @@ +# Diligence — Self-Hosted Fitness Rewards Platform + +> Data sovereignty for people. Self-hosted fitness tracker with a points-based +> behavioral economy and an MCP connector for AI agent integration. +> MIT-licensed. Deploy with a single `docker compose up -d`. + +Diligence is a self-hosted fitness web app built around a points-based reward +system. Complete fitness activities and food logging to earn points, spend +points to unlock configurable real-world rewards. Science-based onboarding +(PAR-Q+, TTM stages of change, BREQ-2 motivation assessment). + +## For AI Agents +- MCP Server: /mcp (Streamable HTTP/SSE, 14 tools) +- Agent Card: /.well-known/agent-card.json +- Skills: /.well-known/skills/diligence-fitness/SKILL.md + +## MCP Tools +- get_context, get_today, get_week (status) +- log_activity, log_food, search_food (tracking) +- get_program_schedule (programs) +- redeem_reward (rewards) +- load_meal_plan, get_meal_plan, update_meal_compliance, get_plan_progress (meal plans) +- configure_integration, get_integration_status (device sync) + +## Technical +- Source: https://github.com/diligenceworks/diligence (MIT) +- Stack: FastAPI + PostgreSQL + React/Vite + FastMCP + +## About +Built by DiligenceWorks (https://diligenceworks.online) — adversarial AI +deal-diligence platform for institutional investors. diff --git a/diligence/main.py b/diligence/main.py new file mode 100644 index 0000000..ee88768 --- /dev/null +++ b/diligence/main.py @@ -0,0 +1,402 @@ +from __future__ import annotations + +import asyncio +import sys +import logging +from pathlib import Path +from contextlib import asynccontextmanager +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("diligence") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Retry DB init — container may start before postgres is ready + for attempt in range(10): + try: + from diligence.database import init_db + await init_db() + logger.info("Database tables created successfully") + break + except Exception as e: + logger.warning(f"DB init attempt {attempt + 1}/10 failed: {e}") + if attempt < 9: + await asyncio.sleep(3) + else: + logger.error("Could not initialize database after 10 attempts") + + # Fail fast if SECRET_KEY not configured + from diligence.config import get_settings + _s = get_settings() + if _s.secret_key == "change-me-in-production": + logger.error("CRITICAL: SECRET_KEY not set. Run ./setup.sh or set SECRET_KEY in .env") + sys.exit(1) + + # Run lightweight migrations for schema changes + try: + await run_migrations() + logger.info("Migrations completed") + except Exception as e: + logger.warning(f"Migration failed (non-fatal): {e}") + + # Seed resources + try: + await seed_resources() + logger.info("Resource library seeded") + except Exception as e: + logger.warning(f"Resource seeding failed (non-fatal): {e}") + + # Start background crawl queue scheduler + crawl_task = None + if _s.crawl_enabled: + try: + from diligence.services.crawl_scheduler import crawl_queue_loop + crawl_task = asyncio.create_task(crawl_queue_loop()) + logger.info("Crawl queue scheduler started") + except Exception as e: + logger.warning(f"Crawl scheduler failed to start (non-fatal): {e}") + else: + logger.info("Crawl scheduler disabled (set CRAWL_ENABLED=true to enable)") + + logger.info("Diligence backend started") + yield + + if crawl_task: + crawl_task.cancel() + try: + await crawl_task + except asyncio.CancelledError: + pass + logger.info("Diligence backend shutting down") + + +app = FastAPI(title="Diligence", version="2.0.0", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], +) + +# Import routers after app creation to avoid circular imports +from diligence.routers.auth import router as auth_router +from diligence.routers.onboarding import router as onboarding_router +from diligence.routers.activities import router as activities_router +from diligence.routers.food import router as food_router +from diligence.routers.points import router as points_router, rewards_router +from diligence.routers.integrations import router as integrations_router +from diligence.routers.programs import router as programs_router +from diligence.routers.catalog import router as catalog_router +from diligence.routers.support import router as support_router +from diligence.routers.nutrition import router as nutrition_router +from diligence.routers.meal_plans import router as meal_plans_router +from diligence.routers.ai_chat import router as ai_chat_router +from diligence.routers.agent import router as agent_router + +app.include_router(auth_router) +app.include_router(onboarding_router) +app.include_router(activities_router) +app.include_router(food_router) +app.include_router(points_router) +app.include_router(rewards_router) +app.include_router(integrations_router) +app.include_router(catalog_router) +app.include_router(support_router) +app.include_router(programs_router) +app.include_router(nutrition_router) +app.include_router(meal_plans_router) +app.include_router(ai_chat_router) +app.include_router(agent_router) + + +@app.get("/api/health") +async def health(): + return {"status": "ok", "version": "2.0.0"} + + +# --- Static frontend serving (pip install path) --- +# When running without nginx, serve the pre-built React app directly. +# The frontend/ directory is bundled with the pip package. +_frontend_dir = Path(__file__).parent / "frontend" +if _frontend_dir.is_dir() and (_frontend_dir / "index.html").exists(): + # B2A discovery files + @app.get("/llms.txt") + async def llms_txt(): + p = _frontend_dir / "llms.txt" + if p.exists(): + return FileResponse(p, media_type="text/plain") + + @app.get("/.well-known/{path:path}") + async def well_known(path: str): + p = _frontend_dir / ".well-known" / path + if p.exists(): + media = "text/plain" if path.endswith(".md") else "application/json" + return FileResponse(p, media_type=media) + + # Static assets (js, css, images) + app.mount("/assets", StaticFiles(directory=_frontend_dir / "assets"), name="assets") + + # SPA catch-all — must be last + @app.get("/{path:path}") + async def spa_catchall(path: str): + # Don't intercept /api routes (already handled above) + if path.startswith("api/"): + return + file_path = _frontend_dir / path + if file_path.is_file(): + return FileResponse(file_path) + return FileResponse(_frontend_dir / "index.html") + + logger.info(f"Serving frontend from {_frontend_dir}") + + +# --- Cross-dialect migrations --- + +async def run_migrations(): + """Add missing columns/tables. Works on both PostgreSQL and SQLite.""" + from diligence.database import engine + from diligence.config import get_settings + from sqlalchemy import text, inspect as sa_inspect + + settings = get_settings() + + async with engine.begin() as conn: + # Get table inspector + def _get_tables_and_columns(connection): + inspector = sa_inspect(connection) + tables = inspector.get_table_names() + columns = {} + for t in tables: + columns[t] = [c["name"] for c in inspector.get_columns(t)] + return tables, columns + + tables, columns = await conn.run_sync(_get_tables_and_columns) + + # v1: Add equipment_list column if missing + if "user_profiles" in tables and "equipment_list" not in columns.get("user_profiles", []): + if settings.is_sqlite: + await conn.execute(text( + "ALTER TABLE user_profiles ADD COLUMN equipment_list TEXT DEFAULT '[]'" + )) + else: + await conn.execute(text( + "ALTER TABLE user_profiles ADD COLUMN equipment_list JSONB DEFAULT '[]'" + )) + + # v2: Add catalog columns to programs table + if "programs" in tables: + prog_cols = columns.get("programs", []) + if "catalog_id" not in prog_cols: + await conn.execute(text( + "ALTER TABLE programs ADD COLUMN catalog_id VARCHAR(36)" + )) + if "current_week" not in prog_cols: + await conn.execute(text( + "ALTER TABLE programs ADD COLUMN current_week INTEGER DEFAULT 1" + )) + if "current_day" not in prog_cols: + await conn.execute(text( + "ALTER TABLE programs ADD COLUMN current_day INTEGER DEFAULT 1" + )) + + # v2.1: Add week_number to workout_logs + if "workout_logs" in tables and "week_number" not in columns.get("workout_logs", []): + await conn.execute(text( + "ALTER TABLE workout_logs ADD COLUMN week_number INTEGER DEFAULT 1" + )) + + # v4: Add is_admin to users + if "users" in tables and "is_admin" not in columns.get("users", []): + await conn.execute(text( + "ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT FALSE" + )) + + # v4.1: Grant admin to first registered user if none exists + if "users" in tables and "is_admin" in columns.get("users", []): + result = await conn.execute(text( + "SELECT COUNT(*) FROM users WHERE is_admin = TRUE" + )) + admin_count = result.scalar() + if admin_count == 0: + await conn.execute(text( + "UPDATE users SET is_admin = TRUE " + "WHERE id = (SELECT id FROM users ORDER BY created_at ASC LIMIT 1)" + )) + + # v5: integration_configs table — handled by create_all + # v6: meal plan tables — handled by create_all + # v7: point rules seeding + if "point_rules" in tables and "users" in tables: + for category, points in [ + ("fast_completed", 200), + ("keto_compliant_day", 100), + ("meal_plan_followed", 40), + ("meal_plan_partial", 20), + ]: + # Check if any user is missing this rule + result = await conn.execute(text( + "SELECT u.id FROM users u " + "WHERE NOT EXISTS (" + " SELECT 1 FROM point_rules pr " + " WHERE pr.user_id = u.id AND pr.category = :cat" + ")" + ), {"cat": category}) + missing_users = result.fetchall() + for (user_id,) in missing_users: + # Use Python uuid for SQLite compatibility + import uuid + await conn.execute(text( + "INSERT INTO point_rules (id, user_id, category, points, unit, is_active) " + "VALUES (:id, :uid, :cat, :pts, 'per_event', TRUE)" + ), { + "id": str(uuid.uuid4()), + "uid": str(user_id), + "cat": category, + "pts": points, + }) + + +async def seed_resources(): + """Seed the resource library with curated fitness programs.""" + from diligence.database import async_session + from diligence.models.resource import Resource + from sqlalchemy import select + + async with async_session() as db: + existing = await db.execute(select(Resource).limit(1)) + if existing.scalar_one_or_none(): + return + + resources = [ + Resource( + name="Darebee Foundation Program", + source="darebee", + url="https://darebee.com/programs/foundation-program.html", + description="30-day beginner program. Bodyweight exercises, no equipment needed. Perfect starting point.", + goal_tags=["get_active", "feel_better"], + activity_tags=["bodyweight"], + equipment_needed="none", + ttm_stages=["precontemplation", "contemplation", "preparation"], + difficulty="beginner", + duration_days=30, + ), + Resource( + name="Darebee 30 Days of Change", + source="darebee", + url="https://darebee.com/programs/30-days-of-change.html", + description="30-day progressive program for building fitness habits. Bodyweight only.", + goal_tags=["get_active", "lose_weight", "feel_better"], + activity_tags=["bodyweight"], + equipment_needed="none", + ttm_stages=["contemplation", "preparation"], + difficulty="beginner", + duration_days=30, + ), + Resource( + name="Darebee IRONHEART", + source="darebee", + url="https://darebee.com/programs/ironheart.html", + description="Strength-focused bodyweight program. No equipment, all levels.", + goal_tags=["build_strength"], + activity_tags=["bodyweight"], + equipment_needed="none", + ttm_stages=["preparation", "action"], + difficulty="intermediate", + duration_days=30, + ), + Resource( + name="Darebee Total Body Strength", + source="darebee", + url="https://darebee.com/programs/total-body-strength.html", + description="Comprehensive strength building program using bodyweight.", + goal_tags=["build_strength"], + activity_tags=["bodyweight"], + equipment_needed="none", + ttm_stages=["action", "maintenance"], + difficulty="intermediate", + duration_days=30, + ), + Resource( + name="Darebee 8 Weeks to 5K", + source="darebee", + url="https://darebee.com/programs/8-weeks-to-5k-program.html", + description="Progressive running program from beginner to 5K in 8 weeks.", + goal_tags=["get_active", "lose_weight"], + activity_tags=["running"], + equipment_needed="none", + ttm_stages=["preparation", "action"], + difficulty="beginner", + duration_days=56, + ), + Resource( + name="StrongLifts 5x5", + source="stronglifts", + url="https://stronglifts.com/5x5/", + description="Simple barbell strength program. 3 days/week, 5 exercises, progressive overload.", + goal_tags=["build_strength"], + activity_tags=["weights"], + equipment_needed="full_gym", + ttm_stages=["preparation", "action", "maintenance"], + difficulty="beginner", + duration_days=90, + ), + Resource( + name="Darebee 30 Days of Cardio", + source="darebee", + url="https://darebee.com/programs/30-days-of-cardio.html", + description="30-day cardio program, no equipment. Great for weight loss.", + goal_tags=["lose_weight", "get_active"], + activity_tags=["bodyweight"], + equipment_needed="none", + ttm_stages=["preparation", "action"], + difficulty="beginner", + duration_days=30, + ), + Resource( + name="Darebee POWERBUILDER", + source="darebee", + url="https://darebee.com/programs/powerbuilder-program.html", + description="Advanced strength and power program using bodyweight.", + goal_tags=["build_strength"], + activity_tags=["bodyweight"], + equipment_needed="none", + ttm_stages=["action", "maintenance"], + difficulty="advanced", + duration_days=30, + ), + Resource( + name="Fitness Blender (YouTube)", + source="youtube", + url="https://www.youtube.com/@fitnessblender", + description="Free workout videos for all levels. Huge variety: HIIT, strength, yoga, pilates.", + goal_tags=["lose_weight", "build_strength", "get_active", "feel_better"], + activity_tags=["bodyweight", "yoga"], + equipment_needed="none", + ttm_stages=["contemplation", "preparation", "action", "maintenance"], + difficulty="beginner", + duration_days=None, + ), + Resource( + name="Darebee Yoga Flexibility Program", + source="darebee", + url="https://darebee.com/programs/flexibility-program.html", + description="30-day flexibility and yoga program for beginners.", + goal_tags=["feel_better"], + activity_tags=["yoga"], + equipment_needed="none", + ttm_stages=["precontemplation", "contemplation", "preparation"], + difficulty="beginner", + duration_days=30, + ), + ] + + for r in resources: + db.add(r) + await db.commit() diff --git a/diligence/mcp/__init__.py b/diligence/mcp/__init__.py new file mode 100644 index 0000000..ff948f8 --- /dev/null +++ b/diligence/mcp/__init__.py @@ -0,0 +1,41 @@ +"""MCP server startup for the pip install path. + +Starts FastMCP in a background thread so AI agents can connect +to the same machine running Diligence. Requires: pip install diligence[mcp] +""" +from __future__ import annotations + +import threading +import logging + +logger = logging.getLogger("diligence.mcp") + + +def start_mcp_background(api_url: str, api_token: str = "", port: int = 3001): + """Start the MCP SSE server in a background thread. + + Args: + api_url: Backend API base URL (e.g. http://localhost:8000) + api_token: Bearer token for API auth + port: Port for the MCP SSE server (default 3001) + """ + try: + from diligence.mcp.server import create_mcp_server + except ImportError: + logger.warning("MCP package not installed. Install with: pip install diligence[mcp]") + return False + + def _run(): + import asyncio + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + mcp = create_mcp_server(api_url=api_url, api_token=api_token, port=port) + mcp.run(transport="sse") + except Exception as e: + logger.error(f"MCP server failed: {e}") + + thread = threading.Thread(target=_run, daemon=True, name="mcp-server") + thread.start() + logger.info(f"MCP server starting on port {port}") + return True diff --git a/diligence/mcp/server.py b/diligence/mcp/server.py new file mode 100644 index 0000000..59bc4c7 --- /dev/null +++ b/diligence/mcp/server.py @@ -0,0 +1,223 @@ +"""Diligence MCP Server — 14 tools for AI agent integration. + +For the pip install path, this runs in-process as a background thread. +For the Docker path, this runs as a separate container (mcp-connector/). +""" +from __future__ import annotations + +import json +import httpx +from mcp.server.fastmcp import FastMCP + +_client: httpx.AsyncClient | None = None + + +def create_mcp_server(api_url: str = "http://localhost:8000", api_token: str = "", port: int = 3001) -> FastMCP: + """Create and configure the MCP server with all 14 tools.""" + + mcp = FastMCP("Diligence Fitness", port=port) + + async def api(method: str, path: str, **kwargs) -> dict: + global _client + if _client is None: + headers = {} + if api_token: + headers["Authorization"] = f"Bearer {api_token}" + _client = httpx.AsyncClient( + base_url=api_url, timeout=30, headers=headers + ) + resp = await getattr(_client, method)(f"/api{path}", **kwargs) + resp.raise_for_status() + return resp.json() + + # --- CONTEXT & STATUS (3 tools) --- + + @mcp.tool() + async def get_context() -> str: + """Get full user profile, motivation type, program status, point rules, rewards, and integration status. Call this at the start of each conversation to understand the user's current state.""" + data = await api("get", "/points/today") + profile = await api("get", "/onboarding/profile") + rewards = await api("get", "/points/rewards") + integrations = await api("get", "/integrations/status") + meal_plan = None + try: + meal_plan = await api("get", "/meal-plans/today") + except Exception: + pass + return json.dumps({ + "user": profile, "today": data, "rewards": rewards, + "integrations": integrations, "active_meal_plan": meal_plan, + }, indent=2, default=str) + + @mcp.tool() + async def get_today(date_str: str | None = None) -> str: + """Get today's points earned, daily gate status, breakdown by category, and activities logged. Optionally pass a date (YYYY-MM-DD).""" + params = {"date": date_str} if date_str else {} + data = await api("get", "/points/today", params=params) + return json.dumps(data, indent=2, default=str) + + @mcp.tool() + async def get_week(start_date: str | None = None) -> str: + """Get weekly summary including total points, active days count, target progress, and day-by-day breakdown.""" + params = {"start_date": start_date} if start_date else {} + data = await api("get", "/points/week", params=params) + return json.dumps(data, indent=2, default=str) + + # --- ACTIVITY LOGGING (1 tool) --- + + @mcp.tool() + async def log_activity( + category: str, title: str, duration_minutes: int, + notes: str | None = None, date_str: str | None = None, + program_day: int | None = None, + ) -> str: + """Log a workout or activity. Returns points earned and daily total. + + Args: + category: Type of activity (workout, cardio, yoga, walking, cycling, swimming, other) + title: Description of what was done + duration_minutes: How long the activity lasted + notes: Optional additional notes + date_str: Optional date (YYYY-MM-DD), defaults to today + program_day: Optional program day number + """ + payload = {"category": category, "title": title, "duration_minutes": duration_minutes} + if notes: payload["notes"] = notes + if date_str: payload["date"] = date_str + if program_day: payload["program_day"] = program_day + data = await api("post", "/activities/log", json=payload) + return json.dumps(data, indent=2, default=str) + + # --- FOOD & NUTRITION (2 tools) --- + + @mcp.tool() + async def log_food( + meal_type: str, food_name: str, calories: int | None = None, + protein_g: float | None = None, carbs_g: float | None = None, + fat_g: float | None = None, servings: float | None = None, + plan_item_id: str | None = None, + ) -> str: + """Log a food item with optional nutrition data. + + Args: + meal_type: One of breakfast, lunch, dinner, snack + food_name: What was eaten + calories: Estimated calories + protein_g: Grams of protein + carbs_g: Grams of carbs + fat_g: Grams of fat + servings: Number of servings (default 1) + plan_item_id: Optional meal plan item ID for compliance + """ + payload = {"meal_type": meal_type, "food_name": food_name} + for k, v in [("calories", calories), ("protein_g", protein_g), ("carbs_g", carbs_g), + ("fat_g", fat_g), ("servings", servings), ("plan_item_id", plan_item_id)]: + if v is not None: payload[k] = v + data = await api("post", "/food/log", json=payload) + return json.dumps(data, indent=2, default=str) + + @mcp.tool() + async def search_food(query: str) -> str: + """Search for food items in Open Food Facts and USDA FoodData Central. Returns nutrition data for matching items.""" + data = await api("get", "/food/search", params={"q": query}) + return json.dumps(data, indent=2, default=str) + + # --- PROGRAMS (1 tool) --- + + @mcp.tool() + async def get_program_schedule(date_str: str | None = None) -> str: + """Get today's scheduled workout from the active program, including exercises with sets, reps, and weight progression.""" + params = {"date": date_str} if date_str else {} + data = await api("get", "/programs/schedule", params=params) + return json.dumps(data, indent=2, default=str) + + # --- REWARDS (1 tool) --- + + @mcp.tool() + async def redeem_reward(reward_name: str) -> str: + """Spend points on a configured reward. Returns success/failure and remaining points.""" + data = await api("post", "/points/rewards/redeem", json={"name": reward_name}) + return json.dumps(data, indent=2, default=str) + + # --- MEAL PLANS (4 tools) --- + + @mcp.tool() + async def load_meal_plan( + name: str, duration_days: int, meals: list[dict], + diet_type: str | None = None, daily_calories: int | None = None, + restrictions: list[str] | None = None, + ) -> str: + """Create a complete meal plan with all items. + + Args: + name: Plan name (e.g. "7-Day Dairy-Free Keto") + duration_days: How many days the plan covers + meals: List of meal items with day_number, meal_type, food_name, calories, protein_g, carbs_g, fat_g + diet_type: Optional diet type (keto, paleo, balanced, custom) + daily_calories: Optional daily calorie target + restrictions: Optional list of dietary restrictions + """ + payload = {"name": name, "duration_days": duration_days, "meals": meals} + if diet_type: payload["diet_type"] = diet_type + if daily_calories: payload["daily_calories"] = daily_calories + if restrictions: payload["restrictions"] = restrictions + data = await api("post", "/meal-plans", json=payload) + return json.dumps(data, indent=2, default=str) + + @mcp.tool() + async def get_meal_plan(date_str: str | None = None) -> str: + """View the active meal plan's meals for today or a specified date.""" + data = await api("get", "/meal-plans/today") + return json.dumps(data, indent=2, default=str) + + @mcp.tool() + async def update_meal_compliance( + plan_item_id: str, status: str, substitution: str | None = None, + ) -> str: + """Mark a planned meal as followed, substituted, or skipped. + + Args: + plan_item_id: The meal plan item ID + status: One of 'followed', 'substituted', 'skipped' + substitution: What was eaten instead (required if 'substituted') + """ + payload = {"plan_item_id": plan_item_id, "status": status} + if substitution: payload["substitution"] = substitution + data = await api("post", "/meal-plans/compliance", json=payload) + return json.dumps(data, indent=2, default=str) + + @mcp.tool() + async def get_plan_progress(plan_id: str | None = None) -> str: + """Get overall compliance stats for a meal plan.""" + if plan_id: + data = await api("get", f"/meal-plans/{plan_id}/progress") + else: + plans = await api("get", "/meal-plans") + active = next((p for p in plans if p.get("status") == "active"), None) + if not active: + return json.dumps({"error": "No active meal plan"}) + data = await api("get", f"/meal-plans/{active['id']}/progress") + return json.dumps(data, indent=2, default=str) + + # --- INTEGRATIONS (2 tools) --- + + @mcp.tool() + async def configure_integration(provider: str, credentials: dict) -> str: + """Store encrypted integration credentials. Write-only. + + Args: + provider: Provider name (strava, polar, garmin, fitbit, usda, telegram, groq, etc.) + credentials: Dict of credential fields + """ + data = await api("post", "/integrations/configure", json={ + "provider": provider, "credentials": credentials, + }) + return json.dumps(data, indent=2, default=str) + + @mcp.tool() + async def get_integration_status() -> str: + """Check connection status of all integration providers.""" + data = await api("get", "/integrations/status") + return json.dumps(data, indent=2, default=str) + + return mcp diff --git a/diligence/models/__init__.py b/diligence/models/__init__.py new file mode 100644 index 0000000..5e08329 --- /dev/null +++ b/diligence/models/__init__.py @@ -0,0 +1,24 @@ +from diligence.models.user import User +from diligence.models.profile import UserProfile +from diligence.models.program import Program +from diligence.models.activity import ActivityLog +from diligence.models.food import FoodLog +from diligence.models.points import PointRule, DailyTarget +from diligence.models.reward import Reward, RewardRedemption +from diligence.models.oauth import OAuthToken +from diligence.models.resource import Resource +from diligence.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue, WorkoutLog +from diligence.models.support import SupportThread, SupportMessage +from diligence.models.nutrition import NutritionGoal, Fast, ElectrolyteLog +from diligence.models.integration_config import IntegrationConfig +from diligence.models.meal_plan import MealPlan, MealPlanItem, MealCompliance + +__all__ = [ + "User", "UserProfile", "Program", "ActivityLog", "FoodLog", + "PointRule", "DailyTarget", "Reward", "RewardRedemption", + "OAuthToken", "Resource", + "ProgramCatalog", "CatalogWorkout", "CrawlQueue", "WorkoutLog", + "SupportThread", "SupportMessage", + "NutritionGoal", "Fast", "ElectrolyteLog", + "IntegrationConfig", "MealPlan", "MealPlanItem", "MealCompliance", +] diff --git a/backend/app/models/activity.py b/diligence/models/activity.py similarity index 68% rename from backend/app/models/activity.py rename to diligence/models/activity.py index 890135d..92f10f7 100644 --- a/backend/app/models/activity.py +++ b/diligence/models/activity.py @@ -2,17 +2,16 @@ from __future__ import annotations import uuid from datetime import datetime, date, timezone -from sqlalchemy import String, Integer, Text, Date, DateTime, ForeignKey, Index +from sqlalchemy import String, Integer, Text, Date, DateTime, ForeignKey, Index, Uuid, JSON from sqlalchemy.orm import Mapped, mapped_column -from sqlalchemy.dialects.postgresql import UUID, JSONB -from app.database import Base +from diligence.database import Base class ActivityLog(Base): __tablename__ = "activity_log" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False) category: Mapped[str] = mapped_column(String(50), nullable=False) title: Mapped[str | None] = mapped_column(String(200)) description: Mapped[str | None] = mapped_column(Text) @@ -20,12 +19,12 @@ class ActivityLog(Base): source: Mapped[str] = mapped_column(String(30), default="manual") external_id: Mapped[str | None] = mapped_column(String(100)) points_earned: Mapped[int] = mapped_column(Integer, default=0) - rule_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("point_rules.id"), nullable=True) + rule_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("point_rules.id"), nullable=True) activity_date: Mapped[date] = mapped_column(Date, nullable=False) logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) - program_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("programs.id"), nullable=True) + program_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("programs.id"), nullable=True) program_day: Mapped[int | None] = mapped_column(Integer) - metadata_json: Mapped[dict] = mapped_column("metadata", JSONB, default=dict) + metadata_json: Mapped[dict] = mapped_column("metadata", JSON, default=dict) __table_args__ = ( Index("idx_activity_log_user_date", "user_id", "activity_date"), diff --git a/backend/app/models/catalog.py b/diligence/models/catalog.py similarity index 76% rename from backend/app/models/catalog.py rename to diligence/models/catalog.py index aa071b8..494fdc0 100644 --- a/backend/app/models/catalog.py +++ b/diligence/models/catalog.py @@ -2,28 +2,27 @@ from __future__ import annotations import uuid from datetime import datetime, timezone -from sqlalchemy import String, Integer, Text, Boolean, DateTime, ForeignKey, UniqueConstraint +from sqlalchemy import String, Integer, Text, Boolean, DateTime, ForeignKey, UniqueConstraint, Uuid, JSON from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID, JSONB -from app.database import Base +from diligence.database import Base class ProgramCatalog(Base): """Shared program definitions — one per program, reusable by all users.""" __tablename__ = "program_catalog" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) name: Mapped[str] = mapped_column(String(200), nullable=False) slug: Mapped[str] = mapped_column(String(200), unique=True, nullable=False) description: Mapped[str | None] = mapped_column(Text) source_url: Mapped[str | None] = mapped_column(Text) duration_weeks: Mapped[int | None] = mapped_column(Integer) frequency_per_week: Mapped[int | None] = mapped_column(Integer) - equipment: Mapped[dict] = mapped_column(JSONB, default=list) + equipment: Mapped[dict] = mapped_column(JSON, default=list) difficulty: Mapped[str | None] = mapped_column(String(20)) category: Mapped[str | None] = mapped_column(String(50)) progression_rules: Mapped[str | None] = mapped_column(Text) - structured_data: Mapped[dict | None] = mapped_column(JSONB) + structured_data: Mapped[dict | None] = mapped_column(JSON) crawl_status: Mapped[str] = mapped_column(String(20), default="pending") crawl_error: Mapped[str | None] = mapped_column(Text) crawled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) @@ -41,14 +40,14 @@ class CatalogWorkout(Base): """Individual workout within a catalog program.""" __tablename__ = "catalog_workouts" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) catalog_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), ForeignKey("program_catalog.id", ondelete="CASCADE"), nullable=False + Uuid, ForeignKey("program_catalog.id", ondelete="CASCADE"), nullable=False ) week_number: Mapped[int] = mapped_column(Integer, nullable=False) day_number: Mapped[int] = mapped_column(Integer, nullable=False) workout_name: Mapped[str | None] = mapped_column(String(200)) - exercises: Mapped[dict] = mapped_column(JSONB, nullable=False) + exercises: Mapped[dict] = mapped_column(JSON, nullable=False) notes: Mapped[str | None] = mapped_column(Text) rest_day: Mapped[bool] = mapped_column(Boolean, default=False) @@ -63,14 +62,14 @@ class CrawlQueue(Base): """Job queue for program research crawls.""" __tablename__ = "crawl_queue" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) catalog_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), ForeignKey("program_catalog.id"), nullable=True + Uuid, ForeignKey("program_catalog.id"), nullable=True ) search_query: Mapped[str] = mapped_column(String(500), nullable=False) priority: Mapped[str] = mapped_column(String(10), default="low") status: Mapped[str] = mapped_column(String(20), default="pending") - urls_to_crawl: Mapped[dict] = mapped_column(JSONB, default=list) + urls_to_crawl: Mapped[dict] = mapped_column(JSON, default=list) crawled_content: Mapped[str | None] = mapped_column(Text) error: Mapped[str | None] = mapped_column(Text) created_at: Mapped[datetime] = mapped_column( @@ -84,20 +83,20 @@ class WorkoutLog(Base): """Per-user workout completion tracking against catalog workouts.""" __tablename__ = "workout_logs" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) user_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), ForeignKey("users.id"), nullable=False + Uuid, ForeignKey("users.id"), nullable=False ) program_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), ForeignKey("programs.id"), nullable=False + Uuid, ForeignKey("programs.id"), nullable=False ) catalog_workout_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), ForeignKey("catalog_workouts.id"), nullable=False + Uuid, ForeignKey("catalog_workouts.id"), nullable=False ) week_number: Mapped[int] = mapped_column(Integer, nullable=False, default=1) completed_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) ) - exercises_completed: Mapped[dict | None] = mapped_column(JSONB) + exercises_completed: Mapped[dict | None] = mapped_column(JSON) points_earned: Mapped[int] = mapped_column(Integer, default=0) notes: Mapped[str | None] = mapped_column(Text) diff --git a/backend/app/models/food.py b/diligence/models/food.py similarity index 80% rename from backend/app/models/food.py rename to diligence/models/food.py index 99dd4e2..432ea48 100644 --- a/backend/app/models/food.py +++ b/diligence/models/food.py @@ -3,17 +3,16 @@ from __future__ import annotations import uuid from datetime import datetime, date, timezone from decimal import Decimal -from sqlalchemy import String, Numeric, Date, DateTime, Text, ForeignKey, Index +from sqlalchemy import String, Numeric, Date, DateTime, Text, ForeignKey, Index, Uuid, JSON from sqlalchemy.orm import Mapped, mapped_column -from sqlalchemy.dialects.postgresql import UUID, JSONB -from app.database import Base +from diligence.database import Base class FoodLog(Base): __tablename__ = "food_log" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False) meal_type: Mapped[str] = mapped_column(String(20), nullable=False) food_name: Mapped[str] = mapped_column(String(300), nullable=False) brand: Mapped[str | None] = mapped_column(String(200)) @@ -30,7 +29,7 @@ class FoodLog(Base): food_date: Mapped[date] = mapped_column(Date, nullable=False) logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) off_product_id: Mapped[str | None] = mapped_column(String(50)) - off_data: Mapped[dict] = mapped_column(JSONB, default=dict) + off_data: Mapped[dict] = mapped_column(JSON, default=dict) __table_args__ = ( Index("idx_food_log_user_date", "user_id", "food_date"), diff --git a/backend/app/models/integration_config.py b/diligence/models/integration_config.py similarity index 72% rename from backend/app/models/integration_config.py rename to diligence/models/integration_config.py index 39893bf..8d23b83 100644 --- a/backend/app/models/integration_config.py +++ b/diligence/models/integration_config.py @@ -3,17 +3,16 @@ from __future__ import annotations import uuid from datetime import datetime, timezone -from sqlalchemy import String, DateTime, Text, UniqueConstraint +from sqlalchemy import String, DateTime, Text, UniqueConstraint, Uuid from sqlalchemy.orm import Mapped, mapped_column -from sqlalchemy.dialects.postgresql import UUID -from app.database import Base +from diligence.database import Base class IntegrationConfig(Base): __tablename__ = "integration_configs" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False) provider: Mapped[str] = mapped_column(String(50), nullable=False) config_key: Mapped[str] = mapped_column(String(100), nullable=False) config_value: Mapped[str] = mapped_column(Text, nullable=False) # Fernet encrypted diff --git a/backend/app/models/meal_plan.py b/diligence/models/meal_plan.py similarity index 72% rename from backend/app/models/meal_plan.py rename to diligence/models/meal_plan.py index 280ddd3..a5ca218 100644 --- a/backend/app/models/meal_plan.py +++ b/diligence/models/meal_plan.py @@ -4,24 +4,23 @@ from __future__ import annotations import uuid from datetime import datetime, date, timezone from decimal import Decimal -from sqlalchemy import String, Integer, Date, DateTime, Text, ForeignKey, DECIMAL +from sqlalchemy import String, Integer, Date, DateTime, Text, ForeignKey, DECIMAL, Uuid, JSON from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID, JSONB -from app.database import Base +from diligence.database import Base class MealPlan(Base): __tablename__ = "meal_plans" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False) name: Mapped[str] = mapped_column(String(200), nullable=False) diet_type: Mapped[str | None] = mapped_column(String(50), nullable=True) daily_calories: Mapped[int | None] = mapped_column(Integer, nullable=True) daily_protein_g: Mapped[int | None] = mapped_column(Integer, nullable=True) daily_carbs_g: Mapped[int | None] = mapped_column(Integer, nullable=True) daily_fat_g: Mapped[int | None] = mapped_column(Integer, nullable=True) - restrictions: Mapped[dict] = mapped_column(JSONB, default=list) + restrictions: Mapped[dict] = mapped_column(JSON, default=list) duration_days: Mapped[int] = mapped_column(Integer, nullable=False) start_date: Mapped[date] = mapped_column(Date, nullable=False) status: Mapped[str] = mapped_column(String(20), default="active") @@ -33,8 +32,8 @@ class MealPlan(Base): class MealPlanItem(Base): __tablename__ = "meal_plan_items" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - plan_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("meal_plans.id", ondelete="CASCADE"), nullable=False) + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + plan_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("meal_plans.id", ondelete="CASCADE"), nullable=False) day_number: Mapped[int] = mapped_column(Integer, nullable=False) meal_type: Mapped[str] = mapped_column(String(20), nullable=False) food_name: Mapped[str] = mapped_column(String(300), nullable=False) @@ -54,12 +53,12 @@ class MealPlanItem(Base): class MealCompliance(Base): __tablename__ = "meal_compliance" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) - plan_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("meal_plans.id"), nullable=False) - plan_item_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False) + plan_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("meal_plans.id"), nullable=False) + plan_item_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True) compliance_date: Mapped[date] = mapped_column(Date, nullable=False) status: Mapped[str] = mapped_column(String(20), nullable=False) # followed, substituted, skipped substitution: Mapped[str | None] = mapped_column(Text, nullable=True) - food_log_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) + food_log_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/models/nutrition.py b/diligence/models/nutrition.py similarity index 79% rename from backend/app/models/nutrition.py rename to diligence/models/nutrition.py index 205dd7e..95a52dd 100644 --- a/backend/app/models/nutrition.py +++ b/diligence/models/nutrition.py @@ -3,18 +3,17 @@ from __future__ import annotations import uuid from datetime import datetime, timezone from decimal import Decimal -from sqlalchemy import String, Integer, Numeric, DateTime, Boolean, Text, ForeignKey, Index +from sqlalchemy import String, Integer, Numeric, DateTime, Boolean, Text, ForeignKey, Index, Uuid from sqlalchemy.orm import Mapped, mapped_column -from sqlalchemy.dialects.postgresql import UUID -from app.database import Base +from diligence.database import Base class NutritionGoal(Base): """Per-user macro + eating-window targets. One active per user.""" __tablename__ = "nutrition_goals" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, unique=True) + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False, unique=True) # Macro targets (grams / kcal) calorie_target: Mapped[int] = mapped_column(Integer, default=2400) @@ -33,7 +32,7 @@ class NutritionGoal(Base): default_fast_hours: Mapped[int] = mapped_column(Integer, default=16) # 16:8 diet_style: Mapped[str] = mapped_column(String(30), default="strict_keto") - timezone_str: Mapped[str] = mapped_column(String(50), default="Asia/Bangkok") + timezone_str: Mapped[str] = mapped_column(String(50), default="UTC") is_active: Mapped[bool] = mapped_column(Boolean, default=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) @@ -44,8 +43,8 @@ class Fast(Base): """A single fasting bout — start/end + type + compliance flag.""" __tablename__ = "fasts" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False) started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) @@ -60,7 +59,7 @@ class Fast(Base): # Points granted (for idempotency — don't double-credit) points_awarded: Mapped[int] = mapped_column(Integer, default=0) - activity_log_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("activity_log.id")) + activity_log_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("activity_log.id")) notes: Mapped[str | None] = mapped_column(Text) @@ -75,8 +74,8 @@ class ElectrolyteLog(Base): """Daily electrolyte dosing checklist — lightweight tracker.""" __tablename__ = "electrolyte_log" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False) log_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) sodium_mg: Mapped[int] = mapped_column(Integer, default=0) diff --git a/backend/app/models/oauth.py b/diligence/models/oauth.py similarity index 77% rename from backend/app/models/oauth.py rename to diligence/models/oauth.py index 59d5450..9cac541 100644 --- a/backend/app/models/oauth.py +++ b/diligence/models/oauth.py @@ -2,17 +2,16 @@ from __future__ import annotations import uuid from datetime import datetime, timezone -from sqlalchemy import String, Text, DateTime, ForeignKey, UniqueConstraint +from sqlalchemy import String, Text, DateTime, ForeignKey, UniqueConstraint, Uuid from sqlalchemy.orm import Mapped, mapped_column -from sqlalchemy.dialects.postgresql import UUID -from app.database import Base +from diligence.database import Base class OAuthToken(Base): __tablename__ = "oauth_tokens" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False) provider: Mapped[str] = mapped_column(String(20), nullable=False) access_token: Mapped[str] = mapped_column(Text, nullable=False) refresh_token: Mapped[str | None] = mapped_column(Text) diff --git a/backend/app/models/points.py b/diligence/models/points.py similarity index 81% rename from backend/app/models/points.py rename to diligence/models/points.py index 1c3bc56..cd6eddc 100644 --- a/backend/app/models/points.py +++ b/diligence/models/points.py @@ -2,17 +2,16 @@ from __future__ import annotations import uuid from datetime import datetime, timezone -from sqlalchemy import String, Integer, Boolean, DateTime, ForeignKey +from sqlalchemy import String, Integer, Boolean, DateTime, ForeignKey, Uuid from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID -from app.database import Base +from diligence.database import Base class PointRule(Base): __tablename__ = "point_rules" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False) category: Mapped[str] = mapped_column(String(50), nullable=False) description: Mapped[str] = mapped_column(String(200), nullable=False) points: Mapped[int] = mapped_column(Integer, nullable=False) @@ -26,8 +25,8 @@ class PointRule(Base): class DailyTarget(Base): __tablename__ = "daily_targets" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, unique=True) + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False, unique=True) daily_minimum_pts: Mapped[int] = mapped_column(Integer, default=80) weekly_target_pts: Mapped[int] = mapped_column(Integer, default=500) weekly_bonus_pts: Mapped[int] = mapped_column(Integer, default=50) diff --git a/backend/app/models/profile.py b/diligence/models/profile.py similarity index 81% rename from backend/app/models/profile.py rename to diligence/models/profile.py index fd8ef5a..07bac20 100644 --- a/backend/app/models/profile.py +++ b/diligence/models/profile.py @@ -3,17 +3,16 @@ from __future__ import annotations import uuid from datetime import datetime, timezone from decimal import Decimal -from sqlalchemy import String, Boolean, Integer, Numeric, DateTime, Text, ForeignKey +from sqlalchemy import String, Boolean, Integer, Numeric, DateTime, Text, ForeignKey, Uuid, JSON from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID, JSONB -from app.database import Base +from diligence.database import Base class UserProfile(Base): __tablename__ = "user_profiles" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, unique=True) + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False, unique=True) # Phase 1 primary_goal: Mapped[str | None] = mapped_column(String(50)) @@ -41,9 +40,9 @@ class UserProfile(Base): motivation_rai: Mapped[Decimal | None] = mapped_column(Numeric(5, 1)) # Preferences - activity_preferences: Mapped[dict] = mapped_column(JSONB, default=list) + activity_preferences: Mapped[dict] = mapped_column(JSON, default=list) equipment_access: Mapped[str | None] = mapped_column(String(50)) # legacy — kept for compat - equipment_list: Mapped[dict] = mapped_column(JSONB, default=list) # new: list of equipment strings + equipment_list: Mapped[dict] = mapped_column(JSON, default=list) # new: list of equipment strings days_per_week: Mapped[int] = mapped_column(Integer, default=3) minutes_per_session: Mapped[int] = mapped_column(Integer, default=30) diff --git a/backend/app/models/program.py b/diligence/models/program.py similarity index 75% rename from backend/app/models/program.py rename to diligence/models/program.py index d343faf..174398c 100644 --- a/backend/app/models/program.py +++ b/diligence/models/program.py @@ -2,17 +2,16 @@ from __future__ import annotations import uuid from datetime import datetime, date, timezone -from sqlalchemy import String, Integer, Text, Date, DateTime, ForeignKey +from sqlalchemy import String, Integer, Text, Date, DateTime, ForeignKey, Uuid from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID -from app.database import Base +from diligence.database import Base class Program(Base): __tablename__ = "programs" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False) name: Mapped[str] = mapped_column(String(200), nullable=False) source: Mapped[str | None] = mapped_column(String(50)) source_url: Mapped[str | None] = mapped_column(Text) @@ -24,7 +23,7 @@ class Program(Base): # v2: Link to catalog program catalog_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), ForeignKey("program_catalog.id"), nullable=True + Uuid, ForeignKey("program_catalog.id"), nullable=True ) current_week: Mapped[int] = mapped_column(Integer, default=1) current_day: Mapped[int] = mapped_column(Integer, default=1) diff --git a/backend/app/models/resource.py b/diligence/models/resource.py similarity index 66% rename from backend/app/models/resource.py rename to diligence/models/resource.py index 7a52147..398e3d9 100644 --- a/backend/app/models/resource.py +++ b/diligence/models/resource.py @@ -2,25 +2,24 @@ from __future__ import annotations import uuid from datetime import datetime, timezone -from sqlalchemy import String, Integer, Boolean, Text, DateTime +from sqlalchemy import String, Integer, Boolean, Text, DateTime, Uuid, JSON from sqlalchemy.orm import Mapped, mapped_column -from sqlalchemy.dialects.postgresql import UUID, JSONB -from app.database import Base +from diligence.database import Base class Resource(Base): __tablename__ = "resource_library" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) name: Mapped[str] = mapped_column(String(200), nullable=False) source: Mapped[str] = mapped_column(String(50), nullable=False) url: Mapped[str] = mapped_column(Text, nullable=False) description: Mapped[str | None] = mapped_column(Text) thumbnail_url: Mapped[str | None] = mapped_column(Text) - goal_tags: Mapped[dict] = mapped_column(JSONB, default=list) - activity_tags: Mapped[dict] = mapped_column(JSONB, default=list) + goal_tags: Mapped[dict] = mapped_column(JSON, default=list) + activity_tags: Mapped[dict] = mapped_column(JSON, default=list) equipment_needed: Mapped[str | None] = mapped_column(String(50)) - ttm_stages: Mapped[dict] = mapped_column(JSONB, default=list) + ttm_stages: Mapped[dict] = mapped_column(JSON, default=list) difficulty: Mapped[str | None] = mapped_column(String(20)) duration_days: Mapped[int | None] = mapped_column(Integer) is_active: Mapped[bool] = mapped_column(Boolean, default=True) diff --git a/backend/app/models/reward.py b/diligence/models/reward.py similarity index 65% rename from backend/app/models/reward.py rename to diligence/models/reward.py index bfd2ea6..0ee91b8 100644 --- a/backend/app/models/reward.py +++ b/diligence/models/reward.py @@ -2,17 +2,16 @@ from __future__ import annotations import uuid from datetime import datetime, date, timezone -from sqlalchemy import String, Integer, Boolean, Text, Date, DateTime, ForeignKey, Index +from sqlalchemy import String, Integer, Boolean, Text, Date, DateTime, ForeignKey, Index, Uuid from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID -from app.database import Base +from diligence.database import Base class Reward(Base): __tablename__ = "rewards" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False) name: Mapped[str] = mapped_column(String(200), nullable=False) description: Mapped[str | None] = mapped_column(Text) point_cost: Mapped[int] = mapped_column(Integer, nullable=False) @@ -25,9 +24,9 @@ class Reward(Base): class RewardRedemption(Base): __tablename__ = "reward_redemptions" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) - reward_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("rewards.id"), nullable=False) + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False) + reward_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("rewards.id"), nullable=False) points_spent: Mapped[int] = mapped_column(Integer, nullable=False) redeemed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) redemption_date: Mapped[date] = mapped_column(Date, nullable=False) diff --git a/backend/app/models/support.py b/diligence/models/support.py similarity index 75% rename from backend/app/models/support.py rename to diligence/models/support.py index 9ee52ab..ef61637 100644 --- a/backend/app/models/support.py +++ b/diligence/models/support.py @@ -2,19 +2,18 @@ from __future__ import annotations import uuid from datetime import datetime, timezone -from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, Index, UniqueConstraint +from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, Index, UniqueConstraint, Uuid, JSON from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID, JSONB -from app.database import Base +from diligence.database import Base class SupportThread(Base): """One thread per user — private conversation with admin.""" __tablename__ = "support_threads" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) user_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), ForeignKey("users.id"), unique=True, nullable=False + Uuid, ForeignKey("users.id"), unique=True, nullable=False ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) @@ -35,13 +34,13 @@ class SupportMessage(Base): """Individual message within a support thread.""" __tablename__ = "support_messages" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) thread_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), ForeignKey("support_threads.id", ondelete="CASCADE"), nullable=False + Uuid, ForeignKey("support_threads.id", ondelete="CASCADE"), nullable=False ) sender: Mapped[str] = mapped_column(String(10), nullable=False) # 'user' or 'admin' body: Mapped[str] = mapped_column(Text, nullable=False) - context: Mapped[dict | None] = mapped_column(JSONB) + context: Mapped[dict | None] = mapped_column(JSON) read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) diff --git a/backend/app/models/user.py b/diligence/models/user.py similarity index 79% rename from backend/app/models/user.py rename to diligence/models/user.py index 104eebb..35e0177 100644 --- a/backend/app/models/user.py +++ b/diligence/models/user.py @@ -2,23 +2,22 @@ from __future__ import annotations import uuid from datetime import datetime, timezone -from sqlalchemy import String, Boolean, DateTime +from sqlalchemy import String, Boolean, DateTime, Uuid from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID -from app.database import Base +from diligence.database import Base class User(Base): __tablename__ = "users" - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) display_name: Mapped[str] = mapped_column(String(100), nullable=False) password_hash: Mapped[str] = mapped_column(String(255), nullable=False) email: Mapped[str | None] = mapped_column(String(255), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) is_active: Mapped[bool] = mapped_column(Boolean, default=True) - timezone: Mapped[str] = mapped_column(String(50), default="Asia/Bangkok") + timezone: Mapped[str] = mapped_column(String(50), default="UTC") is_admin: Mapped[bool] = mapped_column(Boolean, default=False) # Relationships diff --git a/backend/app/__init__.py b/diligence/routers/__init__.py similarity index 100% rename from backend/app/__init__.py rename to diligence/routers/__init__.py diff --git a/backend/app/routers/activities.py b/diligence/routers/activities.py similarity index 89% rename from backend/app/routers/activities.py rename to diligence/routers/activities.py index fde5c5a..aec9942 100644 --- a/backend/app/routers/activities.py +++ b/diligence/routers/activities.py @@ -6,12 +6,12 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import select, delete from sqlalchemy.ext.asyncio import AsyncSession -from app.database import get_db -from app.models.user import User -from app.models.activity import ActivityLog -from app.schemas.activity import ActivityCreate, ActivityResponse -from app.utils.auth import get_current_user -from app.services.points_engine import log_activity_with_points +from diligence.database import get_db +from diligence.models.user import User +from diligence.models.activity import ActivityLog +from diligence.schemas.activity import ActivityCreate, ActivityResponse +from diligence.utils.auth import get_current_user +from diligence.services.points_engine import log_activity_with_points router = APIRouter(prefix="/api/activities", tags=["activities"]) diff --git a/diligence/routers/agent.py b/diligence/routers/agent.py new file mode 100644 index 0000000..9510a67 --- /dev/null +++ b/diligence/routers/agent.py @@ -0,0 +1,38 @@ +"""Agent connection endpoint — returns MCP config for AI agent setup.""" +from __future__ import annotations + +from fastapi import APIRouter, Depends +from diligence.config import get_settings +from diligence.utils.auth import get_current_user + +router = APIRouter(prefix="/api/agent", tags=["agent"]) + + +@router.get("/config") +async def agent_config(user=Depends(get_current_user)): + """Return MCP connection details for the authenticated user.""" + settings = get_settings() + + base = settings.base_url.rstrip("/") + + if settings.is_sqlite: + # pip install path — MCP runs on separate port (same machine) + from urllib.parse import urlparse + parsed = urlparse(base) + host = parsed.hostname or "localhost" + mcp_url = f"http://{host}:{settings.mcp_port}/sse" + else: + # Docker path — MCP on separate container port 3001 + from urllib.parse import urlparse + parsed = urlparse(base) + docker_host = parsed.hostname or "localhost" + mcp_url = f"http://{docker_host}:3001/sse" + + return { + "mcp_url": mcp_url, + "api_token": settings.api_token if getattr(user, "is_admin", False) else None, + "api_token_set": bool(settings.api_token), + "deployment": "local" if settings.is_sqlite else "docker", + "base_url": base, + "tools_count": 14, + } diff --git a/backend/app/routers/ai_chat.py b/diligence/routers/ai_chat.py similarity index 92% rename from backend/app/routers/ai_chat.py rename to diligence/routers/ai_chat.py index 1e2bcd8..28a737d 100644 --- a/backend/app/routers/ai_chat.py +++ b/diligence/routers/ai_chat.py @@ -7,10 +7,10 @@ from fastapi import APIRouter, Depends, Request from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession -from app.database import get_db -from app.utils.auth import get_current_user -from app.models.user import User -from app.services import ai_provider +from diligence.database import get_db +from diligence.utils.auth import get_current_user +from diligence.models.user import User +from diligence.services import ai_provider logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/ai", tags=["AI Coaching"]) diff --git a/backend/app/routers/auth.py b/diligence/routers/auth.py similarity index 86% rename from backend/app/routers/auth.py rename to diligence/routers/auth.py index 517998d..a012035 100644 --- a/backend/app/routers/auth.py +++ b/diligence/routers/auth.py @@ -7,12 +7,12 @@ from fastapi.responses import JSONResponse from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession -from app.database import get_db -from app.models.user import User -from app.models.profile import UserProfile -from app.models.points import PointRule, DailyTarget, DEFAULT_POINT_RULES, TTM_DAILY_TARGETS -from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse, UserResponse -from app.utils.auth import hash_password, verify_password, create_access_token, get_current_user +from diligence.database import get_db +from diligence.models.user import User +from diligence.models.profile import UserProfile +from diligence.models.points import PointRule, DailyTarget, DEFAULT_POINT_RULES, TTM_DAILY_TARGETS +from diligence.schemas.auth import LoginRequest, RegisterRequest, TokenResponse, UserResponse +from diligence.utils.auth import hash_password, verify_password, create_access_token, get_current_user logger = logging.getLogger("fitness-rewards") router = APIRouter(prefix="/api/auth", tags=["auth"]) @@ -34,6 +34,7 @@ async def register(req: RegisterRequest, db: Annotated[AsyncSession, Depends(get display_name=req.display_name, password_hash=hash_password(req.password), email=req.email, + timezone=req.timezone or "UTC", is_admin=is_first_user, ) db.add(user) diff --git a/backend/app/routers/catalog.py b/diligence/routers/catalog.py similarity index 96% rename from backend/app/routers/catalog.py rename to diligence/routers/catalog.py index a1b5fe1..ce78a2a 100644 --- a/backend/app/routers/catalog.py +++ b/diligence/routers/catalog.py @@ -11,13 +11,14 @@ from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.database import get_db -from app.models.user import User -from app.models.program import Program -from app.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue, WorkoutLog -from app.services.program_research import slugify, find_urls_for_program -from app.services.points_engine import log_activity_with_points -from app.utils.auth import get_current_user +from diligence.database import get_db +from diligence.models.user import User +from diligence.models.program import Program +from diligence.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue, WorkoutLog +from diligence.services.program_research import slugify, find_urls_for_program +from diligence.services.points_engine import log_activity_with_points +from diligence.utils.auth import get_current_user +from diligence.utils.dates import today_for_user router = APIRouter(prefix="/api/programs", tags=["programs-v2"]) @@ -337,7 +338,7 @@ async def get_program_schedule( ) # Calculate current real week from start date - today = date.today() + today = today_for_user(user.timezone) days_elapsed = max(0, (today - program.start_date).days) current_week = min(total_weeks, (days_elapsed // 7) + 1) @@ -491,7 +492,7 @@ async def complete_workout( db=db, user_id=user.id, category="workout", - activity_date=date.today(), + activity_date=today_for_user(user.timezone), title=f"{program.name}: Wk{real_week} {workout.workout_name or f'Day {workout.day_number}'}", description=f"Completed program workout ({len(workout.exercises)} exercises)", source="program", @@ -556,7 +557,7 @@ async def check_weekly_bonus( db=db, user_id=user.id, category="bonus", - activity_date=date.today(), + activity_date=today_for_user(user.timezone), title=f"{program.name}: Week {real_week} Complete!", source="program", program_id=program.id, @@ -599,7 +600,7 @@ async def check_completion_bonus( db=db, user_id=user.id, category="bonus", - activity_date=date.today(), + activity_date=today_for_user(user.timezone), title=f"{program.name}: Program Complete!", source="program", program_id=program.id, @@ -654,7 +655,7 @@ async def get_program_progress( ) total_points = result.scalar() or 0 - today = date.today() + today = today_for_user(user.timezone) days_elapsed = max(0, (today - program.start_date).days) current_week = min(total_weeks, (days_elapsed // 7) + 1) diff --git a/backend/app/routers/food.py b/diligence/routers/food.py similarity index 88% rename from backend/app/routers/food.py rename to diligence/routers/food.py index 3ec4fda..b0170d8 100644 --- a/backend/app/routers/food.py +++ b/diligence/routers/food.py @@ -7,12 +7,13 @@ from fastapi import APIRouter, Depends, Query, HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.database import get_db -from app.models.user import User -from app.models.food import FoodLog -from app.schemas.food import FoodCreate, FoodSearchResult -from app.utils.auth import get_current_user -from app.services.food_lookup import lookup_barcode, search_food +from diligence.database import get_db +from diligence.models.user import User +from diligence.models.food import FoodLog +from diligence.schemas.food import FoodCreate, FoodSearchResult +from diligence.utils.auth import get_current_user +from diligence.utils.dates import today_for_user +from diligence.services.food_lookup import lookup_barcode, search_food router = APIRouter(prefix="/api/food", tags=["food"]) @@ -49,7 +50,7 @@ async def list_food( if item.calories: total_cals += float(item.calories) * float(item.servings or 1) - return {"date": (food_date or date.today()).isoformat(), "meals": meals, "total_calories": round(total_cals, 1)} + return {"date": (food_date or today_for_user(user.timezone)).isoformat(), "meals": meals, "total_calories": round(total_cals, 1)} @router.post("") diff --git a/backend/app/routers/integrations.py b/diligence/routers/integrations.py similarity index 92% rename from backend/app/routers/integrations.py rename to diligence/routers/integrations.py index 0aa52a5..5b705a2 100644 --- a/backend/app/routers/integrations.py +++ b/diligence/routers/integrations.py @@ -8,14 +8,14 @@ from fastapi.responses import RedirectResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.database import get_db -from app.models.user import User -from app.models.oauth import OAuthToken -from app.utils.auth import get_current_user -from app.services.strava_sync import ( +from diligence.database import get_db +from diligence.models.user import User +from diligence.models.oauth import OAuthToken +from diligence.utils.auth import get_current_user +from diligence.services.strava_sync import ( get_strava_auth_url, exchange_strava_code, sync_strava_activities, ) -from app.services.polar_sync import ( +from diligence.services.polar_sync import ( get_polar_auth_url, exchange_polar_code, register_polar_user, sync_polar_activities, ) @@ -39,7 +39,7 @@ async def integration_status( # --- Strava --- @router.get("/strava/auth") async def strava_auth(user: Annotated[User, Depends(get_current_user)]): - from app.utils.auth import create_access_token + from diligence.utils.auth import create_access_token from datetime import timedelta state_token = create_access_token(str(user.id), expires_delta=timedelta(minutes=10)) return {"auth_url": get_strava_auth_url(state_token)} @@ -52,7 +52,7 @@ async def strava_callback( db: AsyncSession = Depends(get_db), ): from jose import JWTError, jwt as jose_jwt - from app.config import get_settings + from diligence.config import get_settings _settings = get_settings() try: payload = jose_jwt.decode(state, _settings.secret_key, algorithms=["HS256"]) @@ -85,7 +85,7 @@ async def strava_callback( db.add(token) await db.flush() - from app.config import get_settings + from diligence.config import get_settings return RedirectResponse(url=f"{get_settings().base_url}/settings/integrations?strava=connected") @@ -101,7 +101,7 @@ async def strava_sync( # --- Polar --- @router.get("/polar/auth") async def polar_auth(user: Annotated[User, Depends(get_current_user)]): - from app.utils.auth import create_access_token + from diligence.utils.auth import create_access_token from datetime import timedelta state_token = create_access_token(str(user.id), expires_delta=timedelta(minutes=10)) return {"auth_url": get_polar_auth_url(state_token)} @@ -114,7 +114,7 @@ async def polar_callback( db: AsyncSession = Depends(get_db), ): from jose import JWTError, jwt as jose_jwt - from app.config import get_settings + from diligence.config import get_settings _settings = get_settings() try: payload = jose_jwt.decode(state, _settings.secret_key, algorithms=["HS256"]) @@ -144,7 +144,7 @@ async def polar_callback( db.add(token) await db.flush() - from app.config import get_settings + from diligence.config import get_settings return RedirectResponse(url=f"{get_settings().base_url}/settings/integrations?polar=connected") @@ -178,10 +178,10 @@ async def disconnect( # --- Dynamic Integration Config (v3) --- from pydantic import BaseModel -from app.models.integration_config import IntegrationConfig -from app.services.provider_registry import PROVIDER_REGISTRY -from app.services.crypto import encrypt_value, decrypt_value -from app.config import settings +from diligence.models.integration_config import IntegrationConfig +from diligence.services.provider_registry import PROVIDER_REGISTRY +from diligence.services.crypto import encrypt_value, decrypt_value +from diligence.config import settings class ConfigureRequest(BaseModel): diff --git a/backend/app/routers/meal_plans.py b/diligence/routers/meal_plans.py similarity index 94% rename from backend/app/routers/meal_plans.py rename to diligence/routers/meal_plans.py index c89fe65..24b7abe 100644 --- a/backend/app/routers/meal_plans.py +++ b/diligence/routers/meal_plans.py @@ -10,10 +10,11 @@ from pydantic import BaseModel from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession -from app.database import get_db -from app.models.user import User -from app.models.meal_plan import MealPlan, MealPlanItem, MealCompliance -from app.utils.auth import get_current_user +from diligence.database import get_db +from diligence.models.user import User +from diligence.models.meal_plan import MealPlan, MealPlanItem, MealCompliance +from diligence.utils.auth import get_current_user +from diligence.utils.dates import today_for_user router = APIRouter(prefix="/api/meal-plans", tags=["meal-plans"]) @@ -94,7 +95,7 @@ async def create_meal_plan( daily_fat_g=body.daily_fat_g, restrictions=body.restrictions, duration_days=body.duration_days, - start_date=body.start_date or date.today(), + start_date=body.start_date or today_for_user(user.timezone), ) db.add(plan) await db.flush() @@ -131,7 +132,7 @@ async def get_today_meals( if not plan: return {"active_plan": None} - day_num = (date.today() - plan.start_date).days + 1 + day_num = (today_for_user(user.timezone) - plan.start_date).days + 1 if day_num < 1 or day_num > plan.duration_days: return {"active_plan": plan.name, "day": day_num, "meals": [], "message": "No meals planned for today"} @@ -217,7 +218,7 @@ async def log_compliance( user_id=user.id, plan_id=plan.id, plan_item_id=uuid_mod.UUID(body.plan_item_id) if body.plan_item_id else None, - compliance_date=body.compliance_date or date.today(), + compliance_date=body.compliance_date or today_for_user(user.timezone), status=body.status, substitution=body.substitution, ) @@ -250,7 +251,7 @@ async def get_plan_progress( substituted = sum(1 for e in entries if e.status == "substituted") skipped = sum(1 for e in entries if e.status == "skipped") - days_elapsed = (date.today() - plan.start_date).days + 1 + days_elapsed = (today_for_user(user.timezone) - plan.start_date).days + 1 compliance_pct = (followed + substituted) / total * 100 if total > 0 else 0 return { diff --git a/backend/app/routers/nutrition.py b/diligence/routers/nutrition.py similarity index 94% rename from backend/app/routers/nutrition.py rename to diligence/routers/nutrition.py index 0694741..8b53777 100644 --- a/backend/app/routers/nutrition.py +++ b/diligence/routers/nutrition.py @@ -10,15 +10,16 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import select, func, and_ from sqlalchemy.ext.asyncio import AsyncSession -from app.database import get_db -from app.models.user import User -from app.models.nutrition import NutritionGoal, Fast, ElectrolyteLog -from app.models.food import FoodLog -from app.schemas.nutrition import ( +from diligence.database import get_db +from diligence.models.user import User +from diligence.models.nutrition import NutritionGoal, Fast, ElectrolyteLog +from diligence.models.food import FoodLog +from diligence.schemas.nutrition import ( NutritionGoalIn, NutritionGoalOut, FastStart, FastUpdate, FastOut, ElectrolyteIn, ) -from app.utils.auth import get_current_user -from app.services.points_engine import log_activity_with_points +from diligence.utils.auth import get_current_user +from diligence.services.points_engine import log_activity_with_points +from diligence.utils.dates import today_for_user, now_for_user, day_start_utc router = APIRouter(prefix="/api/nutrition", tags=["nutrition"]) @@ -87,7 +88,7 @@ async def get_today( ): """Today's macros vs target, eating-window state, active fast, compliance.""" goal = await _get_or_create_goal(db, user.id) - today = date.today() + today = today_for_user(user.timezone) # Sum today's food result = await db.execute( @@ -103,8 +104,8 @@ async def get_today( cals, prot, carbs, fat, fiber = float(cals), float(prot), float(carbs), float(fat), float(fiber) net_carbs = max(0.0, carbs - fiber) - # Eating window (local time, using goal timezone_str — simplified: assume server in UTC, user sends local) - now_local = datetime.now(timezone.utc) + timedelta(hours=7) # Asia/Bangkok offset hack + # Eating window (user's local time) + now_local = now_for_user(user.timezone) hour = now_local.hour in_window = goal.eating_window_start_hour <= hour < goal.eating_window_end_hour window_str = f"{goal.eating_window_start_hour:02d}:00–{goal.eating_window_end_hour:02d}:00" @@ -352,7 +353,7 @@ async def get_electrolytes_today( user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)], ): - today_start = datetime.combine(date.today(), datetime.min.time()).replace(tzinfo=timezone.utc) + today_start = day_start_utc(user.timezone) result = await db.execute( select( func.coalesce(func.sum(ElectrolyteLog.sodium_mg), 0), diff --git a/backend/app/routers/onboarding.py b/diligence/routers/onboarding.py similarity index 92% rename from backend/app/routers/onboarding.py rename to diligence/routers/onboarding.py index 79abeb6..487a445 100644 --- a/backend/app/routers/onboarding.py +++ b/diligence/routers/onboarding.py @@ -6,13 +6,13 @@ from fastapi import APIRouter, Depends from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.database import get_db -from app.models.user import User -from app.models.profile import UserProfile -from app.models.points import DailyTarget, TTM_DAILY_TARGETS -from app.schemas.onboarding import Phase1Request, Phase2Request, OnboardingStatusResponse -from app.utils.auth import get_current_user -from app.services.resource_matcher import get_recommendations +from diligence.database import get_db +from diligence.models.user import User +from diligence.models.profile import UserProfile +from diligence.models.points import DailyTarget, TTM_DAILY_TARGETS +from diligence.schemas.onboarding import Phase1Request, Phase2Request, OnboardingStatusResponse +from diligence.utils.auth import get_current_user +from diligence.services.resource_matcher import get_recommendations router = APIRouter(prefix="/api/onboarding", tags=["onboarding"]) diff --git a/backend/app/routers/points.py b/diligence/routers/points.py similarity index 86% rename from backend/app/routers/points.py rename to diligence/routers/points.py index 76c6b32..8aae6a1 100644 --- a/backend/app/routers/points.py +++ b/diligence/routers/points.py @@ -7,14 +7,15 @@ from fastapi import APIRouter, Depends, Query, HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.database import get_db -from app.models.user import User -from app.models.points import PointRule, DailyTarget -from app.models.reward import Reward -from app.schemas.points import PointRuleUpdate, DailyTargetUpdate -from app.schemas.reward import RewardCreate, RewardRedeemRequest -from app.utils.auth import get_current_user -from app.services.points_engine import get_today_status, get_weekly_summary, redeem_reward +from diligence.database import get_db +from diligence.models.user import User +from diligence.models.points import PointRule, DailyTarget +from diligence.models.reward import Reward +from diligence.schemas.points import PointRuleUpdate, DailyTargetUpdate +from diligence.schemas.reward import RewardCreate, RewardRedeemRequest +from diligence.utils.auth import get_current_user +from diligence.utils.dates import today_for_user +from diligence.services.points_engine import get_today_status, get_weekly_summary, redeem_reward router = APIRouter(prefix="/api/points", tags=["points"]) @@ -24,7 +25,7 @@ async def today_status( user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)], ): - return await get_today_status(db, user.id) + return await get_today_status(db, user.id, tz_str=user.timezone) @router.get("/week") @@ -33,7 +34,7 @@ async def week_summary( db: Annotated[AsyncSession, Depends(get_db)], start: date | None = None, ): - d = start or date.today() + d = start or today_for_user(user.timezone) return await get_weekly_summary(db, user.id, d) @@ -143,7 +144,7 @@ async def redeem( user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)], ): - result = await redeem_reward(db, user.id, uuid_mod.UUID(reward_id), req.redemption_date) + result = await redeem_reward(db, user.id, uuid_mod.UUID(reward_id), req.redemption_date, tz_str=user.timezone) if not result["success"]: raise HTTPException(status_code=400, detail=result["reason"]) return result diff --git a/backend/app/routers/programs.py b/diligence/routers/programs.py similarity index 91% rename from backend/app/routers/programs.py rename to diligence/routers/programs.py index 2f42359..c425802 100644 --- a/backend/app/routers/programs.py +++ b/diligence/routers/programs.py @@ -7,11 +7,12 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession -from app.database import get_db -from app.models.user import User -from app.models.program import Program -from app.models.activity import ActivityLog -from app.utils.auth import get_current_user +from diligence.database import get_db +from diligence.models.user import User +from diligence.models.program import Program +from diligence.models.activity import ActivityLog +from diligence.utils.auth import get_current_user +from diligence.utils.dates import today_for_user from pydantic import BaseModel router = APIRouter(prefix="/api/programs", tags=["programs"]) @@ -37,7 +38,7 @@ async def list_programs( programs = result.scalars().all() out = [] for p in programs: - today = date.today() + today = today_for_user(user.timezone) day_num = (today - p.start_date).days + 1 total = (p.end_date - p.start_date).days + 1 out.append({ @@ -90,7 +91,7 @@ async def get_program( ) logged = days_logged.scalar() or 0 - today = date.today() + today = today_for_user(user.timezone) day_num = (today - p.start_date).days + 1 total = (p.end_date - p.start_date).days + 1 diff --git a/backend/app/routers/support.py b/diligence/routers/support.py similarity index 95% rename from backend/app/routers/support.py rename to diligence/routers/support.py index c8cb767..a1efb63 100644 --- a/backend/app/routers/support.py +++ b/diligence/routers/support.py @@ -13,13 +13,14 @@ from sqlalchemy import select, func, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.database import get_db -from app.models.user import User -from app.models.support import SupportThread, SupportMessage -from app.models.activity import ActivityLog -from app.services.points_engine import get_active_program, get_today_status -from app.utils.auth import get_current_user -from app.config import settings +from diligence.database import get_db +from diligence.models.user import User +from diligence.models.support import SupportThread, SupportMessage +from diligence.models.activity import ActivityLog +from diligence.services.points_engine import get_active_program, get_today_status +from diligence.utils.auth import get_current_user +from diligence.utils.dates import day_start_utc +from diligence.config import settings logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/support", tags=["support"]) @@ -45,7 +46,7 @@ async def gather_user_context(db: AsyncSession, user: User) -> dict: } try: - program = await get_active_program(db, user.id) + program = await get_active_program(db, user.id, tz_str=user.timezone) if program: context["program_name"] = program.get("name") context["program_day"] = program.get("day") @@ -57,7 +58,7 @@ async def gather_user_context(db: AsyncSession, user: User) -> dict: pass try: - status = await get_today_status(db, user.id) + status = await get_today_status(db, user.id, tz_str=user.timezone) context["points_today"] = status.get("points_earned", 0) context["daily_target"] = status.get("daily_minimum", 80) context["gate_passed"] = status.get("gate_passed", False) @@ -219,7 +220,7 @@ async def send_message( raise HTTPException(status_code=400, detail="Message too long (max 2000 characters)") # Rate limit check - today_start = datetime.combine(date.today(), datetime.min.time(), tzinfo=timezone.utc) + today_start = day_start_utc(user.timezone) thread = await get_or_create_thread(db, user.id) result = await db.execute( @@ -301,7 +302,7 @@ async def list_threads( out = [] for t in threads: # Get the user's display name - from app.models.user import User as UserModel + from diligence.models.user import User as UserModel user_result = await db.execute( select(UserModel).where(UserModel.id == t.user_id) ) @@ -360,7 +361,7 @@ async def get_admin_thread( await db.flush() # Get user info - from app.models.user import User as UserModel + from diligence.models.user import User as UserModel user_result = await db.execute( select(UserModel).where(UserModel.id == thread.user_id) ) diff --git a/backend/app/routers/__init__.py b/diligence/schemas/__init__.py similarity index 100% rename from backend/app/routers/__init__.py rename to diligence/schemas/__init__.py diff --git a/backend/app/schemas/activity.py b/diligence/schemas/activity.py similarity index 100% rename from backend/app/schemas/activity.py rename to diligence/schemas/activity.py diff --git a/backend/app/schemas/auth.py b/diligence/schemas/auth.py similarity index 95% rename from backend/app/schemas/auth.py rename to diligence/schemas/auth.py index 9d2b238..5d59299 100644 --- a/backend/app/schemas/auth.py +++ b/diligence/schemas/auth.py @@ -13,6 +13,7 @@ class RegisterRequest(BaseModel): password: str = Field(min_length=8, max_length=128) display_name: str = Field(min_length=1, max_length=100) email: str | None = None + timezone: str | None = None class TokenResponse(BaseModel): diff --git a/backend/app/schemas/food.py b/diligence/schemas/food.py similarity index 100% rename from backend/app/schemas/food.py rename to diligence/schemas/food.py diff --git a/backend/app/schemas/nutrition.py b/diligence/schemas/nutrition.py similarity index 100% rename from backend/app/schemas/nutrition.py rename to diligence/schemas/nutrition.py diff --git a/backend/app/schemas/onboarding.py b/diligence/schemas/onboarding.py similarity index 100% rename from backend/app/schemas/onboarding.py rename to diligence/schemas/onboarding.py diff --git a/backend/app/schemas/points.py b/diligence/schemas/points.py similarity index 100% rename from backend/app/schemas/points.py rename to diligence/schemas/points.py diff --git a/backend/app/schemas/reward.py b/diligence/schemas/reward.py similarity index 100% rename from backend/app/schemas/reward.py rename to diligence/schemas/reward.py diff --git a/backend/app/schemas/__init__.py b/diligence/services/__init__.py similarity index 100% rename from backend/app/schemas/__init__.py rename to diligence/services/__init__.py diff --git a/backend/app/services/ai_provider.py b/diligence/services/ai_provider.py similarity index 97% rename from backend/app/services/ai_provider.py rename to diligence/services/ai_provider.py index ba6d160..ad096f1 100644 --- a/backend/app/services/ai_provider.py +++ b/diligence/services/ai_provider.py @@ -18,9 +18,9 @@ import httpx from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.config import get_settings -from app.services.crypto import decrypt_value -from app.models.integration_config import IntegrationConfig +from diligence.config import get_settings +from diligence.services.crypto import decrypt_value +from diligence.models.integration_config import IntegrationConfig logger = logging.getLogger(__name__) settings = get_settings() @@ -101,8 +101,8 @@ async def get_active_ai_provider(db: AsyncSession, user_id=None) -> dict | None: async def build_context(db: AsyncSession, user_id) -> str: """Build live context string from user's current state.""" - from app.models.user import User - from app.models.profile import UserProfile + from diligence.models.user import User + from diligence.models.profile import UserProfile context_parts = [] @@ -122,7 +122,7 @@ async def build_context(db: AsyncSession, user_id) -> str: # Today's points (best effort) try: - from app.services.points_engine import get_daily_summary + from diligence.services.points_engine import get_daily_summary summary = await get_daily_summary(db, user_id) context_parts.append(f"Today's points: {summary.get('earned', 0)}/{summary.get('target', 100)}") context_parts.append(f"Daily gate: {'PASSED' if summary.get('gate_passed') else 'NOT YET'}") diff --git a/backend/app/services/crawl_scheduler.py b/diligence/services/crawl_scheduler.py similarity index 94% rename from backend/app/services/crawl_scheduler.py rename to diligence/services/crawl_scheduler.py index a2a56ed..27c721a 100644 --- a/backend/app/services/crawl_scheduler.py +++ b/diligence/services/crawl_scheduler.py @@ -8,9 +8,9 @@ from datetime import datetime, timezone from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession -from app.database import async_session -from app.models.catalog import CrawlQueue -from app.services.program_research import process_crawl_job +from diligence.database import async_session +from diligence.models.catalog import CrawlQueue +from diligence.services.program_research import process_crawl_job logger = logging.getLogger(__name__) diff --git a/backend/app/services/crypto.py b/diligence/services/crypto.py similarity index 100% rename from backend/app/services/crypto.py rename to diligence/services/crypto.py diff --git a/backend/app/services/device_sync_base.py b/diligence/services/device_sync_base.py similarity index 94% rename from backend/app/services/device_sync_base.py rename to diligence/services/device_sync_base.py index b88bd25..7692ab9 100644 --- a/backend/app/services/device_sync_base.py +++ b/diligence/services/device_sync_base.py @@ -17,11 +17,11 @@ import httpx from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.config import get_settings -from app.models.oauth import OAuthToken -from app.models.integration_config import IntegrationConfig -from app.services.crypto import decrypt_value -from app.services.points_engine import log_activity_with_points +from diligence.config import get_settings +from diligence.models.oauth import OAuthToken +from diligence.models.integration_config import IntegrationConfig +from diligence.services.crypto import decrypt_value +from diligence.services.points_engine import log_activity_with_points logger = logging.getLogger(__name__) settings = get_settings() @@ -146,7 +146,7 @@ class DeviceSyncBase(abc.ABC): Deduplicates by (user_id, provider, external_id). """ - from app.models.activity import ActivityLog + from diligence.models.activity import ActivityLog if external_id: existing = await db.execute( diff --git a/backend/app/services/food_lookup.py b/diligence/services/food_lookup.py similarity index 98% rename from backend/app/services/food_lookup.py rename to diligence/services/food_lookup.py index e499945..9d3221a 100644 --- a/backend/app/services/food_lookup.py +++ b/diligence/services/food_lookup.py @@ -2,7 +2,7 @@ from __future__ import annotations """Open Food Facts API client for barcode scanning and food search.""" import httpx -from app.schemas.food import FoodSearchResult +from diligence.schemas.food import FoodSearchResult OFF_BASE = "https://world.openfoodfacts.org" USER_AGENT = "Diligence/1.0" diff --git a/backend/app/services/points_engine.py b/diligence/services/points_engine.py similarity index 94% rename from backend/app/services/points_engine.py rename to diligence/services/points_engine.py index 07acacc..71675de 100644 --- a/backend/app/services/points_engine.py +++ b/diligence/services/points_engine.py @@ -6,11 +6,11 @@ from datetime import date, timedelta from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession -from app.models.activity import ActivityLog -from app.models.points import PointRule, DailyTarget -from app.models.reward import Reward, RewardRedemption -from app.models.program import Program -from app.utils.dates import get_week_boundaries +from diligence.models.activity import ActivityLog +from diligence.models.points import PointRule, DailyTarget +from diligence.models.reward import Reward, RewardRedemption +from diligence.models.program import Program +from diligence.utils.dates import get_week_boundaries async def get_daily_points_earned(db: AsyncSession, user_id: uuid.UUID, d: date) -> int: @@ -81,7 +81,7 @@ async def get_available_rewards(db: AsyncSession, user_id: uuid.UUID) -> list[di ] -async def get_active_program(db: AsyncSession, user_id: uuid.UUID) -> dict | None: +async def get_active_program(db: AsyncSession, user_id: uuid.UUID, tz_str: str = "UTC") -> dict | None: result = await db.execute( select(Program) .where(Program.user_id == user_id, Program.status == "active") @@ -90,7 +90,7 @@ async def get_active_program(db: AsyncSession, user_id: uuid.UUID) -> dict | Non program = result.scalar_one_or_none() if not program: return None - today = date.today() + today = today_for_user(tz_str) day_num = (today - program.start_date).days + 1 total = (program.end_date - program.start_date).days + 1 return { @@ -104,8 +104,8 @@ async def get_active_program(db: AsyncSession, user_id: uuid.UUID) -> dict | Non } -async def get_today_status(db: AsyncSession, user_id: uuid.UUID) -> dict: - today = date.today() +async def get_today_status(db: AsyncSession, user_id: uuid.UUID, tz_str: str = "UTC") -> dict: + today = today_for_user(tz_str) earned = await get_daily_points_earned(db, user_id, today) target = await get_daily_target(db, user_id) daily_min = target.daily_minimum_pts if target else 80 @@ -189,9 +189,9 @@ async def log_activity_with_points( async def redeem_reward( db: AsyncSession, user_id: uuid.UUID, reward_id: uuid.UUID, redemption_date: date | None = None -) -> dict: +, tz_str: str = "UTC") -> dict: """Attempt to redeem a reward. Returns success/failure with details.""" - d = redemption_date or date.today() + d = redemption_date or today_for_user(tz_str) earned = await get_daily_points_earned(db, user_id, d) target = await get_daily_target(db, user_id) daily_min = target.daily_minimum_pts if target else 80 diff --git a/backend/app/services/polar_sync.py b/diligence/services/polar_sync.py similarity index 94% rename from backend/app/services/polar_sync.py rename to diligence/services/polar_sync.py index a63c856..0cac2e2 100644 --- a/backend/app/services/polar_sync.py +++ b/diligence/services/polar_sync.py @@ -3,14 +3,15 @@ from __future__ import annotations """Polar AccessLink API client for activity sync.""" import uuid from datetime import datetime, timezone +from diligence.utils.dates import today_for_user import httpx from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.config import get_settings -from app.models.oauth import OAuthToken -from app.models.activity import ActivityLog -from app.services.points_engine import log_activity_with_points +from diligence.config import get_settings +from diligence.models.oauth import OAuthToken +from diligence.models.activity import ActivityLog +from diligence.services.points_engine import log_activity_with_points settings = get_settings() @@ -108,7 +109,7 @@ async def sync_polar_activities(db: AsyncSession, user_id: uuid.UUID) -> list[di activity_date = datetime.fromisoformat(start).date() except (ValueError, TypeError): from datetime import date - activity_date = date.today() + activity_date = today_for_user() # Parse duration ISO 8601 (e.g., PT1H30M) duration_str = ex.get("duration", "") diff --git a/backend/app/services/program_research.py b/diligence/services/program_research.py similarity index 98% rename from backend/app/services/program_research.py rename to diligence/services/program_research.py index d24bcd0..e1ab220 100644 --- a/backend/app/services/program_research.py +++ b/diligence/services/program_research.py @@ -12,8 +12,8 @@ import httpx from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue -from app.config import settings +from diligence.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue +from diligence.config import settings # ── Known program sources (skip search for these) ────────────────────────── diff --git a/backend/app/services/provider_registry.py b/diligence/services/provider_registry.py similarity index 100% rename from backend/app/services/provider_registry.py rename to diligence/services/provider_registry.py diff --git a/backend/app/services/resource_matcher.py b/diligence/services/resource_matcher.py similarity index 96% rename from backend/app/services/resource_matcher.py rename to diligence/services/resource_matcher.py index b55d1b0..221eec7 100644 --- a/backend/app/services/resource_matcher.py +++ b/diligence/services/resource_matcher.py @@ -4,8 +4,8 @@ from __future__ import annotations import uuid from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.models.resource import Resource -from app.models.profile import UserProfile +from diligence.models.resource import Resource +from diligence.models.profile import UserProfile # Equipment categories that satisfy each resource requirement diff --git a/backend/app/services/strava_sync.py b/diligence/services/strava_sync.py similarity index 96% rename from backend/app/services/strava_sync.py rename to diligence/services/strava_sync.py index 2f509ce..a4937bb 100644 --- a/backend/app/services/strava_sync.py +++ b/diligence/services/strava_sync.py @@ -7,10 +7,10 @@ import httpx from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.config import get_settings -from app.models.oauth import OAuthToken -from app.models.activity import ActivityLog -from app.services.points_engine import log_activity_with_points +from diligence.config import get_settings +from diligence.models.oauth import OAuthToken +from diligence.models.activity import ActivityLog +from diligence.services.points_engine import log_activity_with_points settings = get_settings() diff --git a/backend/app/services/usda_lookup.py b/diligence/services/usda_lookup.py similarity index 100% rename from backend/app/services/usda_lookup.py rename to diligence/services/usda_lookup.py diff --git a/backend/app/services/__init__.py b/diligence/utils/__init__.py similarity index 100% rename from backend/app/services/__init__.py rename to diligence/utils/__init__.py diff --git a/backend/app/utils/auth.py b/diligence/utils/auth.py similarity index 95% rename from backend/app/utils/auth.py rename to diligence/utils/auth.py index 65bcbf8..6380a2b 100644 --- a/backend/app/utils/auth.py +++ b/diligence/utils/auth.py @@ -9,8 +9,8 @@ from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.config import get_settings -from app.database import get_db +from diligence.config import get_settings +from diligence.database import get_db settings = get_settings() ALGORITHM = "HS256" # Hardcoded — not configurable for security @@ -44,7 +44,7 @@ async def get_current_user( if not token: raise credentials_exception - from app.models.user import User + from diligence.models.user import User # Check if this is an API token (MCP connector auth) if settings.api_token and token == settings.api_token: diff --git a/diligence/utils/dates.py b/diligence/utils/dates.py new file mode 100644 index 0000000..65b0ab9 --- /dev/null +++ b/diligence/utils/dates.py @@ -0,0 +1,42 @@ +"""Timezone-aware date helpers. + +All date calculations should use these helpers instead of date.today() +so the result reflects the user's local date, not the server's. +""" +from __future__ import annotations + +from datetime import date, datetime, timezone +from zoneinfo import ZoneInfo + + +def today_for_user(tz_str: str = "UTC") -> date: + """Return today's date in the user's timezone.""" + try: + tz = ZoneInfo(tz_str) + except (KeyError, Exception): + tz = ZoneInfo("UTC") + return datetime.now(tz).date() + + +def now_for_user(tz_str: str = "UTC") -> datetime: + """Return the current datetime in the user's timezone (tz-aware).""" + try: + tz = ZoneInfo(tz_str) + except (KeyError, Exception): + tz = ZoneInfo("UTC") + return datetime.now(tz) + + +def day_start_utc(tz_str: str = "UTC") -> datetime: + """Return the start of today (midnight) in the user's timezone, converted to UTC. + + Useful for database queries that store timestamps in UTC but need + to filter by the user's local day. + """ + user_today = today_for_user(tz_str) + try: + tz = ZoneInfo(tz_str) + except (KeyError, Exception): + tz = ZoneInfo("UTC") + local_midnight = datetime(user_today.year, user_today.month, user_today.day, tzinfo=tz) + return local_midnight.astimezone(ZoneInfo("UTC")) diff --git a/docker-compose.yml b/docker-compose.yml index 54633e6..e39adf8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,10 +15,13 @@ services: start_period: 10s backend: - build: ./backend + build: + context: . + dockerfile: backend/Dockerfile restart: unless-stopped env_file: .env environment: + - DATABASE_URL=postgresql+asyncpg://fitness@fitness-db:5432/fitness_rewards - BASE_URL=${BASE_URL:-http://localhost} - API_TOKEN=${API_TOKEN:-} depends_on: diff --git a/frontend/index.html b/frontend/index.html index deb9f27..7bab415 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -6,7 +6,7 @@ - Fitness Rewards + Diligence
diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..d839ad8 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1865 @@ +{ + "name": "fitness-rewards-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fitness-rewards-frontend", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@vitejs/plugin-react": "^4.3.4", + "vite": "^6.0.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.41", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz", + "integrity": "sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001800", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", + "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.387", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz", + "integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5ca3fe2..8f2f68e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -19,6 +19,7 @@ import CatalogDetail from './pages/CatalogDetail' import Support from './pages/Support' import SupportAdmin from './pages/SupportAdmin' import ChatCoach from './pages/ChatCoach' +import AgentConnect from './pages/AgentConnect' function ProtectedRoute({ children }) { if (!hasToken()) return @@ -95,6 +96,9 @@ function NavBar() { `nav-item ${isActive ? 'active' : ''}`}> 🧠 Coach + `nav-item ${isActive ? 'active' : ''}`}> + 🔌 Agent + ) } @@ -123,6 +127,7 @@ export default function App() { } /> } /> } /> + } /> {hasToken() && } diff --git a/frontend/src/api.js b/frontend/src/api.js index c027719..1cfd383 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -30,7 +30,7 @@ export const api = { login: (username, password) => request('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }), register: (username, password, display_name) => - request('/auth/register', { method: 'POST', body: JSON.stringify({ username, password, display_name }) }), + request('/auth/register', { method: 'POST', body: JSON.stringify({ username, password, display_name, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }) }), me: () => request('/auth/me'), // Onboarding @@ -145,6 +145,9 @@ export const api = { // Resources getResourceRecommendations: () => request('/onboarding/recommendations'), + + // Agent + agentConfig: () => request('/agent/config'), }; export function setToken(token) { diff --git a/frontend/src/index.css b/frontend/src/index.css index 5cd6aae..a055155 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -41,6 +41,12 @@ --r-lg: 12px; --r-full: 999px; + /* Backwards-compat aliases */ + --surface: var(--card); + --surface-2: var(--card); + --border: var(--card-border); + --r-md: var(--r); + --font: 'Instrument Sans', -apple-system, sans-serif; --font-display: 'Instrument Sans', -apple-system, sans-serif; --font-mono: 'IBM Plex Mono', monospace; @@ -233,7 +239,7 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4 font-size: 0.62rem; font-weight: 700; letter-spacing: 0.03em; - padding: 6px 14px; + padding: 6px 10px; text-decoration: none; transition: color 0.15s; border-radius: var(--r-sm); diff --git a/frontend/src/pages/AgentConnect.jsx b/frontend/src/pages/AgentConnect.jsx new file mode 100644 index 0000000..d6de371 --- /dev/null +++ b/frontend/src/pages/AgentConnect.jsx @@ -0,0 +1,208 @@ +import { useState, useEffect } from 'react' +import { api } from '../api' + +function CopyButton({ text, label }) { + const [copied, setCopied] = useState(false) + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { /* fallback for non-HTTPS */ } + } + return ( + + ) +} + +function CodeBlock({ children, copyText }) { + return ( +
+ {copyText && ( +
+ +
+ )} + {children} +
+ ) +} + +export default function AgentConnect() { + const [config, setConfig] = useState(null) + const [loading, setLoading] = useState(true) + const [showToken, setShowToken] = useState(false) + + useEffect(() => { + api.agentConfig() + .then(setConfig) + .catch(console.error) + .finally(() => setLoading(false)) + }, []) + + if (loading) return
Loading...
+ if (!config) return
Failed to load agent config
+ + const maskedToken = config.api_token + ? config.api_token.slice(0, 8) + '\u2026' + config.api_token.slice(-4) + : null + + const claudeDesktopConfig = JSON.stringify({ + "mcpServers": { + "diligence": { + "url": config.mcp_url, + ...(config.api_token ? { "headers": { "Authorization": `Bearer ${config.api_token}` } } : {}) + } + } + }, null, 2) + + return ( +
+
+

+ Connect Your AI Agent +

+

+ Use any MCP-compatible AI to log workouts, track food, and manage your fitness. +

+
+ + {/* Connection Details */} +
+
Connection
+ +
+
+ MCP Endpoint +
+
+ + {config.mcp_url} + + +
+
+ + {config.api_token && ( +
+
+ API Token +
+
+ setShowToken(!showToken)} + style={{ + flex: 1, fontFamily: 'var(--font-mono)', fontSize: '0.82rem', + cursor: 'pointer', wordBreak: 'break-all', + }} + > + {showToken ? config.api_token : maskedToken} + + +
+
+ Tap token to reveal. Only visible to admins. +
+
+ )} + + {!config.api_token && config.api_token_set && ( +
+ API token is set but only visible to admin users. +
+ )} + +
+ {config.tools_count} tools available + | + {config.deployment === 'local' ? 'Local (SQLite)' : 'Docker (PostgreSQL)'} +
+
+ + {/* Claude Desktop Setup */} +
+
Claude Desktop
+

+ Add this to your Claude Desktop MCP config: +

+ + {claudeDesktopConfig} + +

+ On macOS: + ~/Library/Application Support/Claude/claude_desktop_config.json + +

+
+ + {/* Claude Code Setup */} +
+
Claude Code
+

+ Add the MCP server from your terminal: +

+ + {`claude mcp add diligence --transport sse ${config.mcp_url}`} + +
+ + {/* What your agent can do */} +
+
What Your Agent Can Do
+
+ {[ + ['Log workouts', 'Track any activity and earn points automatically'], + ['Track food', 'Search 400K+ foods, log meals with full macros'], + ['Manage meal plans', 'Create plans, track compliance, adjust portions'], + ['Check progress', 'Daily points, weekly summary, program status'], + ['Redeem rewards', 'Spend earned points on rewards you set up'], + ].map(([title, desc]) => ( +
+ + ✓ + +
+
{title}
+
{desc}
+
+
+ ))} +
+
+
+ ) +} diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index 1fe4fe6..0fcd5ca 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -36,12 +36,12 @@ export default function Login() {
{/* Brand */}
-
🔥
+
💪

- Fitness Rewards + Diligence

- Earn your rewards. Every single day. + Your fitness. Your data. Your rewards.

diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b9990c0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,65 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "diligence" +version = "2.0.0" +description = "Self-hosted fitness rewards platform with AI agent integration" +readme = "README.md" +license = {text = "MIT"} +requires-python = ">=3.11" +authors = [ + {name = "DiligenceWorks Pte. Ltd.", email = "hello@diligenceworks.online"}, +] +keywords = ["fitness", "self-hosted", "mcp", "ai-agent", "rewards"] +classifiers = [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", +] + +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.30.0", + "sqlalchemy[asyncio]>=2.0.30", + "aiosqlite>=0.20.0", + "pydantic>=2.10.0", + "pydantic-settings>=2.7.0", + "python-jose[cryptography]>=3.3.0", + "bcrypt>=4.2.0", + "httpx>=0.27.0", + "python-multipart>=0.0.18", +] + +[project.optional-dependencies] +postgres = [ + "asyncpg>=0.30.0", +] +mcp = [ + "mcp[cli]>=1.0.0", +] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.23", + "ruff>=0.8.0", +] + +[project.scripts] +diligence = "diligence.cli:main" + +[project.urls] +Homepage = "https://github.com/DiligenceWorks/Diligence" +Documentation = "https://github.com/DiligenceWorks/Diligence#readme" +Repository = "https://github.com/DiligenceWorks/Diligence" +Issues = "https://github.com/DiligenceWorks/Diligence/issues" + +[tool.setuptools.packages.find] +include = ["diligence*"] + +[tool.setuptools.package-data] +diligence = ["frontend/**/*"] diff --git a/setup.ps1 b/setup.ps1 index d9b7e3b..1ccb407 100644 --- a/setup.ps1 +++ b/setup.ps1 @@ -1,29 +1,71 @@ -# Diligence — Windows Setup -# Generate API_TOKEN for MCP connector auth -$apiToken = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 32 | ForEach-Object {[char]$_}) -(Get-Content .env) -replace '^API_TOKEN=.*', "API_TOKEN=$apiToken" | Set-Content .env +# Diligence Setup — Windows +Write-Host "" +Write-Host " Diligence Setup" +Write-Host "" -Write-Host "`n`e[36m💪 Diligence — Setup`e[0m`n" +function New-RandomHex { -join ((1..32) | ForEach-Object { '{0:x2}' -f (Get-Random -Max 256) }) } -if (Test-Path .env) { - Write-Host "`e[33m.env already exists. Delete it first to regenerate.`e[0m" - exit 1 +$Secret = New-RandomHex +$Token = New-RandomHex + +# Detect mode — Docker if docker compose is available and docker-compose.yml exists +$DockerAvailable = $false +try { docker compose version 2>$null | Out-Null; $DockerAvailable = $true } catch {} + +if ($DockerAvailable -and (Test-Path "docker-compose.yml")) { + Write-Host " Mode: Docker (PostgreSQL)" + Write-Host "" + + if (Test-Path ".env") { + Write-Host " .env already exists. Delete it first to regenerate." + exit 1 + } + + Copy-Item ".env.example" ".env" + (Get-Content ".env") -replace '^SECRET_KEY=.*', "SECRET_KEY=$Secret" ` + -replace '^API_TOKEN=.*', "API_TOKEN=$Token" ` + -replace '^DATABASE_URL=.*', 'DATABASE_URL=postgresql+asyncpg://fitness@fitness-db:5432/fitness_rewards' ` + -replace '^BASE_URL=.*', 'BASE_URL=http://localhost' | + Set-Content ".env" + + Write-Host " .env created" + Write-Host "" + Write-Host " Next steps:" + Write-Host " docker compose up -d" + Write-Host " Open http://localhost" + +} else { + Write-Host " Mode: Local (SQLite)" + Write-Host "" + + $DataDir = Join-Path $env:USERPROFILE ".diligence" + if (-not (Test-Path $DataDir)) { New-Item -ItemType Directory -Path $DataDir | Out-Null } + $EnvFile = Join-Path $DataDir ".env" + + if (Test-Path $EnvFile) { + Write-Host " Config already exists at $EnvFile" + Write-Host " Delete it to regenerate." + exit 1 + } + + @" +SECRET_KEY=$Secret +API_TOKEN=$Token +BASE_URL=http://localhost:8000 +DATA_DIR=$DataDir +"@ | Set-Content $EnvFile + + Write-Host " Config created at $EnvFile" + Write-Host "" + Write-Host " Next steps:" + Write-Host " pip install ." + Write-Host " diligence" + Write-Host "" + Write-Host " Data stored in: $DataDir" } -# Generate random SECRET_KEY (64 hex chars) -$bytes = New-Object byte[] 32 -[System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes) -$secret = ($bytes | ForEach-Object { $_.ToString("x2") }) -join "" - -# Create .env from template -Copy-Item .env.example .env -(Get-Content .env) -replace "^SECRET_KEY=.*", "SECRET_KEY=$secret" | Set-Content .env - -Write-Host "`e[32m✅ .env created with random SECRET_KEY`e[0m" Write-Host "" -Write-Host "Next steps:" -Write-Host " docker compose up -d" -Write-Host " Open http://localhost" -Write-Host " Register your account" -Write-Host " Configure integrations via Settings or your AI agent" +Write-Host " MCP agent connection:" +Write-Host " URL: http://localhost:3001/sse" +Write-Host " Header: Authorization: Bearer $Token" Write-Host "" diff --git a/setup.sh b/setup.sh index 1118d35..ef02031 100755 --- a/setup.sh +++ b/setup.sh @@ -1,26 +1,81 @@ #!/bin/bash -echo "💪 Diligence — Setup" +set -e +echo "" +echo " Diligence Setup" +echo "" -if [ -f .env ]; then - echo ".env already exists. Delete it first to regenerate." - exit 1 +# Detect install mode +if command -v docker compose &> /dev/null && [ -f docker-compose.yml ]; then + MODE="docker" +else + MODE="local" fi -SECRET=$(openssl rand -hex 32) -TOKEN=$(openssl rand -hex 32) -cp .env.example .env -sed -i.bak "s/^SECRET_KEY=.*/SECRET_KEY=${SECRET}/" .env -sed -i.bak "s/^API_TOKEN=.*/API_TOKEN=${TOKEN}/" .env -rm -f .env.bak +# Generate secrets +SECRET=$(openssl rand -hex 32 2>/dev/null || python3 -c "import secrets; print(secrets.token_hex(32))") +TOKEN=$(openssl rand -hex 32 2>/dev/null || python3 -c "import secrets; print(secrets.token_hex(32))") + +if [ "$MODE" = "docker" ]; then + echo " Mode: Docker (PostgreSQL)" + echo "" + + if [ -f .env ]; then + echo " .env already exists. Delete it first to regenerate." + exit 1 + fi + + cp .env.example .env + sed -i.bak "s/^SECRET_KEY=.*/SECRET_KEY=${SECRET}/" .env + sed -i.bak "s/^API_TOKEN=.*/API_TOKEN=${TOKEN}/" .env + sed -i.bak "s|^DATABASE_URL=.*|DATABASE_URL=postgresql+asyncpg://fitness@fitness-db:5432/fitness_rewards|" .env + sed -i.bak "s|^BASE_URL=.*|BASE_URL=http://localhost|" .env + rm -f .env.bak + + echo " .env created" + echo "" + echo " Next steps:" + echo " docker compose up -d" + echo " Open http://localhost" + echo "" + echo " MCP agent connection:" + echo " URL: http://localhost:3001/sse" + echo " Header: Authorization: Bearer ${TOKEN}" + +else + echo " Mode: Local (SQLite)" + echo "" + + DATA_DIR="${HOME}/.diligence" + mkdir -p "$DATA_DIR" + ENV_FILE="${DATA_DIR}/.env" + + if [ -f "$ENV_FILE" ]; then + echo " Config already exists at ${ENV_FILE}" + echo " Delete it to regenerate." + exit 1 + fi + + cat > "$ENV_FILE" << EOF +SECRET_KEY=${SECRET} +API_TOKEN=${TOKEN} +BASE_URL=http://localhost:8000 +DATA_DIR=${DATA_DIR} +EOF + + echo " Config created at ${ENV_FILE}" + echo "" + echo " Next steps:" + echo " pip install ." + echo " diligence" + echo "" + echo " Or run directly:" + echo " python -m diligence" + echo "" + echo " Data stored in: ${DATA_DIR}" + echo "" + echo " MCP agent connection:" + echo " URL: http://localhost:3001/sse" + echo " Header: Authorization: Bearer ${TOKEN}" +fi -echo "✅ .env created with random SECRET_KEY and API_TOKEN" echo "" -echo "Next steps:" -echo " docker compose up -d" -echo " Open http://localhost" -echo " Register your account" -echo " Configure integrations via Settings or your AI agent" -echo "" -echo "MCP agent connection:" -echo " URL: http://localhost:3001/sse" -echo " Header: Authorization: Bearer ${TOKEN}"