diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3ac26d2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.env +.env.* +!.env.example +__pycache__ +*.pyc +content/ +node_modules/ +dist/ +*.log +.DS_Store diff --git a/.env.example b/.env.example index 32a93d9..4b0fdbf 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ # === REQUIRED (generated automatically by setup.sh) === SECRET_KEY= +API_TOKEN= # === APP URL (change for production) === BASE_URL=http://localhost @@ -8,6 +9,11 @@ BASE_URL=http://localhost DB_USER=fitness DB_NAME=fitness_rewards +# === 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. diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md index ddd2dad..74f9dd5 100644 --- a/AGENT_GUIDE.md +++ b/AGENT_GUIDE.md @@ -97,3 +97,31 @@ Use get_context() to see their motivation profile: understand it, but don't lecture. - Don't read back integration credentials. You can check status but never retrieve stored secrets. + +## Multi-Device Integration + +If you have access to both Diligence tools and a device MCP (like COROS), +you can bridge data between them: + +1. Read the user's recent workouts from the device MCP +2. Use Diligence's log_activity tool to import them +3. This earns the user points automatically + +Example workflow with COROS MCP connected: +- Check COROS for today's activities +- For each unlogged activity, call log_activity with the details +- Report the points earned + +## Strava Data Warning + +Strava's API terms prohibit use of their data for AI/ML purposes. +If the user has Strava connected, you may see synced activities in +their log, but do not reference Strava-specific data in your coaching +advice. Treat Strava activities as user-reported workouts only. + +## Provider-Agnostic + +This guide works regardless of which LLM powers the coaching. +The same context, tools, and personality apply whether you are +GPT-4o, Claude, Llama, Gemini, or any other model. The user +chose you — respect their choice and focus on being helpful. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8da9836 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +All notable changes to Diligence are documented here. + +## [1.0.0] — 2026-06-18 + +Initial open-source release. + +### Features +- Points economy with daily gate, weekly targets, weekly reset +- Science-based onboarding (PAR-Q+, TTM, BREQ-2 motivation profiling) +- Activity logging (manual + Strava OAuth + Polar OAuth sync) +- Food logging with Open Food Facts barcode scanning + USDA FoodData Central +- 90-day structured program tracking +- Configurable reward shop +- In-app support chat with Telegram notifications +- 14-tool MCP connector for AI agent integration +- Meal plan system with compliance tracking +- Dynamic integration configuration (11 providers) +- B2A discovery layer (llms.txt, agent-card.json, SKILL.md) + +### Security +- Fernet-encrypted credential storage (HKDF key derivation) +- Signed JWT OAuth state parameters +- MCP connector authentication via API_TOKEN +- First-user auto-admin grant +- Traceback suppression in error responses diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c14bc4a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,31 @@ +# Contributing to Diligence + +Thanks for your interest in contributing! + +## How to contribute + +1. **Open an issue** to discuss the change before starting work +2. **Fork the repo** and create a branch from `main` +3. **Test locally**: `docker compose up -d` and verify all 4 containers start healthy +4. **Submit a PR** with a clear description of what changed and why + +## Development setup + +```bash +git clone https://github.com/DiligenceWorks/Diligence.git +cd Diligence +./setup.sh # Mac/Linux +docker compose up -d +``` + +Open http://localhost and register. First user gets admin automatically. + +## Code style + +- Backend: Python 3.11+, FastAPI, async/await, type hints +- Frontend: React with hooks, no class components +- CSS: use CSS variables from `index.css`, never hardcode colors + +## Questions? + +Open a GitHub Discussion or reach out at hello@diligenceworks.online. diff --git a/OUTSTANDING.md b/OUTSTANDING.md new file mode 100644 index 0000000..331d702 --- /dev/null +++ b/OUTSTANDING.md @@ -0,0 +1,101 @@ +# Diligence v4 — Outstanding Work + +**Last updated:** June 22, 2026 +**Session summary:** 5 commits, 35 files changed, +1,358/-329 lines + +## What Was Delivered (June 22, 2026) + +### Stream A — DW Branding ✅ COMPLETE +- Full palette swap from "Solar Momentum" (orange/green/blue) to DiligenceWorks brand (deep blue #2952CC, teal, IBM Plex Mono) +- Light and dark mode via `prefers-color-scheme` media query + manual `data-theme` override +- Typography: Outfit → Instrument Sans, Plus Jakarta Sans → IBM Plex Mono for data elements +- 30+ hardcoded hex color values replaced across 16 JSX files +- Border radii tightened from 14/10/20px to 8/6/12px + +### Stream D — Security Hardening ✅ COMPLETE +- SEC-05: Fail-fast if SECRET_KEY is the default value +- SEC-06: CORS `allow_credentials=False` +- SEC-08: Input validation on auth schemas (username length/pattern, password min 8 chars) +- SEC-10: `.dockerignore` files for root, backend, mcp-connector +- SEC-11: JWT algorithm hardcoded as constant +- CQ-01: Crawl scheduler gated on `CRAWL_ENABLED` env var +- OS-02: `CONTRIBUTING.md` +- OS-03: `CHANGELOG.md` (v1.0.0) +- `setup.ps1`: API_TOKEN generation (Windows parity) + +### Stream C — AI Agent Setup ✅ BACKEND + FRONTEND COMPLETE +- `ai_provider.py`: Multi-provider LLM routing with 2 code paths (OpenAI-compatible + Anthropic) +- Streaming SSE responses for all providers +- `ai_chat.py`: POST `/api/ai/chat` (SSE) + GET `/api/ai/status` +- Provider registry expanded from 11 to 20 providers across 4 categories +- `ChatCoach.jsx`: In-app chat UI with streaming, provider indicator, suggestion chips +- Nav updated: Home | Log | Keto | Programs | **Coach** +- README updated with 8-provider table + Claude Desktop/Code/Cursor/Windsurf configs +- AGENT_GUIDE updated with multi-MCP, Strava warning, provider-agnostic statement + +### Stream B — Hardware Integrations ✅ FOUNDATION COMPLETE +- `device_sync_base.py`: Abstract base class with OAuth management, deduplication, webhook interface +- Provider registry includes Garmin, WHOOP, Oura, COROS, Fitbit, Withings, Suunto with accurate API URLs + +--- + +## What Still Needs Doing + +### Priority 1 — Blocked on API Credentials (Scot action items) + +| Task | Blocker | Effort once unblocked | +|------|---------|----------------------| +| `garmin_sync.py` — Garmin Connect sync service | Apply at developer.garmin.com using DiligenceWorks Pte. Ltd. ~2 business days approval | 2-3 hours | +| `whoop_sync.py` — WHOOP sync service | Register at developer.whoop.com. Requires WHOOP device ownership | 2-3 hours | +| `oura_sync.py` — Oura Ring sync service | Register at cloud.ouraring.com. Self-serve, immediate | 2-3 hours | +| COROS partner API application | Apply at support.coros.com. Timeline unclear | MCP bridge already designed, no backend code needed | + +Each sync service follows the pattern in `device_sync_base.py` and mirrors the existing `strava_sync.py`. The work is: OAuth flow, activity type mapping, API data fetching, and point awarding via `import_activity()`. + +### Priority 2 — Code Work (no blockers) + +| Task | Effort | Notes | +|------|--------|-------| +| Generic webhook receiver endpoint (`POST /api/integrations/webhook/{provider}`) | 1-2 hours | Dispatches to sync services when devices push data | +| Theme toggle UI in Settings page | 30 min | Light/dark/system toggle, stored in localStorage | +| SEC-07: Auth rate limiting (`slowapi` on login/register) | 30 min | Add `slowapi` to requirements.txt | +| SEC-09: UUID parameter validation in meal_plans/support routers | 20 min | Change `str` → `uuid.UUID` type hints | +| SEC-15: Enum validation for compliance status and meal_type | 20 min | Add `Literal[...]` types | +| UI-03: Fix React Hooks violation in HelpButton (App.jsx) | 10 min | Move `useEffect` above conditional return | +| UI-04: Load existing compliance data in MealPlan.jsx on mount | 30 min | Fetch from API and pre-populate state | +| UI-05: Error state handling in SettingsIntegrations + MealPlan | 30 min | Add error banners on API failures | +| Settings page accessibility after nav change | 20 min | Add gear icon in header or link from Coach page | +| Function calling for AI chat (tool use) | 2-3 hours | Let AI coach log workouts, search food via internal tools | + +### Priority 3 — Polish (nice to have) + +| Task | Effort | +|------|--------| +| Push updated code to GitHub (DiligenceWorks/Diligence) — clean-history push | 30 min | +| Deploy v4 to Coolify (fitness.littlefake.com) | 20 min | +| Update DW KB CONTEXT.md for fitness-rewards project | 20 min | +| OS-06: GitHub Actions CI pipeline (docker compose build) | 30 min | +| OS-07: Test suite (auth, points engine, meal plans) | 4-6 hours | +| OS-08: System requirements in README (RAM, disk, Docker version) | 10 min | +| OS-09: Backup/restore documentation in README | 10 min | +| UI-08: Default timezone from UTC instead of Asia/Bangkok | 5 min | + +--- + +## Architecture Summary for Reviewers + +**Container count:** 4 (unchanged — frontend, backend, mcp-connector, fitness-db) + +**AI coaching flow:** +User types in Coach tab → POST `/api/ai/chat` → `ai_provider.py` reads configured provider from DB → routes to OpenAI-compatible endpoint OR Anthropic API → streams SSE response back to `ChatCoach.jsx` + +**Device sync flow:** +User connects device via OAuth in Settings → credentials encrypted in `integration_configs` table → sync triggered by webhook (push) or manual sync (pull) → `device_sync_base.py` deduplicates and awards points via `points_engine.py` + +**Provider registry:** 20 providers organized by category: +- **Device** (9): Strava✅, Polar✅, Garmin⏳, WHOOP⏳, Oura⏳, COROS (MCP), Fitbit, Withings, Suunto +- **AI** (8): OpenAI, OpenRouter, HuggingFace, Groq, Ollama, Claude, Gemini, Custom +- **Nutrition** (2): USDA✅, Nutritionix +- **Notifications** (1): Telegram✅ + +✅ = working sync service, ⏳ = sync service pending API credentials diff --git a/README.md b/README.md index 63dd685..37fb408 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,65 @@ 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**. + +--- + +### Windows 11 Setup + +**1. Install Docker Desktop** + +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. + +- **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. + +**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). + +**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 +``` + +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 +``` + +--- + +### macOS Setup + +**1. Install Docker Desktop** + +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 @@ -13,7 +72,30 @@ cd diligence docker compose up -d ``` -Open http://localhost and create your account. +--- + +### Linux Setup + +Install Docker Engine and Docker Compose via your distro's package manager, or install Docker Desktop for Linux. Then: + +```bash +git clone https://github.com/diligenceworks/diligence +cd diligence +./setup.sh +docker compose up -d +``` + +--- + +Open http://localhost and create your account. First user gets admin. + +### Verify + +```bash +docker compose ps +``` + +You should see 4 containers, all healthy: `frontend`, `backend`, `mcp-connector`, `fitness-db`. ## Features @@ -26,17 +108,71 @@ Open http://localhost and create your account. - **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. -## Connecting an AI Agent +## Built-in AI Coach -Point your agent's MCP config to `http://localhost:3001/sse` (development) or `https://your-domain/mcp` (production behind reverse proxy). +Diligence includes a built-in AI coaching chat. Configure any LLM provider in **Settings → Integrations**, then open the **Coach** tab. -Works with Claude Desktop, Cursor, Windsurf, and any MCP-compatible agent. +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. +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 @@ -44,17 +180,57 @@ Diligence is self-hosted software. Your data never leaves your server. No cloud 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 +- 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. Open an issue to discuss before submitting a PR. +Contributions welcome. Please open an issue to discuss before submitting a PR. ## License diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..3ac26d2 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,11 @@ +.git +.env +.env.* +!.env.example +__pycache__ +*.pyc +content/ +node_modules/ +dist/ +*.log +.DS_Store diff --git a/backend/app/config.py b/backend/app/config.py index 4ffe6be..3c2be33 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -10,8 +10,9 @@ class Settings(BaseSettings): # Auth secret_key: str = "change-me-in-production" - algorithm: str = "HS256" + 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 = "" diff --git a/backend/app/main.py b/backend/app/main.py index 55a3c36..be39c0e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import sys import logging from contextlib import asynccontextmanager from fastapi import FastAPI @@ -27,6 +28,13 @@ async def lifespan(app: FastAPI): 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() @@ -41,14 +49,17 @@ async def lifespan(app: FastAPI): except Exception as e: logger.warning(f"Resource seeding failed (non-fatal): {e}") - # Start background crawl queue scheduler + # Start background crawl queue scheduler (gated on CRAWL_ENABLED) crawl_task = None - 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}") + 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 @@ -68,7 +79,7 @@ app = FastAPI(title="Fitness Rewards", version="1.0.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, allow_origins=["*"], - allow_credentials=True, + allow_credentials=False # Bearer tokens don't need credentials mode, allow_methods=["*"], allow_headers=["*"], ) @@ -85,6 +96,7 @@ 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) @@ -98,6 +110,7 @@ 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") diff --git a/backend/app/routers/ai_chat.py b/backend/app/routers/ai_chat.py new file mode 100644 index 0000000..e9b7b24 --- /dev/null +++ b/backend/app/routers/ai_chat.py @@ -0,0 +1,64 @@ +"""AI coaching chat endpoint — SSE streaming responses.""" +from __future__ import annotations + +import json +import logging +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 + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/ai", tags=["AI Coaching"]) + + +@router.post("/chat") +async def chat_with_ai( + request: Request, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Stream an AI coaching response via SSE. + + Body: {"message": "...", "history": [{"role": "user"|"assistant", "content": "..."}]} + Response: text/event-stream with data chunks. + """ + body = await request.json() + message = body.get("message", "").strip() + history = body.get("history", []) + + if not message: + return {"error": "Message cannot be empty"} + + # Validate history format + clean_history = [] + for msg in history[-20:]: # Cap at last 20 messages + if isinstance(msg, dict) and msg.get("role") in ("user", "assistant") and msg.get("content"): + clean_history.append({"role": msg["role"], "content": msg["content"][:4000]}) + + async def event_stream(): + async for chunk in ai_provider.chat(user.id, message, clean_history, db): + yield f"data: {json.dumps({'text': chunk})}\n\n" + yield "data: [DONE]\n\n" + + return StreamingResponse(event_stream(), media_type="text/event-stream") + + +@router.get("/status") +async def ai_status( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Check which AI provider is configured.""" + provider = await ai_provider.get_active_ai_provider(db) + if provider: + return { + "configured": True, + "provider": provider["name"], + "model": provider["model"], + } + return {"configured": False, "provider": None, "model": None} diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index fd3e487..517998d 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,11 +1,10 @@ from __future__ import annotations import logging -import traceback from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, status from fastapi.responses import JSONResponse -from sqlalchemy import select +from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db @@ -26,11 +25,16 @@ async def register(req: RegisterRequest, db: Annotated[AsyncSession, Depends(get if existing.scalar_one_or_none(): raise HTTPException(status_code=400, detail="Username already taken") + # Grant admin to first user + admin_count = await db.execute(select(func.count(User.id)).where(User.is_admin == True)) + is_first_user = (admin_count.scalar() or 0) == 0 + user = User( username=req.username, display_name=req.display_name, password_hash=hash_password(req.password), email=req.email, + is_admin=is_first_user, ) db.add(user) await db.flush() @@ -52,9 +56,8 @@ async def register(req: RegisterRequest, db: Annotated[AsyncSession, Depends(get except HTTPException: raise except Exception as e: - tb = traceback.format_exc() - logger.error(f"Registration failed: {e}\n{tb}") - return JSONResponse(status_code=500, content={"detail": str(e), "traceback": tb}) + logger.error(f"Registration failed: {e}", exc_info=True) + return JSONResponse(status_code=500, content={"detail": "Internal server error"}) @router.post("/login") @@ -69,9 +72,8 @@ async def login(req: LoginRequest, db: Annotated[AsyncSession, Depends(get_db)]) except HTTPException: raise except Exception as e: - tb = traceback.format_exc() - logger.error(f"Login failed: {e}\n{tb}") - return JSONResponse(status_code=500, content={"detail": str(e), "traceback": tb}) + logger.error(f"Login failed: {e}", exc_info=True) + return JSONResponse(status_code=500, content={"detail": "Internal server error"}) @router.get("/me") @@ -82,4 +84,5 @@ async def get_me(user: Annotated[User, Depends(get_current_user)]): "display_name": user.display_name, "email": user.email, "timezone": user.timezone, + "is_admin": getattr(user, "is_admin", False), } diff --git a/backend/app/routers/integrations.py b/backend/app/routers/integrations.py index 8562a7b..e3eb075 100644 --- a/backend/app/routers/integrations.py +++ b/backend/app/routers/integrations.py @@ -39,7 +39,10 @@ async def integration_status( # --- Strava --- @router.get("/strava/auth") async def strava_auth(user: Annotated[User, Depends(get_current_user)]): - return {"auth_url": get_strava_auth_url(str(user.id))} + from app.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)} @router.get("/strava/callback") @@ -48,7 +51,14 @@ async def strava_callback( state: str = Query(...), db: AsyncSession = Depends(get_db), ): - user_id = uuid_mod.UUID(state) + from jose import JWTError, jwt as jose_jwt + from app.config import get_settings + _settings = get_settings() + try: + payload = jose_jwt.decode(state, _settings.secret_key, algorithms=[_settings.algorithm]) + user_id = uuid_mod.UUID(payload["sub"]) + except (JWTError, KeyError, ValueError): + raise HTTPException(status_code=400, detail="Invalid or expired OAuth state") data = await exchange_strava_code(code) # Upsert token @@ -91,7 +101,10 @@ async def strava_sync( # --- Polar --- @router.get("/polar/auth") async def polar_auth(user: Annotated[User, Depends(get_current_user)]): - return {"auth_url": get_polar_auth_url(str(user.id))} + from app.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)} @router.get("/polar/callback") @@ -100,7 +113,14 @@ async def polar_callback( state: str = Query(...), db: AsyncSession = Depends(get_db), ): - user_id = uuid_mod.UUID(state) + from jose import JWTError, jwt as jose_jwt + from app.config import get_settings + _settings = get_settings() + try: + payload = jose_jwt.decode(state, _settings.secret_key, algorithms=[_settings.algorithm]) + user_id = uuid_mod.UUID(payload["sub"]) + except (JWTError, KeyError, ValueError): + raise HTTPException(status_code=400, detail="Invalid or expired OAuth state") data = await exchange_polar_code(code) # Register user with Polar diff --git a/backend/app/routers/support.py b/backend/app/routers/support.py index c243602..c8cb767 100644 --- a/backend/app/routers/support.py +++ b/backend/app/routers/support.py @@ -24,7 +24,6 @@ from app.config import settings logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/support", tags=["support"]) -# Admin check uses is_admin column on User model (first registered user gets admin=True) MAX_MESSAGES_PER_DAY = 10 @@ -279,8 +278,8 @@ async def get_unread_count( # ── Admin Endpoints ──────────────────────────────────────────────────────── def require_admin(user: User): - """Check that the user is the admin.""" - if user.username != ADMIN_USERNAME: + """Check that the user has admin privileges.""" + if not getattr(user, "is_admin", False): raise HTTPException(status_code=403, detail="Admin access required") diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 5cbd802..9d2b238 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -1,17 +1,17 @@ from __future__ import annotations -from pydantic import BaseModel +from pydantic import BaseModel, Field class LoginRequest(BaseModel): - username: str - password: str + username: str = Field(min_length=3, max_length=50) + password: str = Field(min_length=8, max_length=128) class RegisterRequest(BaseModel): - username: str - password: str - display_name: str + username: str = Field(min_length=3, max_length=50, pattern=r'^[a-zA-Z0-9_-]+$') + password: str = Field(min_length=8, max_length=128) + display_name: str = Field(min_length=1, max_length=100) email: str | None = None diff --git a/backend/app/services/ai_provider.py b/backend/app/services/ai_provider.py new file mode 100644 index 0000000..fa1ea7a --- /dev/null +++ b/backend/app/services/ai_provider.py @@ -0,0 +1,316 @@ +"""Multi-provider AI coaching service. + +Two code paths: +- OpenAI-compatible: covers OpenAI, OpenRouter, HuggingFace, Groq, Ollama, Custom +- Anthropic: covers Claude (different message format + auth header) +- Gemini: thin adapter for Google's generateContent API + +System prompt is built from AGENT_GUIDE.md + live user context. +Chat history is ephemeral (not persisted). +""" +from __future__ import annotations + +import json +import logging +from typing import AsyncGenerator + +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 + +logger = logging.getLogger(__name__) +settings = get_settings() + +# AI provider presets — base_url and api_format for each named provider +AI_PRESETS = { + "openai": {"base_url": "https://api.openai.com/v1", "format": "openai", "default_model": "gpt-4o-mini"}, + "openrouter": {"base_url": "https://openrouter.ai/api/v1", "format": "openai", "default_model": "openai/gpt-4o-mini"}, + "huggingface": {"base_url": "https://router.huggingface.co/v1", "format": "openai", "default_model": "meta-llama/Llama-3.3-70B-Instruct"}, + "groq": {"base_url": "https://api.groq.com/openai/v1", "format": "openai", "default_model": "llama-3.3-70b-versatile"}, + "ollama": {"base_url": "http://host.docker.internal:11434/v1", "format": "openai", "default_model": "llama3.1"}, + "claude": {"base_url": "https://api.anthropic.com/v1", "format": "anthropic", "default_model": "claude-sonnet-4-6"}, + "gemini": {"base_url": "https://generativelanguage.googleapis.com/v1beta", "format": "gemini", "default_model": "gemini-2.0-flash"}, + "custom_ai": {"base_url": "", "format": "openai", "default_model": ""}, +} + +# System prompt template — {context} is replaced with live user data +SYSTEM_PROMPT_TEMPLATE = """You are a fitness coach integrated with Diligence, a self-hosted fitness rewards platform. + +Your role: motivate, guide, and help the user stay consistent with their health goals. Use the context below to personalize your responses. + +CURRENT USER CONTEXT: +{context} + +RULES: +- Be encouraging but honest. Celebrate progress, address setbacks constructively. +- When the user asks to log a workout or food, confirm what you'll log and do it. +- Reference their program schedule when suggesting workouts. +- Respect their motivation type (from BREQ-2 profiling) to calibrate your tone. +- Keep responses concise — this is a mobile chat, not an essay. +- If the user hasn't earned enough points today, gently encourage activity. +- Never fabricate data — only reference what's in the context.""" + + +async def get_active_ai_provider(db: AsyncSession) -> dict | None: + """Find the first configured AI provider.""" + result = await db.execute( + select(IntegrationConfig).where( + IntegrationConfig.provider.in_(list(AI_PRESETS.keys())) + ) + ) + configs = result.scalars().all() + + for config in configs: + provider_name = config.provider + try: + creds = json.loads(decrypt_value(config.encrypted_value, settings.secret_key)) + except Exception: + continue + + preset = AI_PRESETS.get(provider_name, AI_PRESETS["custom_ai"]) + api_key = creds.get("api_key", "") + base_url = creds.get("endpoint_url", preset["base_url"]) or preset["base_url"] + model = creds.get("model", preset["default_model"]) or preset["default_model"] + + return { + "name": provider_name, + "format": preset["format"], + "base_url": base_url, + "api_key": api_key, + "model": model, + } + + return 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 + + context_parts = [] + + # User profile + user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none() + if user: + context_parts.append(f"Name: {user.display_name}") + context_parts.append(f"Timezone: {user.timezone}") + + # Profile / motivation + profile = (await db.execute(select(UserProfile).where(UserProfile.user_id == user_id))).scalar_one_or_none() + if profile: + if profile.ttm_stage: + context_parts.append(f"Stage of change: {profile.ttm_stage}") + if profile.breq_profile: + context_parts.append(f"Motivation type: {profile.breq_profile}") + + # Today's points (best effort) + try: + from app.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'}") + except Exception: + pass + + return "\n".join(context_parts) if context_parts else "No profile data available yet." + + +async def chat( + user_id, + message: str, + history: list[dict], + db: AsyncSession, +) -> AsyncGenerator[str, None]: + """Route chat to the configured AI provider with streaming.""" + provider = await get_active_ai_provider(db) + if not provider: + yield "No AI provider configured. Go to Settings → Integrations to connect one (OpenAI, OpenRouter, Ollama, etc.)." + return + + context = await build_context(db, user_id) + system_prompt = SYSTEM_PROMPT_TEMPLATE.format(context=context) + + try: + if provider["format"] == "openai": + async for chunk in _call_openai_compat( + base_url=provider["base_url"], + api_key=provider["api_key"], + model=provider["model"], + system_prompt=system_prompt, + history=history, + message=message, + ): + yield chunk + elif provider["format"] == "anthropic": + async for chunk in _call_anthropic( + api_key=provider["api_key"], + model=provider["model"], + system_prompt=system_prompt, + history=history, + message=message, + ): + yield chunk + elif provider["format"] == "gemini": + async for chunk in _call_gemini( + api_key=provider["api_key"], + model=provider["model"], + system_prompt=system_prompt, + history=history, + message=message, + ): + yield chunk + except httpx.HTTPStatusError as e: + yield f"AI provider error ({e.response.status_code}). Check your API key in Settings → Integrations." + except httpx.ConnectError: + yield f"Cannot reach {provider['name']}. Check your network or endpoint URL." + except Exception as e: + logger.error(f"AI chat error: {e}") + yield "Something went wrong with the AI provider. Check Settings → Integrations." + + +async def _call_openai_compat( + base_url: str, + api_key: str, + model: str, + system_prompt: str, + history: list[dict], + message: str, +) -> AsyncGenerator[str, None]: + """Call any OpenAI-compatible /v1/chat/completions endpoint with streaming. + + Covers: OpenAI, OpenRouter, HuggingFace, Groq, Ollama, Custom. + """ + headers = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + payload = { + "model": model, + "messages": [ + {"role": "system", "content": system_prompt}, + *history, + {"role": "user", "content": message}, + ], + "stream": True, + "max_tokens": 1024, + } + + async with httpx.AsyncClient(timeout=60.0) as client: + async with client.stream( + "POST", + f"{base_url.rstrip('/')}/chat/completions", + json=payload, + headers=headers, + ) as resp: + resp.raise_for_status() + async for line in resp.aiter_lines(): + if line.startswith("data: ") and line.strip() != "data: [DONE]": + try: + chunk = json.loads(line[6:]) + delta = chunk.get("choices", [{}])[0].get("delta", {}) + if content := delta.get("content"): + yield content + except (json.JSONDecodeError, IndexError, KeyError): + continue + + +async def _call_anthropic( + api_key: str, + model: str, + system_prompt: str, + history: list[dict], + message: str, +) -> AsyncGenerator[str, None]: + """Call Anthropic's /v1/messages endpoint with streaming.""" + headers = { + "Content-Type": "application/json", + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + } + + # Convert OpenAI-style history to Anthropic format + messages = [] + for msg in history: + messages.append({"role": msg["role"], "content": msg["content"]}) + messages.append({"role": "user", "content": message}) + + payload = { + "model": model, + "system": system_prompt, + "messages": messages, + "max_tokens": 1024, + "stream": True, + } + + async with httpx.AsyncClient(timeout=60.0) as client: + async with client.stream( + "POST", + "https://api.anthropic.com/v1/messages", + json=payload, + headers=headers, + ) as resp: + resp.raise_for_status() + async for line in resp.aiter_lines(): + if line.startswith("data: "): + try: + event = json.loads(line[6:]) + if event.get("type") == "content_block_delta": + delta = event.get("delta", {}) + if text := delta.get("text"): + yield text + except (json.JSONDecodeError, KeyError): + continue + + +async def _call_gemini( + api_key: str, + model: str, + system_prompt: str, + history: list[dict], + message: str, +) -> AsyncGenerator[str, None]: + """Call Google Gemini's generateContent endpoint with streaming.""" + url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent" + + # Build Gemini content format + contents = [] + # System instruction goes as first user/model exchange + if system_prompt: + contents.append({"role": "user", "parts": [{"text": f"[System instructions]\n{system_prompt}"}]}) + contents.append({"role": "model", "parts": [{"text": "Understood. I'll follow these instructions."}]}) + + for msg in history: + role = "model" if msg["role"] == "assistant" else "user" + contents.append({"role": role, "parts": [{"text": msg["content"]}]}) + contents.append({"role": "user", "parts": [{"text": message}]}) + + payload = { + "contents": contents, + "generationConfig": {"maxOutputTokens": 1024}, + } + + async with httpx.AsyncClient(timeout=60.0) as client: + async with client.stream( + "POST", + url, + json=payload, + params={"key": api_key, "alt": "sse"}, + ) as resp: + resp.raise_for_status() + async for line in resp.aiter_lines(): + if line.startswith("data: "): + try: + chunk = json.loads(line[6:]) + candidates = chunk.get("candidates", []) + if candidates: + parts = candidates[0].get("content", {}).get("parts", []) + for part in parts: + if text := part.get("text"): + yield text + except (json.JSONDecodeError, KeyError): + continue diff --git a/backend/app/services/device_sync_base.py b/backend/app/services/device_sync_base.py new file mode 100644 index 0000000..b88bd25 --- /dev/null +++ b/backend/app/services/device_sync_base.py @@ -0,0 +1,179 @@ +"""Base class for device activity sync services. + +All device sync implementations (garmin_sync, whoop_sync, oura_sync) +inherit from this class. The pattern mirrors the existing strava_sync +and polar_sync services but adds webhook support and a standard +interface for the generic webhook receiver endpoint. +""" +from __future__ import annotations + +import abc +import uuid +import logging +from datetime import datetime, timezone +from typing import Any + +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 + +logger = logging.getLogger(__name__) +settings = get_settings() + + +class DeviceSyncBase(abc.ABC): + """Abstract base for all device sync services. + + Subclasses implement the provider-specific OAuth flow, API calls, + and data mapping. The base handles token storage/refresh patterns + and the common activity logging pipeline. + """ + + PROVIDER: str # e.g. "garmin", "whoop", "oura" + API_BASE: str # e.g. "https://apis.garmin.com" + + # ── OAuth ── + + @abc.abstractmethod + async def get_auth_url(self, user_id: uuid.UUID, db: AsyncSession) -> str: + """Generate the OAuth authorization URL for this provider.""" + + @abc.abstractmethod + async def handle_callback( + self, code: str, state: str, db: AsyncSession + ) -> dict: + """Exchange authorization code for tokens, store them. + + Returns: {"success": True, "provider": "garmin"} + """ + + @abc.abstractmethod + async def _refresh_token(self, token: OAuthToken, db: AsyncSession) -> OAuthToken: + """Provider-specific token refresh logic.""" + + # ── Sync ── + + @abc.abstractmethod + async def sync_activities( + self, user_id: uuid.UUID, db: AsyncSession + ) -> list[dict]: + """Pull new activities from the provider and award points. + + Returns list of imported activity dicts with points_earned. + """ + + # ── Webhooks ── + + @abc.abstractmethod + async def validate_webhook(self, request_body: bytes, headers: dict) -> bool: + """Validate an incoming webhook signature. + + Returns True if the webhook is authentic. + """ + + @abc.abstractmethod + async def handle_webhook( + self, payload: dict, db: AsyncSession + ) -> dict: + """Process an incoming webhook notification. + + Typically identifies the user and triggers sync_activities(). + Returns: {"processed": True, "activities_imported": N} + """ + + # ── Helpers (shared) ── + + async def get_valid_token( + self, db: AsyncSession, user_id: uuid.UUID + ) -> OAuthToken | None: + """Get a valid OAuth token, refreshing if expired.""" + result = await db.execute( + select(OAuthToken).where( + OAuthToken.user_id == user_id, + OAuthToken.provider == self.PROVIDER, + ) + ) + token = result.scalar_one_or_none() + if not token: + return None + + if token.expires_at and token.expires_at < datetime.now(timezone.utc): + try: + token = await self._refresh_token(token, db) + except Exception as e: + logger.error(f"Token refresh failed for {self.PROVIDER}: {e}") + return None + + return token + + async def get_credentials(self, db: AsyncSession) -> dict | None: + """Get decrypted OAuth credentials from integration_configs.""" + import json + result = await db.execute( + select(IntegrationConfig).where( + IntegrationConfig.provider == self.PROVIDER + ) + ) + config = result.scalar_one_or_none() + if not config: + return None + + try: + return json.loads(decrypt_value(config.encrypted_value, settings.secret_key)) + except Exception as e: + logger.error(f"Failed to decrypt {self.PROVIDER} credentials: {e}") + return None + + async def import_activity( + self, + db: AsyncSession, + user_id: uuid.UUID, + *, + title: str, + category: str = "workout", + activity_date: Any, + duration_minutes: int | None = None, + external_id: str | None = None, + metadata: dict | None = None, + ) -> dict: + """Import a single activity using the standard points pipeline. + + Deduplicates by (user_id, provider, external_id). + """ + from app.models.activity import ActivityLog + + if external_id: + existing = await db.execute( + select(ActivityLog).where( + ActivityLog.user_id == user_id, + ActivityLog.source == self.PROVIDER, + ActivityLog.external_id == external_id, + ) + ) + if existing.scalar_one_or_none(): + return {"skipped": True, "external_id": external_id} + + entry = await log_activity_with_points( + db=db, + user_id=user_id, + category=category, + activity_date=activity_date, + title=title, + duration_minutes=duration_minutes, + source=self.PROVIDER, + external_id=external_id, + metadata=metadata or {}, + ) + + return { + "id": str(entry.id), + "title": entry.title, + "points_earned": entry.points_earned, + "activity_date": str(activity_date), + } diff --git a/backend/app/services/provider_registry.py b/backend/app/services/provider_registry.py index 99e2b15..0789659 100644 --- a/backend/app/services/provider_registry.py +++ b/backend/app/services/provider_registry.py @@ -2,65 +2,213 @@ from __future__ import annotations PROVIDER_REGISTRY: dict[str, dict] = { + + # ═══ Device Integrations ═══ + "strava": { "name": "Strava", "type": "oauth2", + "category": "device", "fields": ["client_id", "client_secret"], "auth_url": "https://www.strava.com/oauth/authorize", "token_url": "https://www.strava.com/oauth/token", "scopes": "read,activity:read_all", "help_url": "https://developers.strava.com/", "help_text": "Create an app at developers.strava.com. Set the callback URL to {BASE_URL}/api/integrations/strava/callback", + "warning": "Strava ToS prohibits use of their data for AI/ML. Activity sync works but AI coaching should not reference Strava data.", + "has_webhook": False, + "sync_service": "strava_sync", }, "polar": { "name": "Polar", "type": "oauth2", + "category": "device", "fields": ["client_id", "client_secret"], "auth_url": "https://flow.polar.com/oauth2/authorization", "token_url": "https://polarremote.com/v2/oauth2/token", "help_url": "https://admin.polaraccesslink.com/", "help_text": "Register at admin.polaraccesslink.com", + "has_webhook": False, + "sync_service": "polar_sync", }, "garmin": { "name": "Garmin Connect", "type": "oauth2", + "category": "device", "fields": ["client_id", "client_secret"], + "auth_url": "https://connect.garmin.com/oauthConfirm", + "token_url": "https://connectapi.garmin.com/oauth-service/oauth/token", + "scopes": "HEALTH,ACTIVITY", "help_url": "https://developer.garmin.com/gc-developer-program/", - "help_text": "Apply at developer.garmin.com — approval takes 1-4 weeks", - }, - "fitbit": { - "name": "Fitbit", - "type": "oauth2", - "fields": ["client_id", "client_secret"], - "auth_url": "https://www.fitbit.com/oauth2/authorize", - "token_url": "https://api.fitbit.com/oauth2/token", - "help_url": "https://dev.fitbit.com/", - "help_text": "Register at dev.fitbit.com", - }, - "withings": { - "name": "Withings", - "type": "oauth2", - "fields": ["client_id", "client_secret"], - "help_url": "https://developer.withings.com/", - "help_text": "Register at developer.withings.com — body metrics, scales, blood pressure", + "help_text": "Apply at developer.garmin.com — approval ~2 business days, requires a business entity.", + "has_webhook": True, + "sync_service": "garmin_sync", }, "whoop": { "name": "WHOOP", "type": "oauth2", + "category": "device", "fields": ["client_id", "client_secret"], + "auth_url": "https://api.prod.whoop.com/oauth/oauth2/auth", + "token_url": "https://api.prod.whoop.com/oauth/oauth2/token", + "scopes": "read:workout,read:recovery,read:sleep,read:profile,offline", "help_url": "https://developer.whoop.com/", - "help_text": "Apply at developer.whoop.com — recovery, strain, sleep", + "help_text": "Self-serve at developer.whoop.com. Requires WHOOP device. <10 users without app approval.", + "has_webhook": True, + "sync_service": "whoop_sync", }, "oura": { "name": "Oura Ring", "type": "oauth2", + "category": "device", "fields": ["client_id", "client_secret"], + "auth_url": "https://cloud.ouraring.com/oauth/authorize", + "token_url": "https://api.ouraring.com/oauth/token", + "scopes": "daily,workout,heartrate,session", "help_url": "https://cloud.ouraring.com/v2/docs", - "help_text": "Register at cloud.ouraring.com — sleep, readiness, HRV", + "help_text": "Register at cloud.ouraring.com — self-serve, immediate access.", + "has_webhook": True, + "sync_service": "oura_sync", }, + "coros": { + "name": "COROS (via MCP)", + "type": "mcp_bridge", + "category": "device", + "fields": [], + "help_url": "https://support.coros.com/hc/en-us/articles/17085887816340", + "help_text": "COROS has an official MCP server. Add it alongside Diligence in your AI agent config.", + "has_webhook": False, + "sync_service": None, + }, + "fitbit": { + "name": "Fitbit", + "type": "oauth2", + "category": "device", + "fields": ["client_id", "client_secret"], + "auth_url": "https://www.fitbit.com/oauth2/authorize", + "token_url": "https://api.fitbit.com/oauth2/token", + "help_url": "https://dev.fitbit.com/", + "help_text": "Register at dev.fitbit.com via Google Cloud Console.", + "has_webhook": True, + "sync_service": None, + }, + "withings": { + "name": "Withings", + "type": "oauth2", + "category": "device", + "fields": ["client_id", "client_secret"], + "help_url": "https://developer.withings.com/", + "help_text": "Register at developer.withings.com — body metrics, scales, blood pressure.", + "has_webhook": False, + "sync_service": None, + }, + "suunto": { + "name": "Suunto", + "type": "oauth2", + "category": "device", + "fields": ["client_id", "client_secret"], + "help_url": "https://apizone.suunto.com/", + "help_text": "Apply at Suunto developer portal — contract required, ~2 week response.", + "has_webhook": True, + "sync_service": None, + }, + + # ═══ AI Coaching Providers ═══ + + "openai": { + "name": "OpenAI", + "type": "api_key", + "category": "ai_provider", + "fields": ["api_key"], + "base_url": "https://api.openai.com/v1", + "api_format": "openai", + "help_url": "https://platform.openai.com/api-keys", + "help_text": "Get API key from platform.openai.com. Models: gpt-4o, gpt-4o-mini.", + "default_model": "gpt-4o-mini", + }, + "openrouter": { + "name": "OpenRouter", + "type": "api_key", + "category": "ai_provider", + "fields": ["api_key"], + "base_url": "https://openrouter.ai/api/v1", + "api_format": "openai", + "help_url": "https://openrouter.ai/keys", + "help_text": "One key, 300+ models. 26 free models. Pay-as-you-go, no subscription.", + "default_model": "openai/gpt-4o-mini", + }, + "huggingface": { + "name": "Hugging Face", + "type": "api_key", + "category": "ai_provider", + "fields": ["api_key"], + "base_url": "https://router.huggingface.co/v1", + "api_format": "openai", + "help_url": "https://huggingface.co/settings/tokens", + "help_text": "Free HF token. Thousands of open-source models via 20+ inference providers.", + "default_model": "meta-llama/Llama-3.3-70B-Instruct", + }, + "groq": { + "name": "Groq", + "type": "api_key", + "category": "ai_provider", + "fields": ["api_key"], + "base_url": "https://api.groq.com/openai/v1", + "api_format": "openai", + "help_url": "https://console.groq.com/keys", + "help_text": "Ultra-fast inference. Free tier available. Also used for program research.", + "default_model": "llama-3.3-70b-versatile", + }, + "ollama": { + "name": "Ollama (Local)", + "type": "endpoint", + "category": "ai_provider", + "fields": ["endpoint_url"], + "base_url": "http://host.docker.internal:11434/v1", + "api_format": "openai", + "help_url": "https://ollama.com/", + "help_text": "Run LLMs locally. No API key, no cost. Default: http://host.docker.internal:11434", + "default_model": "llama3.1", + }, + "claude": { + "name": "Anthropic Claude", + "type": "api_key", + "category": "ai_provider", + "fields": ["api_key"], + "base_url": "https://api.anthropic.com/v1", + "api_format": "anthropic", + "help_url": "https://console.anthropic.com/", + "help_text": "Get API key from console.anthropic.com. Models: claude-sonnet-4-6, claude-opus-4-8.", + "default_model": "claude-sonnet-4-6", + }, + "gemini": { + "name": "Google Gemini", + "type": "api_key", + "category": "ai_provider", + "fields": ["api_key"], + "base_url": "https://generativelanguage.googleapis.com/v1beta", + "api_format": "gemini", + "help_url": "https://aistudio.google.com/apikey", + "help_text": "Get API key from AI Studio. Models: gemini-2.0-flash, gemini-2.5-pro.", + "default_model": "gemini-2.0-flash", + }, + "custom_ai": { + "name": "Custom (OpenAI-compatible)", + "type": "endpoint", + "category": "ai_provider", + "fields": ["endpoint_url", "api_key"], + "api_format": "openai", + "help_url": "", + "help_text": "Any server with an OpenAI-compatible /v1/chat/completions endpoint (vLLM, TGI, LiteLLM).", + "default_model": "", + }, + + # ═══ Nutrition & Data ═══ + "usda": { "name": "USDA FoodData Central", "type": "api_key", + "category": "nutrition", "fields": ["api_key"], "help_url": "https://fdc.nal.usda.gov/api-key-signup", "help_text": "Free API key, no approval needed. 400K+ foods with research-grade nutrition data.", @@ -68,22 +216,20 @@ PROVIDER_REGISTRY: dict[str, dict] = { "nutritionix": { "name": "Nutritionix", "type": "api_key", + "category": "nutrition", "fields": ["app_id", "api_key"], "help_url": "https://developer.nutritionix.com/", "help_text": "Free tier: 1K calls/month. Natural language food parsing.", }, - "groq": { - "name": "Groq (Program Research)", - "type": "api_key", - "fields": ["api_key"], - "help_url": "https://console.groq.com/keys", - "help_text": "Free API key. Enables AI-powered workout program extraction.", - }, + + # ═══ Notifications ═══ + "telegram": { "name": "Telegram Notifications", "type": "api_key", + "category": "notifications", "fields": ["bot_token", "chat_id"], "help_url": "https://core.telegram.org/bots#botfather", - "help_text": "Create a bot via @BotFather, then get your chat ID", + "help_text": "Create a bot via @BotFather, then get your chat ID.", }, } diff --git a/backend/app/utils/auth.py b/backend/app/utils/auth.py index b0835ce..74e9ba2 100644 --- a/backend/app/utils/auth.py +++ b/backend/app/utils/auth.py @@ -13,7 +13,8 @@ from app.config import get_settings from app.database import get_db settings = get_settings() -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") +ALGORITHM = "HS256" # Hardcoded — not configurable for security +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False) def hash_password(password: str) -> str: @@ -27,11 +28,11 @@ def verify_password(plain: str, hashed: str) -> bool: def create_access_token(user_id: str, expires_delta: timedelta | None = None) -> str: expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.access_token_expire_minutes)) payload = {"sub": user_id, "exp": expire} - return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm) + return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM) async def get_current_user( - token: Annotated[str, Depends(oauth2_scheme)], + token: Annotated[str | None, Depends(oauth2_scheme)], db: Annotated[AsyncSession, Depends(get_db)], ): credentials_exception = HTTPException( @@ -39,6 +40,30 @@ async def get_current_user( detail="Invalid authentication credentials", headers={"WWW-Authenticate": "Bearer"}, ) + + if not token: + raise credentials_exception + + from app.models.user import User + + # Check if this is an API token (MCP connector auth) + if settings.api_token and token == settings.api_token: + # Map to the first admin user + result = await db.execute( + select(User).where(User.is_admin == True).order_by(User.created_at.asc()).limit(1) + ) + user = result.scalar_one_or_none() + if user is None: + # Fall back to first user if no admin exists yet + result = await db.execute( + select(User).order_by(User.created_at.asc()).limit(1) + ) + user = result.scalar_one_or_none() + if user is None: + raise credentials_exception + return user + + # Standard JWT validation try: payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) user_id: str = payload.get("sub") @@ -47,7 +72,6 @@ async def get_current_user( except JWTError: raise credentials_exception - from app.models.user import User result = await db.execute(select(User).where(User.id == uuid.UUID(user_id))) user = result.scalar_one_or_none() if user is None: diff --git a/docker-compose.yml b/docker-compose.yml index ed1eb51..54633e6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,6 +2,8 @@ services: frontend: build: ./frontend restart: unless-stopped + ports: + - "80:80" depends_on: backend: condition: service_healthy @@ -18,6 +20,7 @@ services: env_file: .env environment: - BASE_URL=${BASE_URL:-http://localhost} + - API_TOKEN=${API_TOKEN:-} depends_on: fitness-db: condition: service_healthy @@ -33,6 +36,7 @@ services: restart: unless-stopped environment: - FITNESS_API_URL=http://backend:8000 + - API_TOKEN=${API_TOKEN:-} depends_on: backend: condition: service_healthy diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 327f3a8..e69de29 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,124 +0,0 @@ -import { Routes, Route, Navigate, NavLink, useNavigate, useLocation } from 'react-router-dom' -import { useState, useEffect } from 'react' -import { hasToken, clearToken, api } from './api' -import Login from './pages/Login' -import Dashboard from './pages/Dashboard' -import LogActivity from './pages/LogActivity' -import LogFood from './pages/LogFood' -import Nutrition from './pages/Nutrition' -import Rewards from './pages/Rewards' -import Settings from './pages/Settings' -import Onboarding from './pages/Onboarding' -import WeekView from './pages/WeekView' -import Welcome from './pages/Welcome' -import SettingsIntegrations from './pages/SettingsIntegrations' -import MealPlan from './pages/MealPlan' -import ProgramSearch from './pages/ProgramSearch' -import ProgramDetail from './pages/ProgramDetail' -import CatalogDetail from './pages/CatalogDetail' -import Support from './pages/Support' -import SupportAdmin from './pages/SupportAdmin' - -function ProtectedRoute({ children }) { - if (!hasToken()) return - return children -} - -function HelpButton() { - const [unread, setUnread] = useState(0) - const navigate = useNavigate() - const location = useLocation() - - // Don't show on login/onboarding/support pages - const hidden = ['/login', '/onboarding', '/support'].some(p => location.pathname.startsWith(p)) - if (hidden) return null - - useEffect(() => { - if (!hasToken()) return - api.getUnreadCount() - .then(d => setUnread(d.unread || 0)) - .catch(() => {}) - // Poll every 60s for new replies - const interval = setInterval(() => { - api.getUnreadCount() - .then(d => setUnread(d.unread || 0)) - .catch(() => {}) - }, 60000) - return () => clearInterval(interval) - }, [location.pathname]) - - return ( - - ) -} - -function NavBar() { - return ( - - ) -} - -export default function App() { - return ( - <> - {hasToken() && } - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - {hasToken() && } - - ) -} diff --git a/frontend/src/api.js b/frontend/src/api.js index 93db0f1..c027719 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -139,6 +139,10 @@ export const api = { listProviders: () => request('/integrations/providers'), configureIntegration: (provider, credentials) => request('/integrations/configure', { method: 'POST', body: JSON.stringify({ provider, credentials }) }), + // AI Coaching + getAIStatus: () => request('/ai/status'), + // Note: chatWithAI uses fetch directly in ChatCoach.jsx for SSE streaming + // Resources getResourceRecommendations: () => request('/onboarding/recommendations'), }; diff --git a/frontend/src/index.css b/frontend/src/index.css index 990283c..5cd6aae 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,51 +1,49 @@ -@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800;900&family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap'); +@import url('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 { - /* === Solar Momentum Palette === */ - --orange: #FF5722; - --orange-dark: #E64A19; - --orange-light: #FF8A65; - --orange-ghost: rgba(255, 87, 34, 0.06); - --orange-glow: rgba(255, 87, 34, 0.18); + /* === DW Brand — Light === */ + --accent: #2952CC; + --accent-dark: #1e3fa8; + --accent-light: #4A78E0; + --accent-bg: #edf1fc; + --accent-glow: rgba(41, 82, 204, 0.18); - --green: #00C853; - --green-dark: #00A844; - --green-light: #69F0AE; - --green-ghost: rgba(0, 200, 83, 0.06); + --green: #0d7d5a; + --green-dark: #065c40; + --green-light: #30C090; + --green-bg: rgba(13, 125, 90, 0.08); - --blue: #2979FF; - --blue-ghost: rgba(41, 121, 255, 0.06); - --purple: #7C4DFF; - --purple-ghost: rgba(124, 77, 255, 0.06); + --red: #C62828; + --red-bg: rgba(198, 40, 40, 0.06); - --red: #FF1744; - --red-ghost: rgba(255, 23, 68, 0.06); - --amber: #FFB300; + --amber: #b07800; + --amber-bg: rgba(176, 120, 0, 0.08); - --text: #1B1B2F; - --text-2: #4A4A68; - --text-3: #8888A4; + --text: #1a1f2e; + --text-2: #3d4660; + --text-3: #6b7490; --text-inv: #FFFFFF; - --bg: #F5F3EF; - --bg-warm: linear-gradient(170deg, #FFF3E8 0%, #F5F3EF 50%, #EDF2FF 100%); + --bg: #fafbfe; + --bg-warm: linear-gradient(170deg, #f0f2fa 0%, #fafbfe 50%, #f5f7fd 100%); --card: #FFFFFF; - --card-border: rgba(0,0,0,0.04); - --divider: rgba(0,0,0,0.06); + --card-border: rgba(216, 220, 232, 0.6); + --divider: rgba(216, 220, 232, 0.8); - --shadow-1: 0 1px 3px rgba(27,27,47,0.05); - --shadow-2: 0 4px 16px rgba(27,27,47,0.08); - --shadow-3: 0 12px 40px rgba(27,27,47,0.12); - --shadow-orange: 0 4px 20px rgba(255,87,34,0.25); - --shadow-green: 0 4px 20px rgba(0,200,83,0.25); + --shadow-1: 0 1px 3px rgba(26, 31, 46, 0.06); + --shadow-2: 0 4px 16px rgba(26, 31, 46, 0.08); + --shadow-3: 0 12px 40px rgba(26, 31, 46, 0.12); + --shadow-accent: 0 4px 20px rgba(41, 82, 204, 0.20); + --shadow-green: 0 4px 20px rgba(13, 125, 90, 0.20); - --r: 14px; - --r-sm: 10px; - --r-lg: 20px; + --r: 8px; + --r-sm: 6px; + --r-lg: 12px; --r-full: 999px; - --font: 'Plus Jakarta Sans', system-ui, sans-serif; - --font-display: 'Outfit', system-ui, sans-serif; + --font: 'Instrument Sans', -apple-system, sans-serif; + --font-display: 'Instrument Sans', -apple-system, sans-serif; + --font-mono: 'IBM Plex Mono', monospace; } /* === Reset === */ @@ -64,7 +62,7 @@ body { /* === Typography === */ h1, h2, h3 { font-family: var(--font-display); letter-spacing: -0.02em; line-height: 1.2; } -a { color: var(--blue); text-decoration: none; font-weight: 600; } +a { color: var(--accent); text-decoration: none; font-weight: 600; } a:hover { opacity: 0.8; } /* === Buttons === */ @@ -83,11 +81,11 @@ button { button:active { transform: scale(0.97); } .btn-primary { - background: var(--orange); + background: var(--accent); color: var(--text-inv); - box-shadow: var(--shadow-orange); + box-shadow: var(--shadow-accent); } -.btn-primary:hover { background: var(--orange-dark); } +.btn-primary:hover { background: var(--accent-dark); } .btn-primary:disabled { opacity: 0.4; cursor: not-allowed; transform: none; box-shadow: none; } .btn-success { @@ -101,14 +99,14 @@ button:active { transform: scale(0.97); } border: 2px solid var(--divider); color: var(--text); } -.btn-outline:hover { border-color: var(--orange); color: var(--orange); } +.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(--orange-ghost); color: var(--orange); } +.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: 0.82rem; border-radius: var(--r-sm); } @@ -129,8 +127,8 @@ input, select, textarea { transition: border-color 0.2s; } input:focus, select:focus, textarea:focus { - border-color: var(--orange); - box-shadow: 0 0 0 3px var(--orange-glow); + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); } input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 400; } @@ -157,7 +155,7 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4 .page-title { font-family: var(--font-display); font-size: 1.5rem; - font-weight: 800; + font-weight: 600; margin-bottom: 18px; } @@ -181,8 +179,9 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4 /* === Section Title (small label) === */ .section-label { + font-family: var(--font-mono); font-size: 0.72rem; - font-weight: 800; + font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-3); @@ -203,6 +202,7 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4 .checklist-check { font-size: 1.15rem; min-width: 26px; } .checklist-points { margin-left: auto; + font-family: var(--font-mono); font-size: 0.78rem; font-weight: 700; color: var(--text-3); @@ -239,13 +239,13 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4 border-radius: var(--r-sm); position: relative; } -.nav-item.active { color: var(--orange); } +.nav-item.active { color: var(--accent); } .nav-item.active::before { content: ''; position: absolute; top: -4px; left: 50%; transform: translateX(-50%); width: 20px; height: 3px; - background: var(--orange); + background: var(--accent); border-radius: 2px; } .nav-icon { font-size: 1.2rem; margin-bottom: 1px; } @@ -280,11 +280,11 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4 transition: all 0.2s; box-shadow: var(--shadow-1); } -.option-btn:hover { border-color: var(--orange-light); transform: translateY(-2px); box-shadow: var(--shadow-2); } +.option-btn:hover { border-color: var(--accent-light); transform: translateY(-2px); box-shadow: var(--shadow-2); } .option-btn.selected { - border-color: var(--orange); - background: var(--orange-ghost); - box-shadow: 0 0 0 3px var(--orange-glow); + border-color: var(--accent); + background: var(--accent-bg); + box-shadow: 0 0 0 3px var(--accent-glow); } /* === Chips === */ @@ -299,10 +299,10 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4 cursor: pointer; transition: all 0.2s; } -.chip:hover { border-color: var(--orange-light); } +.chip:hover { border-color: var(--accent-light); } .chip.selected { - border-color: var(--orange); - background: var(--orange); + border-color: var(--accent); + background: var(--accent); color: var(--text-inv); } @@ -330,19 +330,19 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4 background: var(--card); border: 2px solid var(--divider); color: var(--text); - font-weight: 800; + font-weight: 600; font-size: 0.88rem; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s; box-shadow: var(--shadow-1); } -.likert-btn:hover { border-color: var(--orange); transform: scale(1.1); } +.likert-btn:hover { border-color: var(--accent); transform: scale(1.1); } .likert-btn.selected { border-color: transparent; - background: var(--orange); + background: var(--accent); color: var(--text-inv); - box-shadow: var(--shadow-orange); + box-shadow: var(--shadow-accent); transform: scale(1.1); } .likert-labels { @@ -372,9 +372,9 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4 border: 2px solid rgba(255,87,34,0.15); } .gate-pts { - font-family: var(--font-display); + font-family: var(--font-mono); font-size: 2.8rem; - font-weight: 900; + font-weight: 700; letter-spacing: -0.04em; line-height: 1; margin: 8px 0; @@ -384,7 +384,7 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4 .meal-section { margin-bottom: 18px; } .meal-title { font-size: 0.72rem; - font-weight: 800; + font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-3); @@ -433,6 +433,106 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4 .gate-banner { animation: popIn 0.35s cubic-bezier(.4,0,.2,1) both; } .gate-pts { animation: countUp 0.4s ease-out 0.15s both; } + +/* === Dark Mode === */ +@media (prefers-color-scheme: dark) { + :root { + --accent: #6B9BFF; + --accent-dark: #4A78E0; + --accent-light: #8BB5FF; + --accent-bg: #101630; + --accent-glow: rgba(107, 155, 255, 0.18); + + --green: #30C090; + --green-dark: #20A070; + --green-light: #50D8A8; + --green-bg: rgba(48, 192, 144, 0.10); + + --red: #EF5350; + --red-bg: rgba(239, 83, 80, 0.10); + + --amber: #F0B040; + --amber-bg: rgba(240, 176, 64, 0.10); + + --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, 0.8); + --divider: rgba(34, 40, 64, 0.8); + + --shadow-1: 0 1px 3px rgba(0, 0, 0, 0.3); + --shadow-2: 0 4px 16px rgba(0, 0, 0, 0.4); + --shadow-3: 0 12px 40px rgba(0, 0, 0, 0.5); + --shadow-accent: 0 4px 20px rgba(107, 155, 255, 0.15); + --shadow-green: 0 4px 20px rgba(48, 192, 144, 0.15); + } + + body { background: var(--bg); color: var(--text); } + + .nav-bar { + background: rgba(10, 11, 18, 0.92); + } + + input, select, textarea { + background: var(--bg); + color: var(--text); + } + + .gate-earned { + background: linear-gradient(135deg, rgba(48,192,144,0.12) 0%, rgba(48,192,144,0.06) 100%); + border-color: rgba(48,192,144,0.25); + } + + .gate-locked { + background: linear-gradient(135deg, rgba(107,155,255,0.10) 0%, rgba(107,155,255,0.05) 100%); + border-color: rgba(107,155,255,0.15); + } + + .option-btn, .chip { + background: var(--card); + } + + .likert-btn { + background: var(--card); + } +} + +/* === Manual theme override === */ +:root[data-theme="dark"] { + --accent: #6B9BFF; + --accent-dark: #4A78E0; + --accent-light: #8BB5FF; + --accent-bg: #101630; + --accent-glow: rgba(107, 155, 255, 0.18); + --green: #30C090; + --green-dark: #20A070; + --green-light: #50D8A8; + --green-bg: rgba(48, 192, 144, 0.10); + --red: #EF5350; + --red-bg: rgba(239, 83, 80, 0.10); + --amber: #F0B040; + --amber-bg: rgba(240, 176, 64, 0.10); + --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, 0.8); + --divider: rgba(34, 40, 64, 0.8); + --shadow-1: 0 1px 3px rgba(0, 0, 0, 0.3); + --shadow-2: 0 4px 16px rgba(0, 0, 0, 0.4); + --shadow-3: 0 12px 40px rgba(0, 0, 0, 0.5); + --shadow-accent: 0 4px 20px rgba(107, 155, 255, 0.15); + --shadow-green: 0 4px 20px rgba(48, 192, 144, 0.15); +} + /* === Scrollbar === */ ::-webkit-scrollbar { width: 4px; } ::-webkit-scrollbar-track { background: transparent; } diff --git a/frontend/src/pages/CatalogDetail.jsx b/frontend/src/pages/CatalogDetail.jsx index cc46e43..d3d3bf5 100644 --- a/frontend/src/pages/CatalogDetail.jsx +++ b/frontend/src/pages/CatalogDetail.jsx @@ -4,8 +4,8 @@ import { api } from '../api' const DIFFICULTY_COLORS = { beginner: 'var(--green)', - intermediate: 'var(--blue)', - advanced: 'var(--purple)', + intermediate: 'var(--accent)', + advanced: 'var(--accent-light)', } const CATEGORY_ICONS = { @@ -17,8 +17,8 @@ const CATEGORY_ICONS = { const STATUS_LABELS = { pending: { label: 'Queued', color: 'var(--amber)' }, - crawling: { label: 'Fetching...', color: 'var(--blue)' }, - extracting: { label: 'Analyzing...', color: 'var(--purple)' }, + 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)' }, } @@ -72,7 +72,7 @@ export default function CatalogDetail() {

