Compare commits

..

No commits in common. "e9957c552aca51d23fbc65646a83d982297e3579" and "50eae17c3411fda5087fab4d7a429316e0576500" have entirely different histories.

43 changed files with 330 additions and 1784 deletions

View file

@ -1,11 +0,0 @@
.git
.env
.env.*
!.env.example
__pycache__
*.pyc
content/
node_modules/
dist/
*.log
.DS_Store

View file

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

View file

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

View file

@ -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

View file

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

View file

@ -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

190
README.md
View file

@ -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

View file

@ -1,11 +0,0 @@
.git
.env
.env.*
!.env.example
__pycache__
*.pyc
content/
node_modules/
dist/
*.log
.DS_Store

View file

@ -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 = ""

View file

@ -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)")
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")

View file

@ -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}

View file

@ -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),
}

View file

@ -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

View file

@ -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")

View file

@ -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

View file

@ -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

View file

@ -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),
}

View file

@ -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",
},
}

View file

@ -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:

View file

@ -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

View file

@ -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 <Navigate to="/login" />
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 (
<button
onClick={() => navigate('/support')}
style={{
position: 'fixed', top: '12px', right: '12px', zIndex: 90,
width: '40px', height: '40px', borderRadius: '50%',
background: 'var(--card)', boxShadow: 'var(--shadow-2)',
border: '1px solid var(--divider)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '1.1rem', padding: 0, cursor: 'pointer',
}}
>
?
{unread > 0 && (
<span style={{
position: 'absolute', top: '-4px', right: '-4px',
background: 'var(--red)', color: '#fff', fontSize: '0.6rem', fontWeight: 800,
width: '18px', height: '18px', borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
border: '2px solid var(--bg)',
}}>
{unread}
</span>
)}
</button>
)
}
function NavBar() {
return (
<nav className="nav-bar">
<NavLink to="/" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<span className="nav-icon">🏠</span> Home
</NavLink>
<NavLink to="/log" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<span className="nav-icon">💪</span> Log
</NavLink>
<NavLink to="/nutrition" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<span className="nav-icon">🥑</span> Keto
</NavLink>
<NavLink to="/programs" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<span className="nav-icon">📋</span> Programs
</NavLink>
<NavLink to="/settings" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<span className="nav-icon"></span> More
</NavLink>
</nav>
)
}
export default function App() {
return (
<>
{hasToken() && <HelpButton />}
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/onboarding" element={<ProtectedRoute><Onboarding /></ProtectedRoute>} />
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
<Route path="/log" element={<ProtectedRoute><LogActivity /></ProtectedRoute>} />
<Route path="/food" element={<ProtectedRoute><LogFood /></ProtectedRoute>} />
<Route path="/nutrition" element={<ProtectedRoute><Nutrition /></ProtectedRoute>} />
<Route path="/programs" element={<ProtectedRoute><ProgramSearch /></ProtectedRoute>} />
<Route path="/programs/:id" element={<ProtectedRoute><ProgramDetail /></ProtectedRoute>} />
<Route path="/catalog/:id" element={<ProtectedRoute><CatalogDetail /></ProtectedRoute>} />
<Route path="/rewards" element={<ProtectedRoute><Rewards /></ProtectedRoute>} />
<Route path="/week" element={<ProtectedRoute><WeekView /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
<Route path="/support" element={<ProtectedRoute><Support /></ProtectedRoute>} />
<Route path="/support/admin" element={<ProtectedRoute><SupportAdmin /></ProtectedRoute>} />
<Route path="/support/admin/:threadId" element={<ProtectedRoute><SupportAdmin /></ProtectedRoute>} />
</Routes>
{hasToken() && <NavBar />}
</>
)
}

View file

@ -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'),
};

View file

@ -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; }

View file

