diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 3ac26d2..0000000 --- a/.dockerignore +++ /dev/null @@ -1,11 +0,0 @@ -.git -.env -.env.* -!.env.example -__pycache__ -*.pyc -content/ -node_modules/ -dist/ -*.log -.DS_Store diff --git a/.env.example b/.env.example index 4b0fdbf..32a93d9 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,5 @@ # === REQUIRED (generated automatically by setup.sh) === SECRET_KEY= -API_TOKEN= # === APP URL (change for production) === BASE_URL=http://localhost @@ -9,11 +8,6 @@ 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 74f9dd5..ddd2dad 100644 --- a/AGENT_GUIDE.md +++ b/AGENT_GUIDE.md @@ -97,31 +97,3 @@ 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 deleted file mode 100644 index 8da9836..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,27 +0,0 @@ -# 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 deleted file mode 100644 index c14bc4a..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,31 +0,0 @@ -# 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 deleted file mode 100644 index 331d702..0000000 --- a/OUTSTANDING.md +++ /dev/null @@ -1,101 +0,0 @@ -# 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 37fb408..63dd685 100644 --- a/README.md +++ b/README.md @@ -6,65 +6,6 @@ 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 @@ -72,30 +13,7 @@ cd diligence docker compose up -d ``` ---- - -### 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`. +Open http://localhost and create your account. ## Features @@ -108,71 +26,17 @@ You should see 4 containers, all healthy: `frontend`, `backend`, `mcp-connector` - **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 +## Connecting an AI Agent -Diligence includes a built-in AI coaching chat. Configure any LLM provider in **Settings → Integrations**, then open the **Coach** tab. +Point your agent's MCP config to `http://localhost:3001/sse` (development) or `https://your-domain/mcp` (production behind reverse proxy). -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" } - } -} -``` +Works with Claude Desktop, Cursor, Windsurf, and any MCP-compatible agent. 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. +All integrations are configured through the app UI (Settings → Integrations) or through your AI agent. No `.env` file editing or container restarts needed. ## Data Sovereignty @@ -180,57 +44,17 @@ 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 (async) +- Python 3.12, FastAPI, SQLAlchemy - 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. +Contributions welcome. Open an issue to discuss before submitting a PR. ## License diff --git a/backend/.dockerignore b/backend/.dockerignore deleted file mode 100644 index 3ac26d2..0000000 --- a/backend/.dockerignore +++ /dev/null @@ -1,11 +0,0 @@ -.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 3c2be33..4ffe6be 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -10,9 +10,8 @@ class Settings(BaseSettings): # Auth secret_key: str = "change-me-in-production" - crawl_enabled: bool = False # Set CRAWL_ENABLED=true to start program crawl scheduler + algorithm: str = "HS256" 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 be39c0e..55a3c36 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import sys import logging from contextlib import asynccontextmanager from fastapi import FastAPI @@ -28,13 +27,6 @@ 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() @@ -49,17 +41,14 @@ async def lifespan(app: FastAPI): except Exception as e: logger.warning(f"Resource seeding failed (non-fatal): {e}") - # Start background crawl queue scheduler (gated on CRAWL_ENABLED) + # Start background crawl queue scheduler 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)") + 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}") logger.info("Fitness Rewards backend started") yield @@ -79,7 +68,7 @@ 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_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @@ -96,7 +85,6 @@ 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) @@ -110,7 +98,6 @@ 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 deleted file mode 100644 index e9b7b24..0000000 --- a/backend/app/routers/ai_chat.py +++ /dev/null @@ -1,64 +0,0 @@ -"""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 517998d..fd3e487 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,10 +1,11 @@ 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, func +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db @@ -25,16 +26,11 @@ 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() @@ -56,8 +52,9 @@ async def register(req: RegisterRequest, db: Annotated[AsyncSession, Depends(get except HTTPException: raise except Exception as e: - logger.error(f"Registration failed: {e}", exc_info=True) - return JSONResponse(status_code=500, content={"detail": "Internal server error"}) + tb = traceback.format_exc() + logger.error(f"Registration failed: {e}\n{tb}") + return JSONResponse(status_code=500, content={"detail": str(e), "traceback": tb}) @router.post("/login") @@ -72,8 +69,9 @@ async def login(req: LoginRequest, db: Annotated[AsyncSession, Depends(get_db)]) except HTTPException: raise except Exception as e: - logger.error(f"Login failed: {e}", exc_info=True) - return JSONResponse(status_code=500, content={"detail": "Internal server error"}) + tb = traceback.format_exc() + logger.error(f"Login failed: {e}\n{tb}") + return JSONResponse(status_code=500, content={"detail": str(e), "traceback": tb}) @router.get("/me") @@ -84,5 +82,4 @@ 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 e3eb075..8562a7b 100644 --- a/backend/app/routers/integrations.py +++ b/backend/app/routers/integrations.py @@ -39,10 +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 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)} + return {"auth_url": get_strava_auth_url(str(user.id))} @router.get("/strava/callback") @@ -51,14 +48,7 @@ async def strava_callback( state: str = Query(...), db: AsyncSession = Depends(get_db), ): - 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") + user_id = uuid_mod.UUID(state) data = await exchange_strava_code(code) # Upsert token @@ -101,10 +91,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 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)} + return {"auth_url": get_polar_auth_url(str(user.id))} @router.get("/polar/callback") @@ -113,14 +100,7 @@ async def polar_callback( state: str = Query(...), db: AsyncSession = Depends(get_db), ): - 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") + user_id = uuid_mod.UUID(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 c8cb767..c243602 100644 --- a/backend/app/routers/support.py +++ b/backend/app/routers/support.py @@ -24,6 +24,7 @@ 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 @@ -278,8 +279,8 @@ async def get_unread_count( # ── Admin Endpoints ──────────────────────────────────────────────────────── def require_admin(user: User): - """Check that the user has admin privileges.""" - if not getattr(user, "is_admin", False): + """Check that the user is the admin.""" + if user.username != ADMIN_USERNAME: raise HTTPException(status_code=403, detail="Admin access required") diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 9d2b238..5cbd802 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, Field +from pydantic import BaseModel class LoginRequest(BaseModel): - username: str = Field(min_length=3, max_length=50) - password: str = Field(min_length=8, max_length=128) + username: str + password: str class RegisterRequest(BaseModel): - 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) + username: str + password: str + display_name: str email: str | None = None diff --git a/backend/app/services/ai_provider.py b/backend/app/services/ai_provider.py deleted file mode 100644 index fa1ea7a..0000000 --- a/backend/app/services/ai_provider.py +++ /dev/null @@ -1,316 +0,0 @@ -"""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 deleted file mode 100644 index b88bd25..0000000 --- a/backend/app/services/device_sync_base.py +++ /dev/null @@ -1,179 +0,0 @@ -"""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 0789659..99e2b15 100644 --- a/backend/app/services/provider_registry.py +++ b/backend/app/services/provider_registry.py @@ -2,213 +2,65 @@ 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 ~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": "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 — 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, + "help_text": "Apply at developer.garmin.com — approval takes 1-4 weeks", }, "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, + "help_text": "Register at dev.fitbit.com", }, "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, + "help_text": "Register at developer.withings.com — body metrics, scales, blood pressure", }, - "suunto": { - "name": "Suunto", + "whoop": { + "name": "WHOOP", "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, + "help_url": "https://developer.whoop.com/", + "help_text": "Apply at developer.whoop.com — recovery, strain, sleep", }, - - # ═══ 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", + "oura": { + "name": "Oura Ring", + "type": "oauth2", + "fields": ["client_id", "client_secret"], + "help_url": "https://cloud.ouraring.com/v2/docs", + "help_text": "Register at cloud.ouraring.com — sleep, readiness, HRV", }, - "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.", @@ -216,20 +68,22 @@ 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.", }, - - # ═══ Notifications ═══ - + "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.", + }, "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 74e9ba2..b0835ce 100644 --- a/backend/app/utils/auth.py +++ b/backend/app/utils/auth.py @@ -13,8 +13,7 @@ from app.config import get_settings from app.database import get_db settings = get_settings() -ALGORITHM = "HS256" # Hardcoded — not configurable for security -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False) +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") def hash_password(password: str) -> str: @@ -28,11 +27,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=ALGORITHM) + return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm) async def get_current_user( - token: Annotated[str | None, Depends(oauth2_scheme)], + token: Annotated[str, Depends(oauth2_scheme)], db: Annotated[AsyncSession, Depends(get_db)], ): credentials_exception = HTTPException( @@ -40,30 +39,6 @@ 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") @@ -72,6 +47,7 @@ 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 54633e6..ed1eb51 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,8 +2,6 @@ services: frontend: build: ./frontend restart: unless-stopped - ports: - - "80:80" depends_on: backend: condition: service_healthy @@ -20,7 +18,6 @@ services: env_file: .env environment: - BASE_URL=${BASE_URL:-http://localhost} - - API_TOKEN=${API_TOKEN:-} depends_on: fitness-db: condition: service_healthy @@ -36,7 +33,6 @@ 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 e69de29..327f3a8 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -0,0 +1,124 @@ +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 c027719..93db0f1 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -139,10 +139,6 @@ 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 5cd6aae..990283c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,49 +1,51 @@ -@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'); +@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'); :root { - /* === DW Brand — Light === */ - --accent: #2952CC; - --accent-dark: #1e3fa8; - --accent-light: #4A78E0; - --accent-bg: #edf1fc; - --accent-glow: rgba(41, 82, 204, 0.18); + /* === 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); - --green: #0d7d5a; - --green-dark: #065c40; - --green-light: #30C090; - --green-bg: rgba(13, 125, 90, 0.08); + --green: #00C853; + --green-dark: #00A844; + --green-light: #69F0AE; + --green-ghost: rgba(0, 200, 83, 0.06); - --red: #C62828; - --red-bg: rgba(198, 40, 40, 0.06); + --blue: #2979FF; + --blue-ghost: rgba(41, 121, 255, 0.06); + --purple: #7C4DFF; + --purple-ghost: rgba(124, 77, 255, 0.06); - --amber: #b07800; - --amber-bg: rgba(176, 120, 0, 0.08); + --red: #FF1744; + --red-ghost: rgba(255, 23, 68, 0.06); + --amber: #FFB300; - --text: #1a1f2e; - --text-2: #3d4660; - --text-3: #6b7490; + --text: #1B1B2F; + --text-2: #4A4A68; + --text-3: #8888A4; --text-inv: #FFFFFF; - --bg: #fafbfe; - --bg-warm: linear-gradient(170deg, #f0f2fa 0%, #fafbfe 50%, #f5f7fd 100%); + --bg: #F5F3EF; + --bg-warm: linear-gradient(170deg, #FFF3E8 0%, #F5F3EF 50%, #EDF2FF 100%); --card: #FFFFFF; - --card-border: rgba(216, 220, 232, 0.6); - --divider: rgba(216, 220, 232, 0.8); + --card-border: rgba(0,0,0,0.04); + --divider: rgba(0,0,0,0.06); - --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); + --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); - --r: 8px; - --r-sm: 6px; - --r-lg: 12px; + --r: 14px; + --r-sm: 10px; + --r-lg: 20px; --r-full: 999px; - --font: 'Instrument Sans', -apple-system, sans-serif; - --font-display: 'Instrument Sans', -apple-system, sans-serif; - --font-mono: 'IBM Plex Mono', monospace; + --font: 'Plus Jakarta Sans', system-ui, sans-serif; + --font-display: 'Outfit', system-ui, sans-serif; } /* === Reset === */ @@ -62,7 +64,7 @@ body { /* === Typography === */ h1, h2, h3 { font-family: var(--font-display); letter-spacing: -0.02em; line-height: 1.2; } -a { color: var(--accent); text-decoration: none; font-weight: 600; } +a { color: var(--blue); text-decoration: none; font-weight: 600; } a:hover { opacity: 0.8; } /* === Buttons === */ @@ -81,11 +83,11 @@ button { button:active { transform: scale(0.97); } .btn-primary { - background: var(--accent); + background: var(--orange); color: var(--text-inv); - box-shadow: var(--shadow-accent); + box-shadow: var(--shadow-orange); } -.btn-primary:hover { background: var(--accent-dark); } +.btn-primary:hover { background: var(--orange-dark); } .btn-primary:disabled { opacity: 0.4; cursor: not-allowed; transform: none; box-shadow: none; } .btn-success { @@ -99,14 +101,14 @@ button:active { transform: scale(0.97); } border: 2px solid var(--divider); color: var(--text); } -.btn-outline:hover { border-color: var(--accent); color: var(--accent); } +.btn-outline:hover { border-color: var(--orange); color: var(--orange); } .btn-ghost { background: transparent; color: var(--text-2); padding: 10px 16px; } -.btn-ghost:hover { background: var(--accent-bg); color: var(--accent); } +.btn-ghost:hover { background: var(--orange-ghost); color: var(--orange); } .btn-danger { background: var(--red); color: var(--text-inv); } .btn-sm { padding: 8px 16px; font-size: 0.82rem; border-radius: var(--r-sm); } @@ -127,8 +129,8 @@ input, select, textarea { transition: border-color 0.2s; } input:focus, select:focus, textarea:focus { - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-glow); + border-color: var(--orange); + box-shadow: 0 0 0 3px var(--orange-glow); } input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 400; } @@ -155,7 +157,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: 600; + font-weight: 800; margin-bottom: 18px; } @@ -179,9 +181,8 @@ 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: 700; + font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-3); @@ -202,7 +203,6 @@ 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(--accent); } +.nav-item.active { color: var(--orange); } .nav-item.active::before { content: ''; position: absolute; top: -4px; left: 50%; transform: translateX(-50%); width: 20px; height: 3px; - background: var(--accent); + background: var(--orange); 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(--accent-light); transform: translateY(-2px); box-shadow: var(--shadow-2); } +.option-btn:hover { border-color: var(--orange-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); + border-color: var(--orange); + background: var(--orange-ghost); + box-shadow: 0 0 0 3px var(--orange-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(--accent-light); } +.chip:hover { border-color: var(--orange-light); } .chip.selected { - border-color: var(--accent); - background: var(--accent); + border-color: var(--orange); + background: var(--orange); 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: 600; + font-weight: 800; 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(--accent); transform: scale(1.1); } +.likert-btn:hover { border-color: var(--orange); transform: scale(1.1); } .likert-btn.selected { border-color: transparent; - background: var(--accent); + background: var(--orange); color: var(--text-inv); - box-shadow: var(--shadow-accent); + box-shadow: var(--shadow-orange); 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-mono); + font-family: var(--font-display); font-size: 2.8rem; - font-weight: 700; + font-weight: 900; 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: 600; + font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-3); @@ -433,106 +433,6 @@ 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 d3d3bf5..cc46e43 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(--accent)', - advanced: 'var(--accent-light)', + intermediate: 'var(--blue)', + advanced: 'var(--purple)', } const CATEGORY_ICONS = { @@ -17,8 +17,8 @@ const CATEGORY_ICONS = { const STATUS_LABELS = { pending: { label: 'Queued', color: 'var(--amber)' }, - crawling: { label: 'Fetching...', color: 'var(--accent)' }, - extracting: { label: 'Analyzing...', color: 'var(--accent-light)' }, + crawling: { label: 'Fetching...', color: 'var(--blue)' }, + extracting: { label: 'Analyzing...', color: 'var(--purple)' }, 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(--accent)', textDecoration: 'none', + color: 'var(--blue)', 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 6227b83..0e95cdc 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: '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: '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: '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(--accent-bg)', borderRadius: 'var(--r)', padding: '12px 16px', + background: 'var(--blue-ghost)', 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, var(--accent), var(--accent-light))`, + : `linear-gradient(90deg, #FF5722, #FF8A65)`, }} />
@@ -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 d4d4688..26acb7f 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: 'var(--accent)' }, + { value: 'workout', label: 'Workout', icon: '💪', desc: 'Any exercise session', color: '#FF5722' }, { 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(--accent-bg)', borderRadius: 'var(--r-sm)', padding: '10px 14px', + background: 'var(--blue-ghost)', 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 6d82856..ca05719 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 1fe4fe6..097b6ac 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, var(--accent-light) 0%, var(--accent) 30%, var(--accent-dark) 60%, var(--text) 100%)', + background: 'linear-gradient(170deg, #FF8A65 0%, #FF5722 30%, #E64A19 60%, #1B1B2F 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(--accent)' : 'transparent', + background: mode === m ? 'var(--orange)' : 'transparent', color: mode === m ? '#fff' : 'var(--text-2)', - boxShadow: mode === m ? 'var(--shadow-accent)' : 'none', + boxShadow: mode === m ? 'var(--shadow-orange)' : 'none', }}> {m === 'login' ? 'Sign In' : 'Sign Up'} diff --git a/frontend/src/pages/MealPlan.jsx b/frontend/src/pages/MealPlan.jsx index 71c8504..d3c75f7 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 07abb2b..c431070 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(--orange)' }}> {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 d21096b..f79db12 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(--accent)', - advanced: 'var(--accent-light)', + intermediate: 'var(--blue)', + advanced: 'var(--purple)', } const CATEGORY_ICONS = { @@ -17,8 +17,8 @@ const CATEGORY_ICONS = { const STATUS_LABELS = { pending: { label: 'Queued', color: 'var(--amber)' }, - crawling: { label: 'Fetching...', color: 'var(--accent)' }, - extracting: { label: 'Analyzing...', color: 'var(--accent-light)' }, + crawling: { label: 'Fetching...', color: 'var(--blue)' }, + extracting: { label: 'Analyzing...', color: 'var(--purple)' }, 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(--accent)', + width: '100%', background: researching ? 'var(--text-3)' : 'var(--orange)', color: '#fff', padding: '14px', marginBottom: '1rem', - boxShadow: researching ? 'none' : 'var(--shadow-accent)', + boxShadow: researching ? 'none' : 'var(--shadow-orange)', 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(--accent-glow)', + border: '2px solid var(--orange-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(--accent)', padding: '8px', fontSize: '0.82rem', + color: 'var(--blue)', 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 98c80b0..c7e946b 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -47,7 +47,7 @@ export default function Settings() {
@@ -60,75 +60,6 @@ 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
@@ -188,12 +119,6 @@ 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 → -