Program not found.

) @@ -144,7 +144,7 @@ export default function CatalogDetail() { rel="noopener noreferrer" style={{ display: 'inline-block', marginTop: '14px', fontSize: '0.78rem', - color: 'var(--blue)', textDecoration: 'none', + color: 'var(--accent)', textDecoration: 'none', }} > 🔗 Original source ↗ @@ -157,7 +157,7 @@ export default function CatalogDetail() {
{message.text} @@ -183,9 +183,9 @@ export default function CatalogDetail() { + ))} +
+ + )} + + {messages.map((msg, i) => ( +
+
+ {msg.content || (streaming && i === messages.length - 1 ? '...' : '')} +
+
+ ))} +
+
+ +
+ setInput(e.target.value)} + onKeyDown={e => e.key === 'Enter' && !e.shiftKey && sendMessage()} + placeholder={streaming ? 'Thinking...' : 'Ask your AI coach...'} + disabled={streaming} + style={{ flex: 1 }} + /> + +
+ + ) +} diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 0e95cdc..6227b83 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -76,8 +76,8 @@ export default function Dashboard() { const polarConnected = integrations?.polar?.connected const activities = [ - { key: 'workout', label: 'Workout', icon: '💪', pts: 50, color: '#FF5722', 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: '#4CAF50', tip: 'Log what you eat. Barcode scan or manual entry. Building food awareness is a key habit.' }, + { 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.' }, @@ -90,7 +90,7 @@ export default function Dashboard() {
status.program_id && navigate(`/programs/${status.program_id}`)} style={{ - background: 'var(--blue-ghost)', borderRadius: 'var(--r)', padding: '12px 16px', + 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: status.program_id ? 'pointer' : 'default', @@ -98,15 +98,15 @@ export default function Dashboard() { >
📋
-
{status.program_name}
+
{status.program_name}
-
+
{status.program_day}/{status.program_total_days}
@@ -120,7 +120,7 @@ export default function Dashboard() {
- + {status.points_earned} @@ -132,7 +132,7 @@ export default function Dashboard() { width: `${pct}%`, background: status.gate_passed ? 'linear-gradient(90deg, #00C853, #69F0AE)' - : `linear-gradient(90deg, #FF5722, #FF8A65)`, + : `linear-gradient(90deg, var(--accent), var(--accent-light))`, }} />
@@ -178,7 +178,7 @@ export default function Dashboard() {
{/* === Week Progress === */} -
+
@@ -202,7 +202,7 @@ export default function Dashboard() { {/* === Rewards === */} {status.gate_passed && status.rewards_available.length > 0 && ( -
+
🎮 Rewards Available
{status.rewards_available.map(r => (
diff --git a/frontend/src/pages/LogActivity.jsx b/frontend/src/pages/LogActivity.jsx index 26acb7f..d4d4688 100644 --- a/frontend/src/pages/LogActivity.jsx +++ b/frontend/src/pages/LogActivity.jsx @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom' import { api } from '../api' const CATEGORIES = [ - { value: 'workout', label: 'Workout', icon: '💪', desc: 'Any exercise session', color: '#FF5722' }, + { 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' }, @@ -100,7 +100,7 @@ export default function LogActivity() { {success && (
🎉
{success}
@@ -129,20 +129,20 @@ export default function LogActivity() {
navigate(`/programs/${activeProgram.id}`)} style={{ - background: 'var(--blue-ghost)', borderRadius: 'var(--r-sm)', padding: '10px 14px', + 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)', }} >
-
+
Active Program
{activeProgram.name}
-
+
Week {activeProgram.current_week || 1} →
@@ -176,7 +176,7 @@ export default function LogActivity() { {!todayWorkout && upcomingWorkouts.length === 0 && (
✓ All workouts for this week are complete. Log a freeform workout below or rest up. @@ -223,11 +223,11 @@ function ProgramWorkoutCard({ workout, featured, completing, onComplete }) {
{featured && (
⭐ Today diff --git a/frontend/src/pages/LogFood.jsx b/frontend/src/pages/LogFood.jsx index ca05719..6d82856 100644 --- a/frontend/src/pages/LogFood.jsx +++ b/frontend/src/pages/LogFood.jsx @@ -89,7 +89,7 @@ export default function LogFood() {

Food Log

- {success &&
{success}
} + {success &&
{success}
} {/* Tab bar */}
diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index 097b6ac..1fe4fe6 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -31,7 +31,7 @@ export default function Login() { minHeight: '100dvh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '24px', - background: 'linear-gradient(170deg, #FF8A65 0%, #FF5722 30%, #E64A19 60%, #1B1B2F 100%)', + background: 'linear-gradient(170deg, var(--accent-light) 0%, var(--accent) 30%, var(--accent-dark) 60%, var(--text) 100%)', }}>
{/* Brand */} @@ -61,9 +61,9 @@ export default function Login() { style={{ borderRadius: 'var(--r-sm)', padding: '10px', fontWeight: 700, fontSize: '0.85rem', - background: mode === m ? 'var(--orange)' : 'transparent', + background: mode === m ? 'var(--accent)' : 'transparent', color: mode === m ? '#fff' : 'var(--text-2)', - boxShadow: mode === m ? 'var(--shadow-orange)' : 'none', + boxShadow: mode === m ? 'var(--shadow-accent)' : 'none', }}> {m === 'login' ? 'Sign In' : 'Sign Up'} diff --git a/frontend/src/pages/MealPlan.jsx b/frontend/src/pages/MealPlan.jsx index d3c75f7..71c8504 100644 --- a/frontend/src/pages/MealPlan.jsx +++ b/frontend/src/pages/MealPlan.jsx @@ -75,9 +75,9 @@ export default function MealPlan() {
@@ -141,8 +141,8 @@ export default function MealPlan() {
{p.status} diff --git a/frontend/src/pages/Nutrition.jsx b/frontend/src/pages/Nutrition.jsx index c431070..07abb2b 100644 --- a/frontend/src/pages/Nutrition.jsx +++ b/frontend/src/pages/Nutrition.jsx @@ -145,7 +145,7 @@ export default function Nutrition() {
Today's Keto Day
+ color: c.compliant_day ? 'var(--green)' : 'var(--accent)' }}> {c.compliant_day ? '✓ Compliant' : '⏳ In progress'}
@@ -196,7 +196,7 @@ export default function Nutrition() {
Today's Macros
- +
)} {detailed && exercise.weight_instruction && ( -
+
🏋️ {exercise.weight_instruction}
)} diff --git a/frontend/src/pages/ProgramSearch.jsx b/frontend/src/pages/ProgramSearch.jsx index f79db12..d21096b 100644 --- a/frontend/src/pages/ProgramSearch.jsx +++ b/frontend/src/pages/ProgramSearch.jsx @@ -4,8 +4,8 @@ import { api } from '../api' const DIFFICULTY_COLORS = { beginner: 'var(--green)', - intermediate: 'var(--blue)', - advanced: 'var(--purple)', + intermediate: 'var(--accent)', + advanced: 'var(--accent-light)', } const CATEGORY_ICONS = { @@ -17,8 +17,8 @@ const CATEGORY_ICONS = { const STATUS_LABELS = { pending: { label: 'Queued', color: 'var(--amber)' }, - crawling: { label: 'Fetching...', color: 'var(--blue)' }, - extracting: { label: 'Analyzing...', color: 'var(--purple)' }, + 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)' }, } @@ -117,7 +117,7 @@ export default function ProgramSearch() { }} /> @@ -126,9 +126,9 @@ export default function ProgramSearch() { onClick={handleResearch} disabled={researching || !query.trim()} style={{ - width: '100%', background: researching ? 'var(--text-3)' : 'var(--orange)', + width: '100%', background: researching ? 'var(--text-3)' : 'var(--accent)', color: '#fff', padding: '14px', marginBottom: '1rem', - boxShadow: researching ? 'none' : 'var(--shadow-orange)', + boxShadow: researching ? 'none' : 'var(--shadow-accent)', opacity: !query.trim() ? 0.5 : 1, }} > @@ -140,8 +140,8 @@ export default function ProgramSearch() {
{message.text}
@@ -160,12 +160,12 @@ export default function ProgramSearch() { style={{ background: 'var(--card)', borderRadius: 'var(--r)', padding: '16px', marginBottom: '0.75rem', boxShadow: 'var(--shadow-1)', cursor: 'pointer', - border: '2px solid var(--orange-glow)', + border: '2px solid var(--accent-glow)', }} >
{p.name} - + Day {p.current_day}/{p.total_days}
@@ -173,7 +173,7 @@ export default function ProgramSearch() { marginTop: '8px', height: '6px', borderRadius: '3px', background: 'var(--divider)', }}>
@@ -272,7 +272,7 @@ export default function ProgramSearch() { onClick={() => navigate(`/catalog/${p.id}`)} style={{ marginTop: adopted ? '4px' : '8px', width: '100%', background: 'transparent', - color: 'var(--blue)', padding: '8px', fontSize: '0.82rem', + color: 'var(--accent)', padding: '8px', fontSize: '0.82rem', border: '1px solid var(--divider)', }} > diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index c7e946b..98c80b0 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -47,7 +47,7 @@ export default function Settings() {
@@ -60,6 +60,75 @@ export default function Settings() {
)} + {/* Quick Links */} +
+
Features
+
navigate('/meal-plan')} + style={{ + display: 'flex', alignItems: 'center', justifyContent: 'space-between', + padding: '14px 0', borderBottom: '1px solid var(--divider)', cursor: 'pointer', + }} + > +
+ 🍽️ +
+
Meal Plans
+
AI-generated plans with compliance tracking
+
+
+ +
+
navigate('/settings/integrations')} + style={{ + display: 'flex', alignItems: 'center', justifyContent: 'space-between', + padding: '14px 0', borderBottom: '1px solid var(--divider)', cursor: 'pointer', + }} + > +
+ 🔗 +
+
All Integrations
+
Strava, Polar, Garmin, Fitbit, USDA, and more
+
+
+ +
+
navigate('/rewards')} + style={{ + display: 'flex', alignItems: 'center', justifyContent: 'space-between', + padding: '14px 0', borderBottom: '1px solid var(--divider)', cursor: 'pointer', + }} + > +
+ 🎮 +
+
Rewards
+
Configure and redeem your rewards
+
+
+ +
+
navigate('/week')} + style={{ + display: 'flex', alignItems: 'center', justifyContent: 'space-between', + padding: '14px 0', cursor: 'pointer', + }} + > +
+ 📊 +
+
Week View
+
Weekly progress and day-by-day breakdown
+
+
+ +
+
+ {/* Point Rules */}
Point Rules
@@ -119,6 +188,12 @@ export default function Settings() {
) })} +
navigate('/settings/integrations')} + style={{ padding: '12px 0', textAlign: 'center', cursor: 'pointer', color: 'var(--accent)', fontSize: '0.88rem', fontWeight: 600 }} + > + Configure all 11 providers → +