@ -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() {
<div style={{ padding: '2rem', textAlign: 'center' }}>
<p style={{ color: 'var(--text-2)', marginBottom: '1rem' }}>Program not found.</p>
<button onClick={() => navigate('/programs')} style={{
background: 'var(--accent)', color: '#fff', padding: '10px 20px',
background: 'var(--blue)', color: '#fff', padding: '10px 20px',
}}> Back to Programs</button>
</div>
)
@ -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() {
<div style={{
padding: '12px 16px', borderRadius: 'var(--r-sm)', marginBottom: '1rem',
fontSize: '0.85rem', fontWeight: 600,
background: message.type === 'error' ? 'var(--red-ghost)' : 'var(--green-bg)',
background: message.type === 'error' ? 'var(--red-ghost)' : 'var(--green-ghost)',
color: message.type === 'error' ? 'var(--red)' : 'var(--green-dark)',
}}>
{message.text}
@ -183,9 +183,9 @@ export default function CatalogDetail() {
<button
onClick={() => navigate(`/programs/${activeRecord.id}`)}
style={{
width: '100%', background: 'var(--accent)', color: '#fff',
width: '100%', background: 'var(--orange)', color: '#fff',
padding: '14px', fontSize: '0.95rem', fontWeight: 800,
marginBottom: '1.25rem', boxShadow: 'var(--shadow-accent)',
marginBottom: '1.25rem', boxShadow: 'var(--shadow-orange)',
}}
>
Active Open Your Program
@ -194,8 +194,8 @@ export default function CatalogDetail() {
{program.crawl_status !== 'ready' && (
<div style={{
padding: '14px', borderRadius: 'var(--r)', background: 'var(--accent-bg)',
color: 'var(--accent)', fontSize: '0.85rem', marginBottom: '1.25rem', textAlign: 'center',
padding: '14px', borderRadius: 'var(--r)', background: 'var(--blue-ghost)',
color: 'var(--blue)', fontSize: '0.85rem', marginBottom: '1.25rem', textAlign: 'center',
}}>
{program.crawl_status === 'failed'
? `Crawl failed: ${program.crawl_error || 'unknown error'}`
@ -210,7 +210,7 @@ export default function CatalogDetail() {
marginBottom: '1.25rem', boxShadow: 'var(--shadow-1)',
}}>
<div style={{
fontSize: '0.72rem', fontWeight: 700, color: 'var(--accent)',
fontSize: '0.72rem', fontWeight: 700, color: 'var(--orange)',
textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '8px',
}}>📈 Progression</div>
<p style={{ fontSize: '0.85rem', color: 'var(--text-2)', lineHeight: 1.5 }}>

View file

@ -1,194 +0,0 @@
import { useState, useEffect, useRef } from 'react'
import { api } from '../api'
export default function ChatCoach() {
const [messages, setMessages] = useState([])
const [input, setInput] = useState('')
const [streaming, setStreaming] = useState(false)
const [aiStatus, setAiStatus] = useState(null)
const scrollRef = useRef(null)
useEffect(() => {
api.getAIStatus().then(setAiStatus).catch(() => setAiStatus({ configured: false }))
}, [])
useEffect(() => {
scrollRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
const sendMessage = async () => {
const text = input.trim()
if (!text || streaming) return
const userMsg = { role: 'user', content: text }
setMessages(prev => [...prev, userMsg])
setInput('')
setStreaming(true)
// Add placeholder for assistant
setMessages(prev => [...prev, { role: 'assistant', content: '' }])
try {
const history = messages.map(m => ({ role: m.role, content: m.content }))
const token = localStorage.getItem('fitness_token')
const resp = await fetch('/api/ai/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ message: text, history }),
})
const reader = resp.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ') && line.trim() !== 'data: [DONE]') {
try {
const { text: chunk } = JSON.parse(line.slice(6))
if (chunk) {
setMessages(prev => {
const updated = [...prev]
const last = updated[updated.length - 1]
updated[updated.length - 1] = { ...last, content: last.content + chunk }
return updated
})
}
} catch {}
}
}
}
} catch (err) {
setMessages(prev => {
const updated = [...prev]
updated[updated.length - 1] = {
role: 'assistant',
content: 'Connection error. Check that the backend is running and an AI provider is configured in Settings → Integrations.',
}
return updated
})
}
setStreaming(false)
}
const suggestions = [
'What should I eat today?',
'Log my 30-minute run',
'How am I doing this week?',
'Create a meal plan for me',
]
if (aiStatus && !aiStatus.configured) {
return (
<div className="page">
<h1 className="page-title">AI Coach</h1>
<div className="card" style={{ textAlign: 'center', padding: '32px 20px' }}>
<div style={{ fontSize: '2rem', marginBottom: '12px' }}>🤖</div>
<h3 style={{ marginBottom: '8px' }}>No AI provider connected</h3>
<p style={{ color: 'var(--text-2)', fontSize: '0.9rem', marginBottom: '16px' }}>
Connect OpenAI, OpenRouter, Claude, Ollama, or any other provider to start chatting with your AI fitness coach.
</p>
<a href="/settings/integrations" className="btn-primary" style={{
display: 'inline-block', padding: '12px 24px', borderRadius: 'var(--r)',
background: 'var(--accent)', color: 'var(--text-inv)', textDecoration: 'none',
fontWeight: 600, fontSize: '0.88rem',
}}>
Configure AI Provider
</a>
</div>
</div>
)
}
return (
<div className="page" style={{ display: 'flex', flexDirection: 'column', paddingBottom: '96px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '12px' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>AI Coach</h1>
{aiStatus?.provider && (
<span style={{
fontFamily: 'var(--font-mono)', fontSize: '0.7rem', color: 'var(--text-3)',
background: 'var(--accent-bg)', padding: '4px 10px', borderRadius: 'var(--r-full)',
}}>
{aiStatus.provider} · {aiStatus.model}
</span>
)}
</div>
<div style={{ flex: 1, overflowY: 'auto', marginBottom: '12px' }}>
{messages.length === 0 && (
<div style={{ textAlign: 'center', padding: '24px 0' }}>
<p style={{ color: 'var(--text-3)', fontSize: '0.9rem', marginBottom: '16px' }}>
Ask me anything about your fitness, nutrition, or program.
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', justifyContent: 'center' }}>
{suggestions.map(s => (
<button key={s} className="btn-outline btn-sm" onClick={() => { setInput(s); }}
style={{ fontSize: '0.8rem' }}>
{s}
</button>
))}
</div>
</div>
)}
{messages.map((msg, i) => (
<div key={i} style={{
display: 'flex',
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
marginBottom: '10px',
}}>
<div style={{
maxWidth: '85%',
padding: '10px 14px',
borderRadius: msg.role === 'user'
? 'var(--r-lg) var(--r-lg) var(--r-sm) var(--r-lg)'
: 'var(--r-lg) var(--r-lg) var(--r-lg) var(--r-sm)',
background: msg.role === 'user' ? 'var(--accent)' : 'var(--card)',
color: msg.role === 'user' ? 'var(--text-inv)' : 'var(--text)',
border: msg.role === 'user' ? 'none' : '1px solid var(--card-border)',
fontSize: '0.9rem',
lineHeight: '1.5',
whiteSpace: 'pre-wrap',
}}>
{msg.content || (streaming && i === messages.length - 1 ? '...' : '')}
</div>
</div>
))}
<div ref={scrollRef} />
</div>
<div style={{
display: 'flex', gap: '8px',
position: 'sticky', bottom: '72px',
background: 'var(--bg)', padding: '8px 0',
}}>
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && sendMessage()}
placeholder={streaming ? 'Thinking...' : 'Ask your AI coach...'}
disabled={streaming}
style={{ flex: 1 }}
/>
<button
onClick={sendMessage}
disabled={streaming || !input.trim()}
className="btn-primary"
style={{ padding: '12px 18px', minWidth: 'auto' }}
>
{streaming ? '···' : '→'}
</button>
</div>
</div>
)
}

View file

@ -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() {
<div
onClick={() => 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() {
>
<div style={{ fontSize: '1.1rem' }}>📋</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '0.78rem', fontWeight: 700, color: 'var(--accent)' }}>{status.program_name}</div>
<div style={{ fontSize: '0.78rem', fontWeight: 700, color: 'var(--blue)' }}>{status.program_name}</div>
<div className="progress-bar" style={{ height: '5px', marginTop: '4px' }}>
<div className="progress-bar-fill" style={{
width: `${Math.round((status.program_day / status.program_total_days) * 100)}%`,
background: 'var(--accent)',
background: 'var(--blue)',
}} />
</div>
</div>
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 800, fontSize: '0.85rem', color: 'var(--accent)', whiteSpace: 'nowrap' }}>
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 800, fontSize: '0.85rem', color: 'var(--blue)', whiteSpace: 'nowrap' }}>
{status.program_day}/{status.program_total_days}
</div>
</div>
@ -120,7 +120,7 @@ export default function Dashboard() {
</Tip>
</div>
<div className="gate-pts">
<span style={{ color: status.gate_passed ? 'var(--green)' : 'var(--accent)' }}>
<span style={{ color: status.gate_passed ? 'var(--green)' : 'var(--orange)' }}>
{status.points_earned}
</span>
<span style={{ fontFamily: 'var(--font)', fontSize: '1rem', fontWeight: 600, color: 'var(--text-3)' }}>
@ -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)`,
}} />
</div>
<div style={{ marginTop: '12px', fontSize: '0.88rem', fontWeight: 700 }}>
@ -178,7 +178,7 @@ export default function Dashboard() {
</div>
{/* === Week Progress === */}
<div className="card" style={{ background: 'var(--accent-bg)' }}>
<div className="card" style={{ background: 'var(--purple-ghost)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' }}>
<div className="section-label" style={{ margin: 0 }}>
<Tip text={`Earn ${status.weekly_target} total points across the week for a bonus. Consistent daily effort beats one big day.`}>
@ -202,7 +202,7 @@ export default function Dashboard() {
{/* === Rewards === */}
{status.gate_passed && status.rewards_available.length > 0 && (
<div className="card" style={{ background: 'var(--green-bg)', border: '2px solid rgba(0,200,83,0.15)' }}>
<div className="card" style={{ background: 'var(--green-ghost)', border: '2px solid rgba(0,200,83,0.15)' }}>
<div className="section-label" style={{ color: 'var(--green-dark)' }}>🎮 Rewards Available</div>
{status.rewards_available.map(r => (
<div className="reward-card" key={r.id}>

View file

@ -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 && (
<div style={{
textAlign: 'center', padding: '24px', marginBottom: '14px',
background: 'var(--green-bg)', borderRadius: 'var(--r-lg)', border: '2px solid rgba(0,200,83,0.15)',
background: 'var(--green-ghost)', borderRadius: 'var(--r-lg)', border: '2px solid rgba(0,200,83,0.15)',
}}>
<div style={{ fontSize: '2rem', marginBottom: '4px' }}>🎉</div>
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 900, fontSize: '1.3rem', color: 'var(--green-dark)' }}>{success}</div>
@ -129,20 +129,20 @@ export default function LogActivity() {
<div
onClick={() => 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)',
}}
>
<div>
<div style={{ fontSize: '0.7rem', fontWeight: 700, color: 'var(--accent)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
<div style={{ fontSize: '0.7rem', fontWeight: 700, color: 'var(--blue)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Active Program
</div>
<div style={{ fontWeight: 800, fontSize: '0.92rem', color: 'var(--text-1)' }}>
{activeProgram.name}
</div>
</div>
<div style={{ fontSize: '0.78rem', color: 'var(--accent)', fontWeight: 700 }}>
<div style={{ fontSize: '0.78rem', color: 'var(--blue)', fontWeight: 700 }}>
Week {activeProgram.current_week || 1}
</div>
</div>
@ -176,7 +176,7 @@ export default function LogActivity() {
{!todayWorkout && upcomingWorkouts.length === 0 && (
<div style={{
padding: '14px', borderRadius: 'var(--r-sm)', background: 'var(--green-bg)',
padding: '14px', borderRadius: 'var(--r-sm)', background: 'var(--green-ghost)',
color: 'var(--green-dark)', fontSize: '0.85rem', textAlign: 'center', fontWeight: 600,
}}>
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 }) {
<div style={{
background: 'var(--card)', borderRadius: 'var(--r)', padding: '14px',
marginBottom: '10px', boxShadow: 'var(--shadow-1)',
border: featured ? '2px solid var(--accent-glow)' : '1px solid var(--divider)',
border: featured ? '2px solid var(--orange-glow)' : '1px solid var(--divider)',
}}>
{featured && (
<div style={{
fontSize: '0.65rem', fontWeight: 800, color: 'var(--accent)',
fontSize: '0.65rem', fontWeight: 800, color: 'var(--orange)',
textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: '4px',
}}>
Today

View file

@ -89,7 +89,7 @@ export default function LogFood() {
<div className="page">
<h1 className="page-title">Food Log</h1>
{success && <div style={{ padding: '12px', background: 'var(--green-bg)', borderRadius: 'var(--radius-sm)', color: 'var(--green-dark)', textAlign: 'center', marginBottom: '12px', fontWeight: 600 }}>{success}</div>}
{success && <div style={{ padding: '12px', background: 'var(--green-ghost)', borderRadius: 'var(--radius-sm)', color: 'var(--green-dark)', textAlign: 'center', marginBottom: '12px', fontWeight: 600 }}>{success}</div>}
{/* Tab bar */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>

View file

@ -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%)',
}}>
<div style={{ width: '100%', maxWidth: '380px' }}>
{/* 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'}
</button>

View file

@ -75,9 +75,9 @@ export default function MealPlan() {
<div key={meal.id} style={{
background: 'var(--surface-2)', borderRadius: 'var(--r-md)', padding: 16,
marginBottom: 10,
borderLeft: itemStatus === 'followed' ? '4px solid var(--green)' :
itemStatus === 'skipped' ? '4px solid var(--red)' :
itemStatus === 'substituted' ? '4px solid var(--amber)' : '4px solid transparent',
borderLeft: itemStatus === 'followed' ? '4px solid #4CAF50' :
itemStatus === 'skipped' ? '4px solid #F44336' :
itemStatus === 'substituted' ? '4px solid #FF9800' : '4px solid transparent',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
@ -141,8 +141,8 @@ export default function MealPlan() {
</div>
<span style={{
fontSize: '0.75rem', padding: '2px 8px', borderRadius: 12,
background: p.status === 'active' ? 'var(--green)22' : 'var(--text-3)22',
color: p.status === 'active' ? 'var(--green)' : 'var(--text-3)',
background: p.status === 'active' ? '#4CAF5022' : '#9E9E9E22',
color: p.status === 'active' ? '#4CAF50' : '#9E9E9E',
fontWeight: 600,
}}>
{p.status}

View file

@ -145,7 +145,7 @@ export default function Nutrition() {
<div className={`gate-banner ${c.compliant_day ? 'gate-earned' : 'gate-locked'}`}>
<div className="section-label" style={{ marginBottom: '4px' }}>Today's Keto Day</div>
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 900, fontSize: '1.4rem',
color: c.compliant_day ? 'var(--green)' : 'var(--accent)' }}>
color: c.compliant_day ? 'var(--green)' : 'var(--orange)' }}>
{c.compliant_day ? '✓ Compliant' : '⏳ In progress'}
</div>
<div style={{ fontSize: '0.82rem', color: 'var(--text-2)', marginTop: '6px' }}>
@ -196,7 +196,7 @@ export default function Nutrition() {
<div className="card">
<div className="section-label">Today's Macros</div>
<MacroBar label="Net carbs" value={m.net_carbs_g} target={t.net_carbs_cap} unit="g" inverse />
<MacroBar label="Protein" value={m.protein_g} target={t.protein_g} unit="g" color="var(--accent)" />
<MacroBar label="Protein" value={m.protein_g} target={t.protein_g} unit="g" color="#FF5722" />
<MacroBar label="Fat" value={m.fat_g} target={t.fat_g} unit="g" color="#FFA726" />
<MacroBar label="Calories" value={m.calories} target={t.calories} unit="kcal" color="#2979FF" />
<button className="btn-outline btn-full btn-sm" onClick={() => navigate('/food')}

View file

@ -198,7 +198,7 @@ export default function Onboarding() {
return (
<div className="page" style={{ paddingTop: '40px' }}>
<div className="progress-bar" style={{ marginBottom: '24px' }}>
<div className="progress-bar-fill" style={{ width: `${progress}%`, background: 'var(--accent)' }} />
<div className="progress-bar-fill" style={{ width: `${progress}%`, background: 'var(--orange)' }} />
</div>
{/* Step 0: Goal */}

View file

@ -63,13 +63,13 @@ export default function ProgramDetail() {
<div style={{ display: 'flex', gap: '1.5rem', marginTop: '10px', fontSize: '0.82rem', color: 'var(--text-2)' }}>
<span>Week {schedule.current_week}</span>
<span>{progress?.completed_workouts || 0}/{progress?.total_workouts || 0} workouts</span>
<span style={{ color: 'var(--accent)', fontWeight: 700 }}>{pct}%</span>
<span style={{ color: 'var(--orange)', fontWeight: 700 }}>{pct}%</span>
</div>
{/* Progress bar */}
<div style={{ marginTop: '12px', height: '8px', borderRadius: '4px', background: 'var(--divider)' }}>
<div style={{
height: '100%', borderRadius: '4px',
background: pct >= 100 ? 'var(--green)' : 'var(--accent)',
background: pct >= 100 ? 'var(--green)' : 'var(--orange)',
width: `${Math.min(100, pct)}%`, transition: 'width 0.4s ease',
}} />
</div>
@ -86,7 +86,7 @@ export default function ProgramDetail() {
{/* Completion celebration */}
{completionResult && (
<div style={{
background: 'var(--green-bg)', borderRadius: 'var(--r)', padding: '16px',
background: 'var(--green-ghost)', borderRadius: 'var(--r)', padding: '16px',
marginBottom: '1rem', textAlign: 'center',
}}>
<div style={{ fontSize: '1.8rem', marginBottom: '4px' }}>🎉</div>
@ -110,10 +110,10 @@ export default function ProgramDetail() {
<div style={{
background: 'var(--card)', borderRadius: 'var(--r)', padding: '16px',
marginBottom: '1.5rem', boxShadow: 'var(--shadow-2)',
border: '2px solid var(--accent-glow)',
border: '2px solid var(--orange-glow)',
}}>
<div style={{
fontSize: '0.72rem', fontWeight: 700, color: 'var(--accent)',
fontSize: '0.72rem', fontWeight: 700, color: 'var(--orange)',
textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '6px',
}}>Today's Workout</div>
<strong style={{ fontFamily: 'var(--font-display)', fontSize: '1.1rem' }}>
@ -155,7 +155,7 @@ export default function ProgramDetail() {
Week {week}
{week === schedule.current_week && (
<span style={{
fontSize: '0.65rem', background: 'var(--accent)', color: '#fff',
fontSize: '0.65rem', background: 'var(--orange)', color: '#fff',
padding: '2px 8px', borderRadius: 'var(--r-full)',
}}>Current</span>
)}
@ -185,7 +185,7 @@ export default function ProgramDetail() {
<button
onClick={() => setShowComplete(w)}
style={{
background: 'var(--green-bg)', color: 'var(--green-dark)',
background: 'var(--green-ghost)', color: 'var(--green-dark)',
padding: '6px 12px', fontSize: '0.75rem', fontWeight: 700,
}}
>
@ -223,7 +223,7 @@ export default function ProgramDetail() {
{showComplete.notes && (
<p style={{
marginTop: '12px', fontSize: '0.8rem', color: 'var(--text-2)',
background: 'var(--accent-bg)', padding: '10px 12px', borderRadius: 'var(--r-sm)',
background: 'var(--blue-ghost)', padding: '10px 12px', borderRadius: 'var(--r-sm)',
}}>
💡 {showComplete.notes}
</p>
@ -269,7 +269,7 @@ function ExerciseRow({ exercise, detailed }) {
</div>
)}
{detailed && exercise.weight_instruction && (
<div style={{ fontSize: '0.75rem', color: 'var(--accent)', marginTop: '2px' }}>
<div style={{ fontSize: '0.75rem', color: 'var(--blue)', marginTop: '2px' }}>
🏋 {exercise.weight_instruction}
</div>
)}

View file

@ -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() {
}}
/>
<button type="submit" style={{
background: 'var(--accent)', color: '#fff', padding: '12px 18px',
background: 'var(--blue)', color: '#fff', padding: '12px 18px',
boxShadow: 'var(--shadow-1)',
}}>Search</button>
</form>
@ -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() {
<div style={{
padding: '12px 16px', borderRadius: 'var(--r-sm)', marginBottom: '1rem',
fontSize: '0.85rem', fontWeight: 600,
background: message.type === 'error' ? 'var(--red-ghost)' : message.type === 'success' ? 'var(--green-bg)' : 'var(--accent-bg)',
color: message.type === 'error' ? 'var(--red)' : message.type === 'success' ? 'var(--green-dark)' : 'var(--accent)',
background: message.type === 'error' ? 'var(--red-ghost)' : message.type === 'success' ? 'var(--green-ghost)' : 'var(--blue-ghost)',
color: message.type === 'error' ? 'var(--red)' : message.type === 'success' ? 'var(--green-dark)' : 'var(--blue)',
}}>
{message.text}
</div>
@ -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)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<strong style={{ fontFamily: 'var(--font-display)', fontSize: '1rem' }}>{p.name}</strong>
<span style={{ fontSize: '0.8rem', color: 'var(--accent)', fontWeight: 700 }}>
<span style={{ fontSize: '0.8rem', color: 'var(--orange)', fontWeight: 700 }}>
Day {p.current_day}/{p.total_days}
</span>
</div>
@ -173,7 +173,7 @@ export default function ProgramSearch() {
marginTop: '8px', height: '6px', borderRadius: '3px', background: 'var(--divider)',
}}>
<div style={{
height: '100%', borderRadius: '3px', background: 'var(--accent)',
height: '100%', borderRadius: '3px', background: 'var(--orange)',
width: `${Math.min(100, (p.current_day / p.total_days) * 100)}%`,
transition: 'width 0.3s ease',
}} />
@ -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)',
}}
>

View file

@ -47,7 +47,7 @@ export default function Settings() {
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: '14px' }}>
<div style={{
width: '48px', height: '48px', borderRadius: '14px',
background: 'linear-gradient(135deg, var(--accent), var(--accent-light))',
background: 'linear-gradient(135deg, #FF5722, #FF8A65)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontFamily: 'var(--font-display)', fontWeight: 900, fontSize: '1.2rem',
}}>
@ -60,75 +60,6 @@ export default function Settings() {
</div>
)}
{/* Quick Links */}
<div className="card">
<div className="section-label">Features</div>
<div
onClick={() => navigate('/meal-plan')}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 0', borderBottom: '1px solid var(--divider)', cursor: 'pointer',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: '1.2rem' }}>🍽</span>
<div>
<div style={{ fontWeight: 700, fontSize: '0.95rem' }}>Meal Plans</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text-3)', fontWeight: 500 }}>AI-generated plans with compliance tracking</div>
</div>
</div>
<span style={{ color: 'var(--text-3)', fontSize: '1.1rem' }}></span>
</div>
<div
onClick={() => navigate('/settings/integrations')}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 0', borderBottom: '1px solid var(--divider)', cursor: 'pointer',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: '1.2rem' }}>🔗</span>
<div>
<div style={{ fontWeight: 700, fontSize: '0.95rem' }}>All Integrations</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text-3)', fontWeight: 500 }}>Strava, Polar, Garmin, Fitbit, USDA, and more</div>
</div>
</div>
<span style={{ color: 'var(--text-3)', fontSize: '1.1rem' }}></span>
</div>
<div
onClick={() => navigate('/rewards')}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 0', borderBottom: '1px solid var(--divider)', cursor: 'pointer',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: '1.2rem' }}>🎮</span>
<div>
<div style={{ fontWeight: 700, fontSize: '0.95rem' }}>Rewards</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text-3)', fontWeight: 500 }}>Configure and redeem your rewards</div>
</div>
</div>
<span style={{ color: 'var(--text-3)', fontSize: '1.1rem' }}></span>
</div>
<div
onClick={() => navigate('/week')}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 0', cursor: 'pointer',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: '1.2rem' }}>📊</span>
<div>
<div style={{ fontWeight: 700, fontSize: '0.95rem' }}>Week View</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text-3)', fontWeight: 500 }}>Weekly progress and day-by-day breakdown</div>
</div>
</div>
<span style={{ color: 'var(--text-3)', fontSize: '1.1rem' }}></span>
</div>
</div>
{/* Point Rules */}
<div className="card">
<div className="section-label">Point Rules</div>
@ -188,12 +119,6 @@ export default function Settings() {
</div>
)
})}
<div
onClick={() => navigate('/settings/integrations')}
style={{ padding: '12px 0', textAlign: 'center', cursor: 'pointer', color: 'var(--accent)', fontSize: '0.88rem', fontWeight: 600 }}
>
Configure all 11 providers
</div>
</div>
<button className="btn-danger btn-full" style={{ marginTop: '10px' }} onClick={() => { clearToken(); navigate('/login') }}>

View file

@ -2,9 +2,9 @@ import { useState, useEffect } from 'react'
import { api } from '../api'
const STATUS_COLORS = {
connected: 'var(--green)',
configured: 'var(--amber)',
not_configured: 'var(--text-3)',
connected: '#4CAF50',
configured: '#FF9800',
not_configured: '#9E9E9E',
}
const STATUS_LABELS = {

View file

@ -95,7 +95,7 @@ export default function Support() {
borderRadius: m.sender === 'user'
? 'var(--r) var(--r) 4px var(--r)'
: 'var(--r) var(--r) var(--r) 4px',
background: m.sender === 'user' ? 'var(--accent)' : 'var(--card)',
background: m.sender === 'user' ? 'var(--orange)' : 'var(--card)',
color: m.sender === 'user' ? '#fff' : 'var(--text)',
boxShadow: 'var(--shadow-1)',
fontSize: '0.88rem',
@ -116,7 +116,7 @@ export default function Support() {
{confirmation && (
<div style={{
textAlign: 'center', fontSize: '0.8rem', color: 'var(--green-dark)',
padding: '8px', background: 'var(--green-bg)', borderRadius: 'var(--r-sm)',
padding: '8px', background: 'var(--green-ghost)', borderRadius: 'var(--r-sm)',
}}>
{confirmation}
</div>
@ -146,11 +146,11 @@ export default function Support() {
type="submit"
disabled={!input.trim() || sending}
style={{
background: sending ? 'var(--text-3)' : 'var(--accent)',
background: sending ? 'var(--text-3)' : 'var(--orange)',
color: '#fff', borderRadius: 'var(--r-full)',
width: '44px', height: '44px', padding: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '1.1rem', boxShadow: 'var(--shadow-accent)',
fontSize: '1.1rem', boxShadow: 'var(--shadow-orange)',
opacity: !input.trim() ? 0.5 : 1,
}}
>

View file

@ -56,7 +56,7 @@ function AdminThreadList() {
style={{
background: 'var(--card)', borderRadius: 'var(--r)', padding: '14px 16px',
marginBottom: '0.6rem', boxShadow: 'var(--shadow-1)', cursor: 'pointer',
border: t.unread_admin > 0 ? '2px solid var(--accent-glow)' : '1px solid var(--card-border)',
border: t.unread_admin > 0 ? '2px solid var(--orange-glow)' : '1px solid var(--card-border)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
@ -66,7 +66,7 @@ function AdminThreadList() {
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{t.unread_admin > 0 && (
<span style={{
background: 'var(--accent)', color: '#fff', fontSize: '0.65rem', fontWeight: 800,
background: 'var(--orange)', color: '#fff', fontSize: '0.65rem', fontWeight: 800,
width: '20px', height: '20px', borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>{t.unread_admin}</span>
@ -168,7 +168,7 @@ function AdminThread({ threadId }) {
)}
<span style={{
...ctxTag,
background: ctx.gate_passed ? 'var(--green-bg)' : 'var(--red-ghost)',
background: ctx.gate_passed ? 'var(--green-ghost)' : 'var(--red-ghost)',
color: ctx.gate_passed ? 'var(--green-dark)' : 'var(--red)',
}}>
{ctx.points_today}/{ctx.daily_target} pts {ctx.gate_passed ? '✓' : '✗'}
@ -193,7 +193,7 @@ function AdminThread({ threadId }) {
borderRadius: m.sender === 'admin'
? 'var(--r) var(--r) 4px var(--r)'
: 'var(--r) var(--r) var(--r) 4px',
background: m.sender === 'admin' ? 'var(--accent)' : 'var(--card)',
background: m.sender === 'admin' ? 'var(--blue)' : 'var(--card)',
color: m.sender === 'admin' ? '#fff' : 'var(--text)',
boxShadow: 'var(--shadow-1)',
fontSize: '0.88rem',
@ -233,7 +233,7 @@ function AdminThread({ threadId }) {
type="submit"
disabled={!input.trim() || sending}
style={{
background: sending ? 'var(--text-3)' : 'var(--accent)',
background: sending ? 'var(--text-3)' : 'var(--blue)',
color: '#fff', borderRadius: 'var(--r-full)',
width: '44px', height: '44px', padding: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',

View file

@ -24,7 +24,7 @@ export default function WeekView() {
<h1 className="page-title">This Week</h1>
{/* Summary hero */}
<div className="card" style={{ textAlign: 'center', background: 'var(--accent-bg)' }}>
<div className="card" style={{ textAlign: 'center', background: 'var(--purple-ghost)' }}>
<div className="section-label">Weekly Total</div>
<div style={{ fontFamily: 'var(--font-display)', fontSize: '2.6rem', fontWeight: 900, letterSpacing: '-0.03em' }}>
{week.total_points_earned}
@ -68,7 +68,7 @@ export default function WeekView() {
}}>
<div style={{
width: '38px', fontWeight: isToday ? 800 : 600, fontSize: '0.85rem',
color: isToday ? 'var(--accent)' : 'var(--text)',
color: isToday ? 'var(--orange)' : 'var(--text)',
}}>
{days[i]}
</div>

View file

@ -1,4 +0,0 @@
.git
.env
__pycache__
*.pyc

View file

@ -12,7 +12,6 @@ from datetime import date
from mcp.server.fastmcp import FastMCP
FITNESS_API_URL = os.getenv("FITNESS_API_URL", "http://backend:8000")
API_TOKEN = os.getenv("API_TOKEN", "")
mcp = FastMCP("Diligence Fitness", port=3001)
@ -22,12 +21,8 @@ _client: httpx.AsyncClient | None = None
async def api(method: str, path: str, **kwargs) -> dict:
global _client
if _client is None:
headers = {}
if API_TOKEN:
headers["Authorization"] = f"Bearer {API_TOKEN}"
_client = httpx.AsyncClient(
base_url=FITNESS_API_URL, timeout=30, headers=headers
)
_client = httpx.AsyncClient(base_url=FITNESS_API_URL, timeout=30)
# TODO: add service account API key auth header
resp = await getattr(_client, method)(f"/api{path}", **kwargs)
resp.raise_for_status()
return resp.json()

View file

@ -1,29 +0,0 @@
# Diligence — Windows Setup
# Generate API_TOKEN for MCP connector auth
$apiToken = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 32 | ForEach-Object {[char]$_})
(Get-Content .env) -replace '^API_TOKEN=.*', "API_TOKEN=$apiToken" | Set-Content .env
Write-Host "`n`e[36m💪 Diligence — Setup`e[0m`n"
if (Test-Path .env) {
Write-Host "`e[33m.env already exists. Delete it first to regenerate.`e[0m"
exit 1
}
# Generate random SECRET_KEY (64 hex chars)
$bytes = New-Object byte[] 32
[System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
$secret = ($bytes | ForEach-Object { $_.ToString("x2") }) -join ""
# Create .env from template
Copy-Item .env.example .env
(Get-Content .env) -replace "^SECRET_KEY=.*", "SECRET_KEY=$secret" | Set-Content .env
Write-Host "`e[32m✅ .env created with random SECRET_KEY`e[0m"
Write-Host ""
Write-Host "Next steps:"
Write-Host " docker compose up -d"
Write-Host " Open http://localhost"
Write-Host " Register your account"
Write-Host " Configure integrations via Settings or your AI agent"
Write-Host ""

View file

@ -7,20 +7,13 @@ if [ -f .env ]; then
fi
SECRET=$(openssl rand -hex 32)
TOKEN=$(openssl rand -hex 32)
cp .env.example .env
sed -i.bak "s/^SECRET_KEY=.*/SECRET_KEY=${SECRET}/" .env
sed -i.bak "s/^API_TOKEN=.*/API_TOKEN=${TOKEN}/" .env
rm -f .env.bak
sed -i "s/^SECRET_KEY=.*/SECRET_KEY=${SECRET}/" .env
echo "✅ .env created with random SECRET_KEY and API_TOKEN"
echo "✅ .env created with random SECRET_KEY"
echo ""
echo "Next steps:"
echo " docker compose up -d"
echo " Open http://localhost"
echo " Register your account"
echo " Configure integrations via Settings or your AI agent"
echo ""
echo "MCP agent connection:"
echo " URL: http://localhost:3001/sse"
echo " Header: Authorization: Bearer ${TOKEN}"