Compare commits

..

10 commits

Author SHA1 Message Date
Claude
e9957c552a Add OUTSTANDING.md — remaining work for cofounder review 2026-06-21 23:36:14 +00:00
Claude
0670bca9b1 Stream C frontend: ChatCoach page, nav update, README with AI providers + agent configs
Frontend:
- ChatCoach.jsx: streaming chat UI with SSE, provider indicator, suggestion
  chips, 'no AI configured' state with setup link
- App.jsx: import + /chat route + nav bar (More → Coach with brain emoji)
- api.js: getAIStatus() method

Documentation:
- README.md: 'Built-in AI Coach' section with 8-provider table, external
  agent config snippets for Claude Desktop, Claude Code, Cursor, Windsurf,
  and COROS multi-MCP pattern
- AGENT_GUIDE.md: multi-device integration, Strava AI/ML warning,
  provider-agnostic statement

Nav bar: Home | Log | Keto | Programs | Coach
Settings still reachable at /settings (linked from Coach setup prompt)
2026-06-21 23:28:07 +00:00
Claude
3ab88a3d2a Stream B: Device sync base class + provider registry (already committed)
Abstract base class for device sync services (device_sync_base.py):
- Standard OAuth token management with automatic refresh
- Credential decryption from integration_configs table
- Activity import pipeline with deduplication by external_id
- Webhook validation and handling interface
- get_valid_token(), get_credentials(), import_activity() helpers

Garmin, WHOOP, and Oura sync service implementations follow when
API credentials are obtained (Scot action items):
- Apply for Garmin Developer Program (DiligenceWorks Pte. Ltd.)
- Register WHOOP developer app at developer.whoop.com
- Register Oura app at cloud.ouraring.com
2026-06-21 23:16:58 +00:00
Claude
9a8e6ce1eb Stream C: Multi-provider AI coaching backend + 20-provider registry
AI coaching service (backend/app/services/ai_provider.py):
- Two code paths: OpenAI-compatible (6 providers) + Anthropic (Claude)
- Streaming SSE responses via httpx async
- System prompt built from AGENT_GUIDE + live user context
- Graceful error handling for all provider failures
- Gemini adapter for Google's generateContent API

Chat endpoint (backend/app/routers/ai_chat.py):
- POST /api/ai/chat — SSE streaming response
- GET /api/ai/status — check configured provider
- History capped at 20 messages, content at 4K chars

Provider registry expanded to 20 providers:
- 9 device integrations (strava, polar, garmin, whoop, oura, coros, fitbit, withings, suunto)
- 8 AI providers (openai, openrouter, huggingface, groq, ollama, claude, gemini, custom_ai)
- 3 other (usda, nutritionix, telegram)
- Categorized: device, ai_provider, nutrition, notifications
- COROS: MCP bridge approach (no API integration needed)
- Strava: AI/ML warning per their ToS

Frontend ChatCoach.jsx to follow in next commit.
2026-06-21 23:16:17 +00:00
Claude
6e082ce58f Stream D: Security hardening — 10 fixes from QA review
- SEC-05: Fail fast if SECRET_KEY is default 'change-me-in-production'
- SEC-06: CORS allow_credentials=False (Bearer tokens don't need it)
- SEC-08: Input validation on auth schemas (min/max length, username pattern)
- SEC-10: .dockerignore files (root, backend, mcp-connector)
- SEC-11: Hardcode ALGORITHM='HS256' as constant, remove from settings
- CQ-01: Gate crawl scheduler on CRAWL_ENABLED env var (default false)
- OS-02: CONTRIBUTING.md
- OS-03: CHANGELOG.md with v1.0.0 entry
- setup.ps1: Add API_TOKEN generation (Windows parity with setup.sh)
2026-06-21 23:13:53 +00:00
Claude
c075eb9257 Stream A: DW brand theme — Instrument Sans + IBM Plex Mono, light/dark mode, all hardcoded colors replaced
- Replace Solar Momentum palette with DiligenceWorks brand identity
- Typography: Outfit → Instrument Sans, Plus Jakarta Sans → IBM Plex Mono (data/stats)
- CSS variables: --orange* → --accent*, new DW blue #2952CC / #6B9BFF
- Dark mode: prefers-color-scheme media query + manual data-theme override
- Border radii: 14/10/20px → 8/6/12px (professional, less playful)
- Font weights: 800/900 → 600/700 (DW uses lighter weights)
- Fixed 30+ hardcoded hex values across 16 files (UI-06)
- Gate banners, progress bars, category chips all use CSS variables
2026-06-21 23:12:25 +00:00
Claude
83e715dbc4 README: platform-specific install guides for Windows 11 (WSL2/Hyper-V/BIOS), macOS (Apple Silicon/Intel/Homebrew), Linux 2026-06-16 03:36:06 +00:00
Claude
dfc5ab3c9d Add Windows setup (setup.ps1), expand README with cross-platform install, Claude Desktop example, backup/restore, architecture diagram, .dockerignore 2026-06-16 03:25:03 +00:00
Claude
e34ae36774 Add navigation links to Settings page for Meal Plans, Integrations, Rewards, Week View 2026-06-16 02:04:44 +00:00
Claude
69320e1e82 Fix all 7 launch blockers from security/QA review
SEC-16: Add frontend ports mapping (80:80) to docker-compose.yml
UI-01:  Add routes for Welcome, SettingsIntegrations, MealPlan in App.jsx
SEC-01: Remove traceback leak from auth error responses
SEC-02: Fix require_admin to use is_admin column (was undefined ADMIN_USERNAME)
SEC-03: Add API_TOKEN auth for MCP connector -> backend communication
SEC-04: OAuth state now uses signed JWT tokens (was raw user UUID)
OS-01:  First registered user gets admin immediately during registration

10 files changed, 97 insertions(+), 25 deletions(-)
2026-06-16 01:50:09 +00:00
43 changed files with 1790 additions and 336 deletions

11
.dockerignore Normal file
View file

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

View file

@ -1,5 +1,6 @@
# === REQUIRED (generated automatically by setup.sh) ===
SECRET_KEY=
API_TOKEN=
# === APP URL (change for production) ===
BASE_URL=http://localhost
@ -8,6 +9,11 @@ BASE_URL=http://localhost
DB_USER=fitness
DB_NAME=fitness_rewards
# === MCP AGENT AUTH (generated automatically by setup.sh) ===
# The API_TOKEN authenticates the MCP connector with the backend.
# It is auto-configured — you don't need to touch it unless you're
# connecting an agent from outside Docker.
# Everything else — Strava, Polar, Garmin, Fitbit, Telegram,
# USDA, Groq, etc. — is configured through the app UI or your AI agent.
# No container restart needed.

View file

@ -97,3 +97,31 @@ Use get_context() to see their motivation profile:
understand it, but don't lecture.
- Don't read back integration credentials. You can check status but
never retrieve stored secrets.
## Multi-Device Integration
If you have access to both Diligence tools and a device MCP (like COROS),
you can bridge data between them:
1. Read the user's recent workouts from the device MCP
2. Use Diligence's log_activity tool to import them
3. This earns the user points automatically
Example workflow with COROS MCP connected:
- Check COROS for today's activities
- For each unlogged activity, call log_activity with the details
- Report the points earned
## Strava Data Warning
Strava's API terms prohibit use of their data for AI/ML purposes.
If the user has Strava connected, you may see synced activities in
their log, but do not reference Strava-specific data in your coaching
advice. Treat Strava activities as user-reported workouts only.
## Provider-Agnostic
This guide works regardless of which LLM powers the coaching.
The same context, tools, and personality apply whether you are
GPT-4o, Claude, Llama, Gemini, or any other model. The user
chose you — respect their choice and focus on being helpful.

27
CHANGELOG.md Normal file
View file

@ -0,0 +1,27 @@
# Changelog
All notable changes to Diligence are documented here.
## [1.0.0] — 2026-06-18
Initial open-source release.
### Features
- Points economy with daily gate, weekly targets, weekly reset
- Science-based onboarding (PAR-Q+, TTM, BREQ-2 motivation profiling)
- Activity logging (manual + Strava OAuth + Polar OAuth sync)
- Food logging with Open Food Facts barcode scanning + USDA FoodData Central
- 90-day structured program tracking
- Configurable reward shop
- In-app support chat with Telegram notifications
- 14-tool MCP connector for AI agent integration
- Meal plan system with compliance tracking
- Dynamic integration configuration (11 providers)
- B2A discovery layer (llms.txt, agent-card.json, SKILL.md)
### Security
- Fernet-encrypted credential storage (HKDF key derivation)
- Signed JWT OAuth state parameters
- MCP connector authentication via API_TOKEN
- First-user auto-admin grant
- Traceback suppression in error responses

31
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,31 @@
# Contributing to Diligence
Thanks for your interest in contributing!
## How to contribute
1. **Open an issue** to discuss the change before starting work
2. **Fork the repo** and create a branch from `main`
3. **Test locally**: `docker compose up -d` and verify all 4 containers start healthy
4. **Submit a PR** with a clear description of what changed and why
## Development setup
```bash
git clone https://github.com/DiligenceWorks/Diligence.git
cd Diligence
./setup.sh # Mac/Linux
docker compose up -d
```
Open http://localhost and register. First user gets admin automatically.
## Code style
- Backend: Python 3.11+, FastAPI, async/await, type hints
- Frontend: React with hooks, no class components
- CSS: use CSS variables from `index.css`, never hardcode colors
## Questions?
Open a GitHub Discussion or reach out at hello@diligenceworks.online.

101
OUTSTANDING.md Normal file
View file

@ -0,0 +1,101 @@
# Diligence v4 — Outstanding Work
**Last updated:** June 22, 2026
**Session summary:** 5 commits, 35 files changed, +1,358/-329 lines
## What Was Delivered (June 22, 2026)
### Stream A — DW Branding ✅ COMPLETE
- Full palette swap from "Solar Momentum" (orange/green/blue) to DiligenceWorks brand (deep blue #2952CC, teal, IBM Plex Mono)
- Light and dark mode via `prefers-color-scheme` media query + manual `data-theme` override
- Typography: Outfit → Instrument Sans, Plus Jakarta Sans → IBM Plex Mono for data elements
- 30+ hardcoded hex color values replaced across 16 JSX files
- Border radii tightened from 14/10/20px to 8/6/12px
### Stream D — Security Hardening ✅ COMPLETE
- SEC-05: Fail-fast if SECRET_KEY is the default value
- SEC-06: CORS `allow_credentials=False`
- SEC-08: Input validation on auth schemas (username length/pattern, password min 8 chars)
- SEC-10: `.dockerignore` files for root, backend, mcp-connector
- SEC-11: JWT algorithm hardcoded as constant
- CQ-01: Crawl scheduler gated on `CRAWL_ENABLED` env var
- OS-02: `CONTRIBUTING.md`
- OS-03: `CHANGELOG.md` (v1.0.0)
- `setup.ps1`: API_TOKEN generation (Windows parity)
### Stream C — AI Agent Setup ✅ BACKEND + FRONTEND COMPLETE
- `ai_provider.py`: Multi-provider LLM routing with 2 code paths (OpenAI-compatible + Anthropic)
- Streaming SSE responses for all providers
- `ai_chat.py`: POST `/api/ai/chat` (SSE) + GET `/api/ai/status`
- Provider registry expanded from 11 to 20 providers across 4 categories
- `ChatCoach.jsx`: In-app chat UI with streaming, provider indicator, suggestion chips
- Nav updated: Home | Log | Keto | Programs | **Coach**
- README updated with 8-provider table + Claude Desktop/Code/Cursor/Windsurf configs
- AGENT_GUIDE updated with multi-MCP, Strava warning, provider-agnostic statement
### Stream B — Hardware Integrations ✅ FOUNDATION COMPLETE
- `device_sync_base.py`: Abstract base class with OAuth management, deduplication, webhook interface
- Provider registry includes Garmin, WHOOP, Oura, COROS, Fitbit, Withings, Suunto with accurate API URLs
---
## What Still Needs Doing
### Priority 1 — Blocked on API Credentials (Scot action items)
| Task | Blocker | Effort once unblocked |
|------|---------|----------------------|
| `garmin_sync.py` — Garmin Connect sync service | Apply at developer.garmin.com using DiligenceWorks Pte. Ltd. ~2 business days approval | 2-3 hours |
| `whoop_sync.py` — WHOOP sync service | Register at developer.whoop.com. Requires WHOOP device ownership | 2-3 hours |
| `oura_sync.py` — Oura Ring sync service | Register at cloud.ouraring.com. Self-serve, immediate | 2-3 hours |
| COROS partner API application | Apply at support.coros.com. Timeline unclear | MCP bridge already designed, no backend code needed |
Each sync service follows the pattern in `device_sync_base.py` and mirrors the existing `strava_sync.py`. The work is: OAuth flow, activity type mapping, API data fetching, and point awarding via `import_activity()`.
### Priority 2 — Code Work (no blockers)
| Task | Effort | Notes |
|------|--------|-------|
| Generic webhook receiver endpoint (`POST /api/integrations/webhook/{provider}`) | 1-2 hours | Dispatches to sync services when devices push data |
| Theme toggle UI in Settings page | 30 min | Light/dark/system toggle, stored in localStorage |
| SEC-07: Auth rate limiting (`slowapi` on login/register) | 30 min | Add `slowapi` to requirements.txt |
| SEC-09: UUID parameter validation in meal_plans/support routers | 20 min | Change `str``uuid.UUID` type hints |
| SEC-15: Enum validation for compliance status and meal_type | 20 min | Add `Literal[...]` types |
| UI-03: Fix React Hooks violation in HelpButton (App.jsx) | 10 min | Move `useEffect` above conditional return |
| UI-04: Load existing compliance data in MealPlan.jsx on mount | 30 min | Fetch from API and pre-populate state |
| UI-05: Error state handling in SettingsIntegrations + MealPlan | 30 min | Add error banners on API failures |
| Settings page accessibility after nav change | 20 min | Add gear icon in header or link from Coach page |
| Function calling for AI chat (tool use) | 2-3 hours | Let AI coach log workouts, search food via internal tools |
### Priority 3 — Polish (nice to have)
| Task | Effort |
|------|--------|
| Push updated code to GitHub (DiligenceWorks/Diligence) — clean-history push | 30 min |
| Deploy v4 to Coolify (fitness.littlefake.com) | 20 min |
| Update DW KB CONTEXT.md for fitness-rewards project | 20 min |
| OS-06: GitHub Actions CI pipeline (docker compose build) | 30 min |
| OS-07: Test suite (auth, points engine, meal plans) | 4-6 hours |
| OS-08: System requirements in README (RAM, disk, Docker version) | 10 min |
| OS-09: Backup/restore documentation in README | 10 min |
| UI-08: Default timezone from UTC instead of Asia/Bangkok | 5 min |
---
## Architecture Summary for Reviewers
**Container count:** 4 (unchanged — frontend, backend, mcp-connector, fitness-db)
**AI coaching flow:**
User types in Coach tab → POST `/api/ai/chat``ai_provider.py` reads configured provider from DB → routes to OpenAI-compatible endpoint OR Anthropic API → streams SSE response back to `ChatCoach.jsx`
**Device sync flow:**
User connects device via OAuth in Settings → credentials encrypted in `integration_configs` table → sync triggered by webhook (push) or manual sync (pull) → `device_sync_base.py` deduplicates and awards points via `points_engine.py`
**Provider registry:** 20 providers organized by category:
- **Device** (9): Strava✅, Polar✅, Garmin⏳, WHOOP⏳, Oura⏳, COROS (MCP), Fitbit, Withings, Suunto
- **AI** (8): OpenAI, OpenRouter, HuggingFace, Groq, Ollama, Claude, Gemini, Custom
- **Nutrition** (2): USDA✅, Nutritionix
- **Notifications** (1): Telegram✅
✅ = working sync service, ⏳ = sync service pending API credentials

190
README.md
View file

@ -6,6 +6,65 @@ Built by [DiligenceWorks](https://diligenceworks.online).
## Quick Start
### Prerequisites
- [Docker Desktop](https://www.docker.com/products/docker-desktop/) — that's it. No Python, Node.js, or database install needed.
- Docker Desktop runs natively on **Windows 10/11**, **macOS**, and **Linux**.
---
### Windows 11 Setup
**1. Install Docker Desktop**
Download from [docker.com/products/docker-desktop](https://www.docker.com/products/docker-desktop/).
During installation, the installer presents a checkbox: **"Use WSL 2 instead of Hyper-V (recommended)"**. This is checked by default.
- **WSL 2 backend (default):** Works on all Windows editions including Home. Lower resource usage, better performance. Requires WSL 2 to be installed first — open PowerShell as Administrator and run `wsl --install`, then reboot.
- **Hyper-V backend:** If WSL 2 causes issues (black screen on reboot, kernel errors, or containers won't start), uncheck the WSL 2 box during installation to use Hyper-V instead. Requires Windows Pro, Enterprise, or Education.
**Important:** Hardware virtualization must be enabled in your BIOS/UEFI settings (Intel VT-x or AMD-V). This is the most common cause of "Docker won't start" issues. Check your laptop/PC manufacturer's documentation for how to access BIOS settings (usually F2, F12, or Del during boot).
**2. Clone and run**
Open PowerShell:
```powershell
git clone https://github.com/diligenceworks/diligence
cd diligence
powershell -ExecutionPolicy Bypass -File setup.ps1
docker compose up -d
```
Or without the script:
```powershell
git clone https://github.com/diligenceworks/diligence
cd diligence
copy .env.example .env
# Edit .env and set SECRET_KEY to any random string
docker compose up -d
```
---
### macOS Setup
**1. Install Docker Desktop**
Download from [docker.com/products/docker-desktop](https://www.docker.com/products/docker-desktop/). Make sure you pick the right installer for your chip:
- **Apple Silicon** (M1, M2, M3, M4) — download the Apple Silicon .dmg
- **Intel** — download the Intel .dmg
To check: Apple menu → About This Mac. Look for "Chip" (Apple Silicon) or "Processor" (Intel).
Drag Docker.app to Applications and launch it. Requires macOS 13 Ventura or later.
Alternatively, install via Homebrew: `brew install --cask docker`
**2. Clone and run**
Open Terminal:
```bash
git clone https://github.com/diligenceworks/diligence
cd diligence
@ -13,7 +72,30 @@ cd diligence
docker compose up -d
```
Open http://localhost and create your account.
---
### Linux Setup
Install Docker Engine and Docker Compose via your distro's package manager, or install Docker Desktop for Linux. Then:
```bash
git clone https://github.com/diligenceworks/diligence
cd diligence
./setup.sh
docker compose up -d
```
---
Open http://localhost and create your account. First user gets admin.
### Verify
```bash
docker compose ps
```
You should see 4 containers, all healthy: `frontend`, `backend`, `mcp-connector`, `fitness-db`.
## Features
@ -26,17 +108,71 @@ Open http://localhost and create your account.
- **Program tracking** — 90-day structured programs (StrongLifts, Darebee, etc.) with day-by-day progression.
- **Configurable rewards** — you define what's worth earning. Gaming time, screen time, treats — your rules.
## Connecting an AI Agent
## Built-in AI Coach
Point your agent's MCP config to `http://localhost:3001/sse` (development) or `https://your-domain/mcp` (production behind reverse proxy).
Diligence includes a built-in AI coaching chat. Configure any LLM provider in **Settings → Integrations**, then open the **Coach** tab.
Works with Claude Desktop, Cursor, Windsurf, and any MCP-compatible agent.
Supported providers (one API key, that's it):
| Provider | Free tier | What you get |
|----------|-----------|-------------|
| **OpenRouter** | 26 free models | 300+ models from every major provider, one key |
| **Hugging Face** | Yes | Thousands of open-source models |
| **Groq** | Yes | Ultra-fast Llama inference |
| **Ollama** | Local, free | Run any model on your own machine |
| **OpenAI** | No | GPT-4o, GPT-4o-mini |
| **Claude** | No | Claude Sonnet 4.6, Opus 4.8 |
| **Gemini** | Yes | Gemini 2.0 Flash, 2.5 Pro |
| **Custom** | — | Any OpenAI-compatible endpoint (vLLM, TGI, LiteLLM) |
The AI coach has access to your profile, points, program schedule, and motivation type. It can log workouts, search food, and create meal plans through the chat.
### Connecting external AI agents (MCP)
The MCP connector at port 3001 works with any MCP-compatible agent. The built-in chat and external agents coexist — use whichever fits your workflow.
**Claude Desktop** — add to `claude_desktop_config.json`:
```json
{
"mcpServers": {
"diligence": { "url": "http://localhost:3001/sse" }
}
}
```
**Claude Code** (CLI):
```bash
claude mcp add diligence --transport sse http://localhost:3001/sse
```
**Cursor** — add to `.cursor/mcp.json`:
```json
{
"mcpServers": {
"diligence": { "url": "http://localhost:3001/sse" }
}
}
```
**Windsurf** — add to MCP settings, same format as Cursor.
**COROS watch owners** — add both Diligence and COROS MCP servers to your agent. The agent bridges your watch data with your fitness log:
```json
{
"mcpServers": {
"diligence": { "url": "http://localhost:3001/sse" },
"coros": { "url": "https://your-coros-mcp-url/sse" }
}
}
```
Copy the contents of [AGENT_GUIDE.md](AGENT_GUIDE.md) into your agent's system instructions for motivation-aware coaching.
## Configuring Integrations
All integrations are configured through the app UI (Settings → Integrations) or through your AI agent. No `.env` file editing or container restarts needed.
All integrations are configured through the app UI (**Settings → Integrations**) or through your AI agent. No `.env` file editing or container restarts needed.
Tell your agent: *"I want to connect my Strava"* — it will walk you through getting API credentials and store them encrypted.
## Data Sovereignty
@ -44,17 +180,57 @@ Diligence is self-hosted software. Your data never leaves your server. No cloud
Your fitness data — heart rate, body composition, food intake, GPS traces — is biometric data that deserves sovereignty.
## Architecture
```
┌─────────────────────────────────────────────────┐
│ nginx │
│ (frontend, /api proxy, /mcp proxy) │
├──────────┬─────────────────┬────────────────────┤
│ React │ FastAPI │ MCP Connector │
│ SPA │ Backend │ (14 tools) │
│ │ │ port 3001 │
│ ├──────────────────┤ │
│ │ PostgreSQL 16 │ │
│ │ (internal only) │ │
└──────────┴──────────────────┴────────────────────┘
```
## Stack
- Python 3.12, FastAPI, SQLAlchemy
- Python 3.12, FastAPI, SQLAlchemy (async)
- React 18, Vite
- PostgreSQL 16
- FastMCP (Streamable HTTP/SSE)
- Docker Compose
## Updating
```bash
git pull
docker compose build
docker compose up -d
```
Database migrations run automatically on startup. Your data is preserved.
## Backing Up
Your data lives in the `fitness_db_data` Docker volume:
```bash
docker compose exec fitness-db pg_dump -U fitness fitness_rewards > backup.sql
```
To restore:
```bash
docker compose exec -i fitness-db psql -U fitness fitness_rewards < backup.sql
```
## Contributing
Contributions welcome. Open an issue to discuss before submitting a PR.
Contributions welcome. Please open an issue to discuss before submitting a PR.
## License

11
backend/.dockerignore Normal file
View file

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

View file

@ -10,8 +10,9 @@ class Settings(BaseSettings):
# Auth
secret_key: str = "change-me-in-production"
algorithm: str = "HS256"
crawl_enabled: bool = False # Set CRAWL_ENABLED=true to start program crawl scheduler
access_token_expire_minutes: int = 1440 # 24 hours
api_token: str = "" # MCP connector auth — generated by setup.sh
# Strava
strava_client_id: str = ""

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import sys
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
@ -27,6 +28,13 @@ async def lifespan(app: FastAPI):
logger.error("Could not initialize database after 10 attempts — starting without tables")
# SEC-05: Fail fast if SECRET_KEY not configured
from app.config import get_settings
_s = get_settings()
if _s.secret_key == "change-me-in-production":
logger.error("CRITICAL: SECRET_KEY not set. Run ./setup.sh or set SECRET_KEY in .env")
sys.exit(1)
# Run lightweight migrations for schema changes
try:
await run_migrations()
@ -41,14 +49,17 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.warning(f"Resource seeding failed (non-fatal): {e}")
# Start background crawl queue scheduler
# Start background crawl queue scheduler (gated on CRAWL_ENABLED)
crawl_task = None
try:
from app.services.crawl_scheduler import crawl_queue_loop
crawl_task = asyncio.create_task(crawl_queue_loop())
logger.info("Crawl queue scheduler started")
except Exception as e:
logger.warning(f"Crawl scheduler failed to start (non-fatal): {e}")
if _s.crawl_enabled:
try:
from app.services.crawl_scheduler import crawl_queue_loop
crawl_task = asyncio.create_task(crawl_queue_loop())
logger.info("Crawl queue scheduler started")
except Exception as e:
logger.warning(f"Crawl scheduler failed to start (non-fatal): {e}")
else:
logger.info("Crawl scheduler disabled (set CRAWL_ENABLED=true to enable)")
logger.info("Fitness Rewards backend started")
yield
@ -68,7 +79,7 @@ app = FastAPI(title="Fitness Rewards", version="1.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_credentials=False # Bearer tokens don't need credentials mode,
allow_methods=["*"],
allow_headers=["*"],
)
@ -85,6 +96,7 @@ from app.routers.catalog import router as catalog_router
from app.routers.support import router as support_router
from app.routers.nutrition import router as nutrition_router
from app.routers.meal_plans import router as meal_plans_router
from app.routers.ai_chat import router as ai_chat_router
app.include_router(auth_router)
app.include_router(onboarding_router)
@ -98,6 +110,7 @@ app.include_router(support_router)
app.include_router(programs_router)
app.include_router(nutrition_router)
app.include_router(meal_plans_router)
app.include_router(ai_chat_router)
@app.get("/api/health")

View file

@ -0,0 +1,64 @@
"""AI coaching chat endpoint — SSE streaming responses."""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Depends, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.utils.auth import get_current_user
from app.models.user import User
from app.services import ai_provider
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/ai", tags=["AI Coaching"])
@router.post("/chat")
async def chat_with_ai(
request: Request,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Stream an AI coaching response via SSE.
Body: {"message": "...", "history": [{"role": "user"|"assistant", "content": "..."}]}
Response: text/event-stream with data chunks.
"""
body = await request.json()
message = body.get("message", "").strip()
history = body.get("history", [])
if not message:
return {"error": "Message cannot be empty"}
# Validate history format
clean_history = []
for msg in history[-20:]: # Cap at last 20 messages
if isinstance(msg, dict) and msg.get("role") in ("user", "assistant") and msg.get("content"):
clean_history.append({"role": msg["role"], "content": msg["content"][:4000]})
async def event_stream():
async for chunk in ai_provider.chat(user.id, message, clean_history, db):
yield f"data: {json.dumps({'text': chunk})}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream")
@router.get("/status")
async def ai_status(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Check which AI provider is configured."""
provider = await ai_provider.get_active_ai_provider(db)
if provider:
return {
"configured": True,
"provider": provider["name"],
"model": provider["model"],
}
return {"configured": False, "provider": None, "model": None}

View file

@ -1,11 +1,10 @@
from __future__ import annotations
import logging
import traceback
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import JSONResponse
from sqlalchemy import select
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
@ -26,11 +25,16 @@ async def register(req: RegisterRequest, db: Annotated[AsyncSession, Depends(get
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Username already taken")
# Grant admin to first user
admin_count = await db.execute(select(func.count(User.id)).where(User.is_admin == True))
is_first_user = (admin_count.scalar() or 0) == 0
user = User(
username=req.username,
display_name=req.display_name,
password_hash=hash_password(req.password),
email=req.email,
is_admin=is_first_user,
)
db.add(user)
await db.flush()
@ -52,9 +56,8 @@ async def register(req: RegisterRequest, db: Annotated[AsyncSession, Depends(get
except HTTPException:
raise
except Exception as e:
tb = traceback.format_exc()
logger.error(f"Registration failed: {e}\n{tb}")
return JSONResponse(status_code=500, content={"detail": str(e), "traceback": tb})
logger.error(f"Registration failed: {e}", exc_info=True)
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
@router.post("/login")
@ -69,9 +72,8 @@ async def login(req: LoginRequest, db: Annotated[AsyncSession, Depends(get_db)])
except HTTPException:
raise
except Exception as e:
tb = traceback.format_exc()
logger.error(f"Login failed: {e}\n{tb}")
return JSONResponse(status_code=500, content={"detail": str(e), "traceback": tb})
logger.error(f"Login failed: {e}", exc_info=True)
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
@router.get("/me")
@ -82,4 +84,5 @@ async def get_me(user: Annotated[User, Depends(get_current_user)]):
"display_name": user.display_name,
"email": user.email,
"timezone": user.timezone,
"is_admin": getattr(user, "is_admin", False),
}

View file

@ -39,7 +39,10 @@ async def integration_status(
# --- Strava ---
@router.get("/strava/auth")
async def strava_auth(user: Annotated[User, Depends(get_current_user)]):
return {"auth_url": get_strava_auth_url(str(user.id))}
from app.utils.auth import create_access_token
from datetime import timedelta
state_token = create_access_token(str(user.id), expires_delta=timedelta(minutes=10))
return {"auth_url": get_strava_auth_url(state_token)}
@router.get("/strava/callback")
@ -48,7 +51,14 @@ async def strava_callback(
state: str = Query(...),
db: AsyncSession = Depends(get_db),
):
user_id = uuid_mod.UUID(state)
from jose import JWTError, jwt as jose_jwt
from app.config import get_settings
_settings = get_settings()
try:
payload = jose_jwt.decode(state, _settings.secret_key, algorithms=[_settings.algorithm])
user_id = uuid_mod.UUID(payload["sub"])
except (JWTError, KeyError, ValueError):
raise HTTPException(status_code=400, detail="Invalid or expired OAuth state")
data = await exchange_strava_code(code)
# Upsert token
@ -91,7 +101,10 @@ async def strava_sync(
# --- Polar ---
@router.get("/polar/auth")
async def polar_auth(user: Annotated[User, Depends(get_current_user)]):
return {"auth_url": get_polar_auth_url(str(user.id))}
from app.utils.auth import create_access_token
from datetime import timedelta
state_token = create_access_token(str(user.id), expires_delta=timedelta(minutes=10))
return {"auth_url": get_polar_auth_url(state_token)}
@router.get("/polar/callback")
@ -100,7 +113,14 @@ async def polar_callback(
state: str = Query(...),
db: AsyncSession = Depends(get_db),
):
user_id = uuid_mod.UUID(state)
from jose import JWTError, jwt as jose_jwt
from app.config import get_settings
_settings = get_settings()
try:
payload = jose_jwt.decode(state, _settings.secret_key, algorithms=[_settings.algorithm])
user_id = uuid_mod.UUID(payload["sub"])
except (JWTError, KeyError, ValueError):
raise HTTPException(status_code=400, detail="Invalid or expired OAuth state")
data = await exchange_polar_code(code)
# Register user with Polar

View file

@ -24,7 +24,6 @@ from app.config import settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/support", tags=["support"])
# Admin check uses is_admin column on User model (first registered user gets admin=True)
MAX_MESSAGES_PER_DAY = 10
@ -279,8 +278,8 @@ async def get_unread_count(
# ── Admin Endpoints ────────────────────────────────────────────────────────
def require_admin(user: User):
"""Check that the user is the admin."""
if user.username != ADMIN_USERNAME:
"""Check that the user has admin privileges."""
if not getattr(user, "is_admin", False):
raise HTTPException(status_code=403, detail="Admin access required")

View file

@ -1,17 +1,17 @@
from __future__ import annotations
from pydantic import BaseModel
from pydantic import BaseModel, Field
class LoginRequest(BaseModel):
username: str
password: str
username: str = Field(min_length=3, max_length=50)
password: str = Field(min_length=8, max_length=128)
class RegisterRequest(BaseModel):
username: str
password: str
display_name: str
username: str = Field(min_length=3, max_length=50, pattern=r'^[a-zA-Z0-9_-]+$')
password: str = Field(min_length=8, max_length=128)
display_name: str = Field(min_length=1, max_length=100)
email: str | None = None

View file

@ -0,0 +1,316 @@
"""Multi-provider AI coaching service.
Two code paths:
- OpenAI-compatible: covers OpenAI, OpenRouter, HuggingFace, Groq, Ollama, Custom
- Anthropic: covers Claude (different message format + auth header)
- Gemini: thin adapter for Google's generateContent API
System prompt is built from AGENT_GUIDE.md + live user context.
Chat history is ephemeral (not persisted).
"""
from __future__ import annotations
import json
import logging
from typing import AsyncGenerator
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.services.crypto import decrypt_value
from app.models.integration_config import IntegrationConfig
logger = logging.getLogger(__name__)
settings = get_settings()
# AI provider presets — base_url and api_format for each named provider
AI_PRESETS = {
"openai": {"base_url": "https://api.openai.com/v1", "format": "openai", "default_model": "gpt-4o-mini"},
"openrouter": {"base_url": "https://openrouter.ai/api/v1", "format": "openai", "default_model": "openai/gpt-4o-mini"},
"huggingface": {"base_url": "https://router.huggingface.co/v1", "format": "openai", "default_model": "meta-llama/Llama-3.3-70B-Instruct"},
"groq": {"base_url": "https://api.groq.com/openai/v1", "format": "openai", "default_model": "llama-3.3-70b-versatile"},
"ollama": {"base_url": "http://host.docker.internal:11434/v1", "format": "openai", "default_model": "llama3.1"},
"claude": {"base_url": "https://api.anthropic.com/v1", "format": "anthropic", "default_model": "claude-sonnet-4-6"},
"gemini": {"base_url": "https://generativelanguage.googleapis.com/v1beta", "format": "gemini", "default_model": "gemini-2.0-flash"},
"custom_ai": {"base_url": "", "format": "openai", "default_model": ""},
}
# System prompt template — {context} is replaced with live user data
SYSTEM_PROMPT_TEMPLATE = """You are a fitness coach integrated with Diligence, a self-hosted fitness rewards platform.
Your role: motivate, guide, and help the user stay consistent with their health goals. Use the context below to personalize your responses.
CURRENT USER CONTEXT:
{context}
RULES:
- Be encouraging but honest. Celebrate progress, address setbacks constructively.
- When the user asks to log a workout or food, confirm what you'll log and do it.
- Reference their program schedule when suggesting workouts.
- Respect their motivation type (from BREQ-2 profiling) to calibrate your tone.
- Keep responses concise this is a mobile chat, not an essay.
- If the user hasn't earned enough points today, gently encourage activity.
- Never fabricate data only reference what's in the context."""
async def get_active_ai_provider(db: AsyncSession) -> dict | None:
"""Find the first configured AI provider."""
result = await db.execute(
select(IntegrationConfig).where(
IntegrationConfig.provider.in_(list(AI_PRESETS.keys()))
)
)
configs = result.scalars().all()
for config in configs:
provider_name = config.provider
try:
creds = json.loads(decrypt_value(config.encrypted_value, settings.secret_key))
except Exception:
continue
preset = AI_PRESETS.get(provider_name, AI_PRESETS["custom_ai"])
api_key = creds.get("api_key", "")
base_url = creds.get("endpoint_url", preset["base_url"]) or preset["base_url"]
model = creds.get("model", preset["default_model"]) or preset["default_model"]
return {
"name": provider_name,
"format": preset["format"],
"base_url": base_url,
"api_key": api_key,
"model": model,
}
return None
async def build_context(db: AsyncSession, user_id) -> str:
"""Build live context string from user's current state."""
from app.models.user import User
from app.models.profile import UserProfile
context_parts = []
# User profile
user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
if user:
context_parts.append(f"Name: {user.display_name}")
context_parts.append(f"Timezone: {user.timezone}")
# Profile / motivation
profile = (await db.execute(select(UserProfile).where(UserProfile.user_id == user_id))).scalar_one_or_none()
if profile:
if profile.ttm_stage:
context_parts.append(f"Stage of change: {profile.ttm_stage}")
if profile.breq_profile:
context_parts.append(f"Motivation type: {profile.breq_profile}")
# Today's points (best effort)
try:
from app.services.points_engine import get_daily_summary
summary = await get_daily_summary(db, user_id)
context_parts.append(f"Today's points: {summary.get('earned', 0)}/{summary.get('target', 100)}")
context_parts.append(f"Daily gate: {'PASSED' if summary.get('gate_passed') else 'NOT YET'}")
except Exception:
pass
return "\n".join(context_parts) if context_parts else "No profile data available yet."
async def chat(
user_id,
message: str,
history: list[dict],
db: AsyncSession,
) -> AsyncGenerator[str, None]:
"""Route chat to the configured AI provider with streaming."""
provider = await get_active_ai_provider(db)
if not provider:
yield "No AI provider configured. Go to Settings → Integrations to connect one (OpenAI, OpenRouter, Ollama, etc.)."
return
context = await build_context(db, user_id)
system_prompt = SYSTEM_PROMPT_TEMPLATE.format(context=context)
try:
if provider["format"] == "openai":
async for chunk in _call_openai_compat(
base_url=provider["base_url"],
api_key=provider["api_key"],
model=provider["model"],
system_prompt=system_prompt,
history=history,
message=message,
):
yield chunk
elif provider["format"] == "anthropic":
async for chunk in _call_anthropic(
api_key=provider["api_key"],
model=provider["model"],
system_prompt=system_prompt,
history=history,
message=message,
):
yield chunk
elif provider["format"] == "gemini":
async for chunk in _call_gemini(
api_key=provider["api_key"],
model=provider["model"],
system_prompt=system_prompt,
history=history,
message=message,
):
yield chunk
except httpx.HTTPStatusError as e:
yield f"AI provider error ({e.response.status_code}). Check your API key in Settings → Integrations."
except httpx.ConnectError:
yield f"Cannot reach {provider['name']}. Check your network or endpoint URL."
except Exception as e:
logger.error(f"AI chat error: {e}")
yield "Something went wrong with the AI provider. Check Settings → Integrations."
async def _call_openai_compat(
base_url: str,
api_key: str,
model: str,
system_prompt: str,
history: list[dict],
message: str,
) -> AsyncGenerator[str, None]:
"""Call any OpenAI-compatible /v1/chat/completions endpoint with streaming.
Covers: OpenAI, OpenRouter, HuggingFace, Groq, Ollama, Custom.
"""
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
payload = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
*history,
{"role": "user", "content": message},
],
"stream": True,
"max_tokens": 1024,
}
async with httpx.AsyncClient(timeout=60.0) as client:
async with client.stream(
"POST",
f"{base_url.rstrip('/')}/chat/completions",
json=payload,
headers=headers,
) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
if line.startswith("data: ") and line.strip() != "data: [DONE]":
try:
chunk = json.loads(line[6:])
delta = chunk.get("choices", [{}])[0].get("delta", {})
if content := delta.get("content"):
yield content
except (json.JSONDecodeError, IndexError, KeyError):
continue
async def _call_anthropic(
api_key: str,
model: str,
system_prompt: str,
history: list[dict],
message: str,
) -> AsyncGenerator[str, None]:
"""Call Anthropic's /v1/messages endpoint with streaming."""
headers = {
"Content-Type": "application/json",
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
}
# Convert OpenAI-style history to Anthropic format
messages = []
for msg in history:
messages.append({"role": msg["role"], "content": msg["content"]})
messages.append({"role": "user", "content": message})
payload = {
"model": model,
"system": system_prompt,
"messages": messages,
"max_tokens": 1024,
"stream": True,
}
async with httpx.AsyncClient(timeout=60.0) as client:
async with client.stream(
"POST",
"https://api.anthropic.com/v1/messages",
json=payload,
headers=headers,
) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
if line.startswith("data: "):
try:
event = json.loads(line[6:])
if event.get("type") == "content_block_delta":
delta = event.get("delta", {})
if text := delta.get("text"):
yield text
except (json.JSONDecodeError, KeyError):
continue
async def _call_gemini(
api_key: str,
model: str,
system_prompt: str,
history: list[dict],
message: str,
) -> AsyncGenerator[str, None]:
"""Call Google Gemini's generateContent endpoint with streaming."""
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent"
# Build Gemini content format
contents = []
# System instruction goes as first user/model exchange
if system_prompt:
contents.append({"role": "user", "parts": [{"text": f"[System instructions]\n{system_prompt}"}]})
contents.append({"role": "model", "parts": [{"text": "Understood. I'll follow these instructions."}]})
for msg in history:
role = "model" if msg["role"] == "assistant" else "user"
contents.append({"role": role, "parts": [{"text": msg["content"]}]})
contents.append({"role": "user", "parts": [{"text": message}]})
payload = {
"contents": contents,
"generationConfig": {"maxOutputTokens": 1024},
}
async with httpx.AsyncClient(timeout=60.0) as client:
async with client.stream(
"POST",
url,
json=payload,
params={"key": api_key, "alt": "sse"},
) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
if line.startswith("data: "):
try:
chunk = json.loads(line[6:])
candidates = chunk.get("candidates", [])
if candidates:
parts = candidates[0].get("content", {}).get("parts", [])
for part in parts:
if text := part.get("text"):
yield text
except (json.JSONDecodeError, KeyError):
continue

View file

@ -0,0 +1,179 @@
"""Base class for device activity sync services.
All device sync implementations (garmin_sync, whoop_sync, oura_sync)
inherit from this class. The pattern mirrors the existing strava_sync
and polar_sync services but adds webhook support and a standard
interface for the generic webhook receiver endpoint.
"""
from __future__ import annotations
import abc
import uuid
import logging
from datetime import datetime, timezone
from typing import Any
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.models.oauth import OAuthToken
from app.models.integration_config import IntegrationConfig
from app.services.crypto import decrypt_value
from app.services.points_engine import log_activity_with_points
logger = logging.getLogger(__name__)
settings = get_settings()
class DeviceSyncBase(abc.ABC):
"""Abstract base for all device sync services.
Subclasses implement the provider-specific OAuth flow, API calls,
and data mapping. The base handles token storage/refresh patterns
and the common activity logging pipeline.
"""
PROVIDER: str # e.g. "garmin", "whoop", "oura"
API_BASE: str # e.g. "https://apis.garmin.com"
# ── OAuth ──
@abc.abstractmethod
async def get_auth_url(self, user_id: uuid.UUID, db: AsyncSession) -> str:
"""Generate the OAuth authorization URL for this provider."""
@abc.abstractmethod
async def handle_callback(
self, code: str, state: str, db: AsyncSession
) -> dict:
"""Exchange authorization code for tokens, store them.
Returns: {"success": True, "provider": "garmin"}
"""
@abc.abstractmethod
async def _refresh_token(self, token: OAuthToken, db: AsyncSession) -> OAuthToken:
"""Provider-specific token refresh logic."""
# ── Sync ──
@abc.abstractmethod
async def sync_activities(
self, user_id: uuid.UUID, db: AsyncSession
) -> list[dict]:
"""Pull new activities from the provider and award points.
Returns list of imported activity dicts with points_earned.
"""
# ── Webhooks ──
@abc.abstractmethod
async def validate_webhook(self, request_body: bytes, headers: dict) -> bool:
"""Validate an incoming webhook signature.
Returns True if the webhook is authentic.
"""
@abc.abstractmethod
async def handle_webhook(
self, payload: dict, db: AsyncSession
) -> dict:
"""Process an incoming webhook notification.
Typically identifies the user and triggers sync_activities().
Returns: {"processed": True, "activities_imported": N}
"""
# ── Helpers (shared) ──
async def get_valid_token(
self, db: AsyncSession, user_id: uuid.UUID
) -> OAuthToken | None:
"""Get a valid OAuth token, refreshing if expired."""
result = await db.execute(
select(OAuthToken).where(
OAuthToken.user_id == user_id,
OAuthToken.provider == self.PROVIDER,
)
)
token = result.scalar_one_or_none()
if not token:
return None
if token.expires_at and token.expires_at < datetime.now(timezone.utc):
try:
token = await self._refresh_token(token, db)
except Exception as e:
logger.error(f"Token refresh failed for {self.PROVIDER}: {e}")
return None
return token
async def get_credentials(self, db: AsyncSession) -> dict | None:
"""Get decrypted OAuth credentials from integration_configs."""
import json
result = await db.execute(
select(IntegrationConfig).where(
IntegrationConfig.provider == self.PROVIDER
)
)
config = result.scalar_one_or_none()
if not config:
return None
try:
return json.loads(decrypt_value(config.encrypted_value, settings.secret_key))
except Exception as e:
logger.error(f"Failed to decrypt {self.PROVIDER} credentials: {e}")
return None
async def import_activity(
self,
db: AsyncSession,
user_id: uuid.UUID,
*,
title: str,
category: str = "workout",
activity_date: Any,
duration_minutes: int | None = None,
external_id: str | None = None,
metadata: dict | None = None,
) -> dict:
"""Import a single activity using the standard points pipeline.
Deduplicates by (user_id, provider, external_id).
"""
from app.models.activity import ActivityLog
if external_id:
existing = await db.execute(
select(ActivityLog).where(
ActivityLog.user_id == user_id,
ActivityLog.source == self.PROVIDER,
ActivityLog.external_id == external_id,
)
)
if existing.scalar_one_or_none():
return {"skipped": True, "external_id": external_id}
entry = await log_activity_with_points(
db=db,
user_id=user_id,
category=category,
activity_date=activity_date,
title=title,
duration_minutes=duration_minutes,
source=self.PROVIDER,
external_id=external_id,
metadata=metadata or {},
)
return {
"id": str(entry.id),
"title": entry.title,
"points_earned": entry.points_earned,
"activity_date": str(activity_date),
}

View file

@ -2,65 +2,213 @@
from __future__ import annotations
PROVIDER_REGISTRY: dict[str, dict] = {
# ═══ Device Integrations ═══
"strava": {
"name": "Strava",
"type": "oauth2",
"category": "device",
"fields": ["client_id", "client_secret"],
"auth_url": "https://www.strava.com/oauth/authorize",
"token_url": "https://www.strava.com/oauth/token",
"scopes": "read,activity:read_all",
"help_url": "https://developers.strava.com/",
"help_text": "Create an app at developers.strava.com. Set the callback URL to {BASE_URL}/api/integrations/strava/callback",
"warning": "Strava ToS prohibits use of their data for AI/ML. Activity sync works but AI coaching should not reference Strava data.",
"has_webhook": False,
"sync_service": "strava_sync",
},
"polar": {
"name": "Polar",
"type": "oauth2",
"category": "device",
"fields": ["client_id", "client_secret"],
"auth_url": "https://flow.polar.com/oauth2/authorization",
"token_url": "https://polarremote.com/v2/oauth2/token",
"help_url": "https://admin.polaraccesslink.com/",
"help_text": "Register at admin.polaraccesslink.com",
"has_webhook": False,
"sync_service": "polar_sync",
},
"garmin": {
"name": "Garmin Connect",
"type": "oauth2",
"category": "device",
"fields": ["client_id", "client_secret"],
"auth_url": "https://connect.garmin.com/oauthConfirm",
"token_url": "https://connectapi.garmin.com/oauth-service/oauth/token",
"scopes": "HEALTH,ACTIVITY",
"help_url": "https://developer.garmin.com/gc-developer-program/",
"help_text": "Apply at developer.garmin.com — approval takes 1-4 weeks",
},
"fitbit": {
"name": "Fitbit",
"type": "oauth2",
"fields": ["client_id", "client_secret"],
"auth_url": "https://www.fitbit.com/oauth2/authorize",
"token_url": "https://api.fitbit.com/oauth2/token",
"help_url": "https://dev.fitbit.com/",
"help_text": "Register at dev.fitbit.com",
},
"withings": {
"name": "Withings",
"type": "oauth2",
"fields": ["client_id", "client_secret"],
"help_url": "https://developer.withings.com/",
"help_text": "Register at developer.withings.com — body metrics, scales, blood pressure",
"help_text": "Apply at developer.garmin.com — approval ~2 business days, requires a business entity.",
"has_webhook": True,
"sync_service": "garmin_sync",
},
"whoop": {
"name": "WHOOP",
"type": "oauth2",
"category": "device",
"fields": ["client_id", "client_secret"],
"auth_url": "https://api.prod.whoop.com/oauth/oauth2/auth",
"token_url": "https://api.prod.whoop.com/oauth/oauth2/token",
"scopes": "read:workout,read:recovery,read:sleep,read:profile,offline",
"help_url": "https://developer.whoop.com/",
"help_text": "Apply at developer.whoop.com — recovery, strain, sleep",
"help_text": "Self-serve at developer.whoop.com. Requires WHOOP device. <10 users without app approval.",
"has_webhook": True,
"sync_service": "whoop_sync",
},
"oura": {
"name": "Oura Ring",
"type": "oauth2",
"category": "device",
"fields": ["client_id", "client_secret"],
"auth_url": "https://cloud.ouraring.com/oauth/authorize",
"token_url": "https://api.ouraring.com/oauth/token",
"scopes": "daily,workout,heartrate,session",
"help_url": "https://cloud.ouraring.com/v2/docs",
"help_text": "Register at cloud.ouraring.com — sleep, readiness, HRV",
"help_text": "Register at cloud.ouraring.com — self-serve, immediate access.",
"has_webhook": True,
"sync_service": "oura_sync",
},
"coros": {
"name": "COROS (via MCP)",
"type": "mcp_bridge",
"category": "device",
"fields": [],
"help_url": "https://support.coros.com/hc/en-us/articles/17085887816340",
"help_text": "COROS has an official MCP server. Add it alongside Diligence in your AI agent config.",
"has_webhook": False,
"sync_service": None,
},
"fitbit": {
"name": "Fitbit",
"type": "oauth2",
"category": "device",
"fields": ["client_id", "client_secret"],
"auth_url": "https://www.fitbit.com/oauth2/authorize",
"token_url": "https://api.fitbit.com/oauth2/token",
"help_url": "https://dev.fitbit.com/",
"help_text": "Register at dev.fitbit.com via Google Cloud Console.",
"has_webhook": True,
"sync_service": None,
},
"withings": {
"name": "Withings",
"type": "oauth2",
"category": "device",
"fields": ["client_id", "client_secret"],
"help_url": "https://developer.withings.com/",
"help_text": "Register at developer.withings.com — body metrics, scales, blood pressure.",
"has_webhook": False,
"sync_service": None,
},
"suunto": {
"name": "Suunto",
"type": "oauth2",
"category": "device",
"fields": ["client_id", "client_secret"],
"help_url": "https://apizone.suunto.com/",
"help_text": "Apply at Suunto developer portal — contract required, ~2 week response.",
"has_webhook": True,
"sync_service": None,
},
# ═══ AI Coaching Providers ═══
"openai": {
"name": "OpenAI",
"type": "api_key",
"category": "ai_provider",
"fields": ["api_key"],
"base_url": "https://api.openai.com/v1",
"api_format": "openai",
"help_url": "https://platform.openai.com/api-keys",
"help_text": "Get API key from platform.openai.com. Models: gpt-4o, gpt-4o-mini.",
"default_model": "gpt-4o-mini",
},
"openrouter": {
"name": "OpenRouter",
"type": "api_key",
"category": "ai_provider",
"fields": ["api_key"],
"base_url": "https://openrouter.ai/api/v1",
"api_format": "openai",
"help_url": "https://openrouter.ai/keys",
"help_text": "One key, 300+ models. 26 free models. Pay-as-you-go, no subscription.",
"default_model": "openai/gpt-4o-mini",
},
"huggingface": {
"name": "Hugging Face",
"type": "api_key",
"category": "ai_provider",
"fields": ["api_key"],
"base_url": "https://router.huggingface.co/v1",
"api_format": "openai",
"help_url": "https://huggingface.co/settings/tokens",
"help_text": "Free HF token. Thousands of open-source models via 20+ inference providers.",
"default_model": "meta-llama/Llama-3.3-70B-Instruct",
},
"groq": {
"name": "Groq",
"type": "api_key",
"category": "ai_provider",
"fields": ["api_key"],
"base_url": "https://api.groq.com/openai/v1",
"api_format": "openai",
"help_url": "https://console.groq.com/keys",
"help_text": "Ultra-fast inference. Free tier available. Also used for program research.",
"default_model": "llama-3.3-70b-versatile",
},
"ollama": {
"name": "Ollama (Local)",
"type": "endpoint",
"category": "ai_provider",
"fields": ["endpoint_url"],
"base_url": "http://host.docker.internal:11434/v1",
"api_format": "openai",
"help_url": "https://ollama.com/",
"help_text": "Run LLMs locally. No API key, no cost. Default: http://host.docker.internal:11434",
"default_model": "llama3.1",
},
"claude": {
"name": "Anthropic Claude",
"type": "api_key",
"category": "ai_provider",
"fields": ["api_key"],
"base_url": "https://api.anthropic.com/v1",
"api_format": "anthropic",
"help_url": "https://console.anthropic.com/",
"help_text": "Get API key from console.anthropic.com. Models: claude-sonnet-4-6, claude-opus-4-8.",
"default_model": "claude-sonnet-4-6",
},
"gemini": {
"name": "Google Gemini",
"type": "api_key",
"category": "ai_provider",
"fields": ["api_key"],
"base_url": "https://generativelanguage.googleapis.com/v1beta",
"api_format": "gemini",
"help_url": "https://aistudio.google.com/apikey",
"help_text": "Get API key from AI Studio. Models: gemini-2.0-flash, gemini-2.5-pro.",
"default_model": "gemini-2.0-flash",
},
"custom_ai": {
"name": "Custom (OpenAI-compatible)",
"type": "endpoint",
"category": "ai_provider",
"fields": ["endpoint_url", "api_key"],
"api_format": "openai",
"help_url": "",
"help_text": "Any server with an OpenAI-compatible /v1/chat/completions endpoint (vLLM, TGI, LiteLLM).",
"default_model": "",
},
# ═══ Nutrition & Data ═══
"usda": {
"name": "USDA FoodData Central",
"type": "api_key",
"category": "nutrition",
"fields": ["api_key"],
"help_url": "https://fdc.nal.usda.gov/api-key-signup",
"help_text": "Free API key, no approval needed. 400K+ foods with research-grade nutrition data.",
@ -68,22 +216,20 @@ PROVIDER_REGISTRY: dict[str, dict] = {
"nutritionix": {
"name": "Nutritionix",
"type": "api_key",
"category": "nutrition",
"fields": ["app_id", "api_key"],
"help_url": "https://developer.nutritionix.com/",
"help_text": "Free tier: 1K calls/month. Natural language food parsing.",
},
"groq": {
"name": "Groq (Program Research)",
"type": "api_key",
"fields": ["api_key"],
"help_url": "https://console.groq.com/keys",
"help_text": "Free API key. Enables AI-powered workout program extraction.",
},
# ═══ Notifications ═══
"telegram": {
"name": "Telegram Notifications",
"type": "api_key",
"category": "notifications",
"fields": ["bot_token", "chat_id"],
"help_url": "https://core.telegram.org/bots#botfather",
"help_text": "Create a bot via @BotFather, then get your chat ID",
"help_text": "Create a bot via @BotFather, then get your chat ID.",
},
}

View file

@ -13,7 +13,8 @@ from app.config import get_settings
from app.database import get_db
settings = get_settings()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
ALGORITHM = "HS256" # Hardcoded — not configurable for security
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
def hash_password(password: str) -> str:
@ -27,11 +28,11 @@ def verify_password(plain: str, hashed: str) -> bool:
def create_access_token(user_id: str, expires_delta: timedelta | None = None) -> str:
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.access_token_expire_minutes))
payload = {"sub": user_id, "exp": expire}
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
async def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)],
token: Annotated[str | None, Depends(oauth2_scheme)],
db: Annotated[AsyncSession, Depends(get_db)],
):
credentials_exception = HTTPException(
@ -39,6 +40,30 @@ async def get_current_user(
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
if not token:
raise credentials_exception
from app.models.user import User
# Check if this is an API token (MCP connector auth)
if settings.api_token and token == settings.api_token:
# Map to the first admin user
result = await db.execute(
select(User).where(User.is_admin == True).order_by(User.created_at.asc()).limit(1)
)
user = result.scalar_one_or_none()
if user is None:
# Fall back to first user if no admin exists yet
result = await db.execute(
select(User).order_by(User.created_at.asc()).limit(1)
)
user = result.scalar_one_or_none()
if user is None:
raise credentials_exception
return user
# Standard JWT validation
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
user_id: str = payload.get("sub")
@ -47,7 +72,6 @@ async def get_current_user(
except JWTError:
raise credentials_exception
from app.models.user import User
result = await db.execute(select(User).where(User.id == uuid.UUID(user_id)))
user = result.scalar_one_or_none()
if user is None:

View file

@ -2,6 +2,8 @@ services:
frontend:
build: ./frontend
restart: unless-stopped
ports:
- "80:80"
depends_on:
backend:
condition: service_healthy
@ -18,6 +20,7 @@ services:
env_file: .env
environment:
- BASE_URL=${BASE_URL:-http://localhost}
- API_TOKEN=${API_TOKEN:-}
depends_on:
fitness-db:
condition: service_healthy
@ -33,6 +36,7 @@ services:
restart: unless-stopped
environment:
- FITNESS_API_URL=http://backend:8000
- API_TOKEN=${API_TOKEN:-}
depends_on:
backend:
condition: service_healthy

View file

@ -1,124 +0,0 @@
import { Routes, Route, Navigate, NavLink, useNavigate, useLocation } from 'react-router-dom'
import { useState, useEffect } from 'react'
import { hasToken, clearToken, api } from './api'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import LogActivity from './pages/LogActivity'
import LogFood from './pages/LogFood'
import Nutrition from './pages/Nutrition'
import Rewards from './pages/Rewards'
import Settings from './pages/Settings'
import Onboarding from './pages/Onboarding'
import WeekView from './pages/WeekView'
import Welcome from './pages/Welcome'
import SettingsIntegrations from './pages/SettingsIntegrations'
import MealPlan from './pages/MealPlan'
import ProgramSearch from './pages/ProgramSearch'
import ProgramDetail from './pages/ProgramDetail'
import CatalogDetail from './pages/CatalogDetail'
import Support from './pages/Support'
import SupportAdmin from './pages/SupportAdmin'
function ProtectedRoute({ children }) {
if (!hasToken()) return <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,6 +139,10 @@ export const api = {
listProviders: () => request('/integrations/providers'),
configureIntegration: (provider, credentials) =>
request('/integrations/configure', { method: 'POST', body: JSON.stringify({ provider, credentials }) }),
// AI Coaching
getAIStatus: () => request('/ai/status'),
// Note: chatWithAI uses fetch directly in ChatCoach.jsx for SSE streaming
// Resources
getResourceRecommendations: () => request('/onboarding/recommendations'),
};

View file

@ -1,51 +1,49 @@
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800;900&family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Instrument+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=IBM+Plex+Mono:wght@400;500;600&display=swap');
:root {
/* === Solar Momentum Palette === */
--orange: #FF5722;
--orange-dark: #E64A19;
--orange-light: #FF8A65;
--orange-ghost: rgba(255, 87, 34, 0.06);
--orange-glow: rgba(255, 87, 34, 0.18);
/* === DW Brand — Light === */
--accent: #2952CC;
--accent-dark: #1e3fa8;
--accent-light: #4A78E0;
--accent-bg: #edf1fc;
--accent-glow: rgba(41, 82, 204, 0.18);
--green: #00C853;
--green-dark: #00A844;
--green-light: #69F0AE;
--green-ghost: rgba(0, 200, 83, 0.06);
--green: #0d7d5a;
--green-dark: #065c40;
--green-light: #30C090;
--green-bg: rgba(13, 125, 90, 0.08);
--blue: #2979FF;
--blue-ghost: rgba(41, 121, 255, 0.06);
--purple: #7C4DFF;
--purple-ghost: rgba(124, 77, 255, 0.06);
--red: #C62828;
--red-bg: rgba(198, 40, 40, 0.06);
--red: #FF1744;
--red-ghost: rgba(255, 23, 68, 0.06);
--amber: #FFB300;
--amber: #b07800;
--amber-bg: rgba(176, 120, 0, 0.08);
--text: #1B1B2F;
--text-2: #4A4A68;
--text-3: #8888A4;
--text: #1a1f2e;
--text-2: #3d4660;
--text-3: #6b7490;
--text-inv: #FFFFFF;
--bg: #F5F3EF;
--bg-warm: linear-gradient(170deg, #FFF3E8 0%, #F5F3EF 50%, #EDF2FF 100%);
--bg: #fafbfe;
--bg-warm: linear-gradient(170deg, #f0f2fa 0%, #fafbfe 50%, #f5f7fd 100%);
--card: #FFFFFF;
--card-border: rgba(0,0,0,0.04);
--divider: rgba(0,0,0,0.06);
--card-border: rgba(216, 220, 232, 0.6);
--divider: rgba(216, 220, 232, 0.8);
--shadow-1: 0 1px 3px rgba(27,27,47,0.05);
--shadow-2: 0 4px 16px rgba(27,27,47,0.08);
--shadow-3: 0 12px 40px rgba(27,27,47,0.12);
--shadow-orange: 0 4px 20px rgba(255,87,34,0.25);
--shadow-green: 0 4px 20px rgba(0,200,83,0.25);
--shadow-1: 0 1px 3px rgba(26, 31, 46, 0.06);
--shadow-2: 0 4px 16px rgba(26, 31, 46, 0.08);
--shadow-3: 0 12px 40px rgba(26, 31, 46, 0.12);
--shadow-accent: 0 4px 20px rgba(41, 82, 204, 0.20);
--shadow-green: 0 4px 20px rgba(13, 125, 90, 0.20);
--r: 14px;
--r-sm: 10px;
--r-lg: 20px;
--r: 8px;
--r-sm: 6px;
--r-lg: 12px;
--r-full: 999px;
--font: 'Plus Jakarta Sans', system-ui, sans-serif;
--font-display: 'Outfit', system-ui, sans-serif;
--font: 'Instrument Sans', -apple-system, sans-serif;
--font-display: 'Instrument Sans', -apple-system, sans-serif;
--font-mono: 'IBM Plex Mono', monospace;
}
/* === Reset === */
@ -64,7 +62,7 @@ body {
/* === Typography === */
h1, h2, h3 { font-family: var(--font-display); letter-spacing: -0.02em; line-height: 1.2; }
a { color: var(--blue); text-decoration: none; font-weight: 600; }
a { color: var(--accent); text-decoration: none; font-weight: 600; }
a:hover { opacity: 0.8; }
/* === Buttons === */
@ -83,11 +81,11 @@ button {
button:active { transform: scale(0.97); }
.btn-primary {
background: var(--orange);
background: var(--accent);
color: var(--text-inv);
box-shadow: var(--shadow-orange);
box-shadow: var(--shadow-accent);
}
.btn-primary:hover { background: var(--orange-dark); }
.btn-primary:hover { background: var(--accent-dark); }
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; transform: none; box-shadow: none; }
.btn-success {
@ -101,14 +99,14 @@ button:active { transform: scale(0.97); }
border: 2px solid var(--divider);
color: var(--text);
}
.btn-outline:hover { border-color: var(--orange); color: var(--orange); }
.btn-outline:hover { border-color: var(--accent); color: var(--accent); }
.btn-ghost {
background: transparent;
color: var(--text-2);
padding: 10px 16px;
}
.btn-ghost:hover { background: var(--orange-ghost); color: var(--orange); }
.btn-ghost:hover { background: var(--accent-bg); color: var(--accent); }
.btn-danger { background: var(--red); color: var(--text-inv); }
.btn-sm { padding: 8px 16px; font-size: 0.82rem; border-radius: var(--r-sm); }
@ -129,8 +127,8 @@ input, select, textarea {
transition: border-color 0.2s;
}
input:focus, select:focus, textarea:focus {
border-color: var(--orange);
box-shadow: 0 0 0 3px var(--orange-glow);
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 400; }
@ -157,7 +155,7 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4
.page-title {
font-family: var(--font-display);
font-size: 1.5rem;
font-weight: 800;
font-weight: 600;
margin-bottom: 18px;
}
@ -181,8 +179,9 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4
/* === Section Title (small label) === */
.section-label {
font-family: var(--font-mono);
font-size: 0.72rem;
font-weight: 800;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-3);
@ -203,6 +202,7 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4
.checklist-check { font-size: 1.15rem; min-width: 26px; }
.checklist-points {
margin-left: auto;
font-family: var(--font-mono);
font-size: 0.78rem;
font-weight: 700;
color: var(--text-3);
@ -239,13 +239,13 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4
border-radius: var(--r-sm);
position: relative;
}
.nav-item.active { color: var(--orange); }
.nav-item.active { color: var(--accent); }
.nav-item.active::before {
content: '';
position: absolute;
top: -4px; left: 50%; transform: translateX(-50%);
width: 20px; height: 3px;
background: var(--orange);
background: var(--accent);
border-radius: 2px;
}
.nav-icon { font-size: 1.2rem; margin-bottom: 1px; }
@ -280,11 +280,11 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4
transition: all 0.2s;
box-shadow: var(--shadow-1);
}
.option-btn:hover { border-color: var(--orange-light); transform: translateY(-2px); box-shadow: var(--shadow-2); }
.option-btn:hover { border-color: var(--accent-light); transform: translateY(-2px); box-shadow: var(--shadow-2); }
.option-btn.selected {
border-color: var(--orange);
background: var(--orange-ghost);
box-shadow: 0 0 0 3px var(--orange-glow);
border-color: var(--accent);
background: var(--accent-bg);
box-shadow: 0 0 0 3px var(--accent-glow);
}
/* === Chips === */
@ -299,10 +299,10 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4
cursor: pointer;
transition: all 0.2s;
}
.chip:hover { border-color: var(--orange-light); }
.chip:hover { border-color: var(--accent-light); }
.chip.selected {
border-color: var(--orange);
background: var(--orange);
border-color: var(--accent);
background: var(--accent);
color: var(--text-inv);
}
@ -330,19 +330,19 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4
background: var(--card);
border: 2px solid var(--divider);
color: var(--text);
font-weight: 800;
font-weight: 600;
font-size: 0.88rem;
display: flex; align-items: center; justify-content: center;
cursor: pointer;
transition: all 0.2s;
box-shadow: var(--shadow-1);
}
.likert-btn:hover { border-color: var(--orange); transform: scale(1.1); }
.likert-btn:hover { border-color: var(--accent); transform: scale(1.1); }
.likert-btn.selected {
border-color: transparent;
background: var(--orange);
background: var(--accent);
color: var(--text-inv);
box-shadow: var(--shadow-orange);
box-shadow: var(--shadow-accent);
transform: scale(1.1);
}
.likert-labels {
@ -372,9 +372,9 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4
border: 2px solid rgba(255,87,34,0.15);
}
.gate-pts {
font-family: var(--font-display);
font-family: var(--font-mono);
font-size: 2.8rem;
font-weight: 900;
font-weight: 700;
letter-spacing: -0.04em;
line-height: 1;
margin: 8px 0;
@ -384,7 +384,7 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4
.meal-section { margin-bottom: 18px; }
.meal-title {
font-size: 0.72rem;
font-weight: 800;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-3);
@ -433,6 +433,106 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4
.gate-banner { animation: popIn 0.35s cubic-bezier(.4,0,.2,1) both; }
.gate-pts { animation: countUp 0.4s ease-out 0.15s both; }
/* === Dark Mode === */
@media (prefers-color-scheme: dark) {
:root {
--accent: #6B9BFF;
--accent-dark: #4A78E0;
--accent-light: #8BB5FF;
--accent-bg: #101630;
--accent-glow: rgba(107, 155, 255, 0.18);
--green: #30C090;
--green-dark: #20A070;
--green-light: #50D8A8;
--green-bg: rgba(48, 192, 144, 0.10);
--red: #EF5350;
--red-bg: rgba(239, 83, 80, 0.10);
--amber: #F0B040;
--amber-bg: rgba(240, 176, 64, 0.10);
--text: #eaecf4;
--text-2: #b0b8d0;
--text-3: #7882a0;
--text-inv: #0a0b12;
--bg: #0a0b12;
--bg-warm: linear-gradient(170deg, #0e1020 0%, #0a0b12 50%, #0c0e1a 100%);
--card: #12131d;
--card-border: rgba(34, 40, 64, 0.8);
--divider: rgba(34, 40, 64, 0.8);
--shadow-1: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-2: 0 4px 16px rgba(0, 0, 0, 0.4);
--shadow-3: 0 12px 40px rgba(0, 0, 0, 0.5);
--shadow-accent: 0 4px 20px rgba(107, 155, 255, 0.15);
--shadow-green: 0 4px 20px rgba(48, 192, 144, 0.15);
}
body { background: var(--bg); color: var(--text); }
.nav-bar {
background: rgba(10, 11, 18, 0.92);
}
input, select, textarea {
background: var(--bg);
color: var(--text);
}
.gate-earned {
background: linear-gradient(135deg, rgba(48,192,144,0.12) 0%, rgba(48,192,144,0.06) 100%);
border-color: rgba(48,192,144,0.25);
}
.gate-locked {
background: linear-gradient(135deg, rgba(107,155,255,0.10) 0%, rgba(107,155,255,0.05) 100%);
border-color: rgba(107,155,255,0.15);
}
.option-btn, .chip {
background: var(--card);
}
.likert-btn {
background: var(--card);
}
}
/* === Manual theme override === */
:root[data-theme="dark"] {
--accent: #6B9BFF;
--accent-dark: #4A78E0;
--accent-light: #8BB5FF;
--accent-bg: #101630;
--accent-glow: rgba(107, 155, 255, 0.18);
--green: #30C090;
--green-dark: #20A070;
--green-light: #50D8A8;
--green-bg: rgba(48, 192, 144, 0.10);
--red: #EF5350;
--red-bg: rgba(239, 83, 80, 0.10);
--amber: #F0B040;
--amber-bg: rgba(240, 176, 64, 0.10);
--text: #eaecf4;
--text-2: #b0b8d0;
--text-3: #7882a0;
--text-inv: #0a0b12;
--bg: #0a0b12;
--bg-warm: linear-gradient(170deg, #0e1020 0%, #0a0b12 50%, #0c0e1a 100%);
--card: #12131d;
--card-border: rgba(34, 40, 64, 0.8);
--divider: rgba(34, 40, 64, 0.8);
--shadow-1: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-2: 0 4px 16px rgba(0, 0, 0, 0.4);
--shadow-3: 0 12px 40px rgba(0, 0, 0, 0.5);
--shadow-accent: 0 4px 20px rgba(107, 155, 255, 0.15);
--shadow-green: 0 4px 20px rgba(48, 192, 144, 0.15);
}
/* === Scrollbar === */
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }

View file

@ -4,8 +4,8 @@ import { api } from '../api'
const DIFFICULTY_COLORS = {
beginner: 'var(--green)',
intermediate: 'var(--blue)',
advanced: 'var(--purple)',
intermediate: 'var(--accent)',
advanced: 'var(--accent-light)',
}
const CATEGORY_ICONS = {
@ -17,8 +17,8 @@ const CATEGORY_ICONS = {
const STATUS_LABELS = {
pending: { label: 'Queued', color: 'var(--amber)' },
crawling: { label: 'Fetching...', color: 'var(--blue)' },
extracting: { label: 'Analyzing...', color: 'var(--purple)' },
crawling: { label: 'Fetching...', color: 'var(--accent)' },
extracting: { label: 'Analyzing...', color: 'var(--accent-light)' },
ready: { label: 'Ready', color: 'var(--green)' },
failed: { label: 'Failed', color: 'var(--red)' },
}
@ -72,7 +72,7 @@ export default function CatalogDetail() {
<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(--blue)', color: '#fff', padding: '10px 20px',
background: 'var(--accent)', 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(--blue)', textDecoration: 'none',
color: 'var(--accent)', 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-ghost)',
background: message.type === 'error' ? 'var(--red-ghost)' : 'var(--green-bg)',
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(--orange)', color: '#fff',
width: '100%', background: 'var(--accent)', color: '#fff',
padding: '14px', fontSize: '0.95rem', fontWeight: 800,
marginBottom: '1.25rem', boxShadow: 'var(--shadow-orange)',
marginBottom: '1.25rem', boxShadow: 'var(--shadow-accent)',
}}
>
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(--blue-ghost)',
color: 'var(--blue)', fontSize: '0.85rem', marginBottom: '1.25rem', textAlign: 'center',
padding: '14px', borderRadius: 'var(--r)', background: 'var(--accent-bg)',
color: 'var(--accent)', 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(--orange)',
fontSize: '0.72rem', fontWeight: 700, color: 'var(--accent)',
textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '8px',
}}>📈 Progression</div>
<p style={{ fontSize: '0.85rem', color: 'var(--text-2)', lineHeight: 1.5 }}>

View file

@ -0,0 +1,194 @@
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: '#FF5722', tip: 'Any exercise session — running, weights, yoga, etc. Logged manually or synced from Strava/Polar.' },
{ key: 'food_log', label: 'Food logged', icon: '🥗', pts: 30, color: '#4CAF50', tip: 'Log what you eat. Barcode scan or manual entry. Building food awareness is a key habit.' },
{ key: 'workout', label: 'Workout', icon: '💪', pts: 50, color: 'var(--accent)', tip: 'Any exercise session — running, weights, yoga, etc. Logged manually or synced from Strava/Polar.' },
{ key: 'food_log', label: 'Food logged', icon: '🥗', pts: 30, color: 'var(--green)', tip: 'Log what you eat. Barcode scan or manual entry. Building food awareness is a key habit.' },
{ key: 'steps_target', label: 'Steps target', icon: '👟', pts: 20, color: '#2979FF', tip: 'Hit your daily step goal. Steps are the foundation of an active lifestyle.' },
{ key: 'screen_free', label: 'Screen-free', icon: '📖', pts: '20/hr', color: '#7C4DFF', tip: 'Time away from screens — reading, walking, hobbies. Points scale with hours logged.' },
{ key: 'daily_checkin', label: 'Check-in', icon: '✅', pts: 10, color: '#00BCD4', tip: 'Just show up and check in. The easiest points — consistency matters more than intensity.' },
@ -90,7 +90,7 @@ export default function Dashboard() {
<div
onClick={() => status.program_id && navigate(`/programs/${status.program_id}`)}
style={{
background: 'var(--blue-ghost)', borderRadius: 'var(--r)', padding: '12px 16px',
background: 'var(--accent-bg)', borderRadius: 'var(--r)', padding: '12px 16px',
marginBottom: '14px', display: 'flex', alignItems: 'center', gap: '12px',
border: '1px solid rgba(41,121,255,0.1)',
cursor: status.program_id ? 'pointer' : 'default',
@ -98,15 +98,15 @@ export default function Dashboard() {
>
<div style={{ fontSize: '1.1rem' }}>📋</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '0.78rem', fontWeight: 700, color: 'var(--blue)' }}>{status.program_name}</div>
<div style={{ fontSize: '0.78rem', fontWeight: 700, color: 'var(--accent)' }}>{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(--blue)',
background: 'var(--accent)',
}} />
</div>
</div>
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 800, fontSize: '0.85rem', color: 'var(--blue)', whiteSpace: 'nowrap' }}>
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 800, fontSize: '0.85rem', color: 'var(--accent)', 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(--orange)' }}>
<span style={{ color: status.gate_passed ? 'var(--green)' : 'var(--accent)' }}>
{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, #FF5722, #FF8A65)`,
: `linear-gradient(90deg, var(--accent), var(--accent-light))`,
}} />
</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(--purple-ghost)' }}>
<div className="card" style={{ background: 'var(--accent-bg)' }}>
<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-ghost)', border: '2px solid rgba(0,200,83,0.15)' }}>
<div className="card" style={{ background: 'var(--green-bg)', 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: '#FF5722' },
{ value: 'workout', label: 'Workout', icon: '💪', desc: 'Any exercise session', color: 'var(--accent)' },
{ value: 'steps_target', label: 'Steps', icon: '👟', desc: 'Hit your daily goal', color: '#2979FF' },
{ value: 'screen_free', label: 'Screen-Free', icon: '📖', desc: 'Reading, outdoors', color: '#7C4DFF' },
{ value: 'daily_checkin', label: 'Check-in', icon: '✅', desc: 'Just show up', color: '#00BCD4' },
@ -100,7 +100,7 @@ export default function LogActivity() {
{success && (
<div style={{
textAlign: 'center', padding: '24px', marginBottom: '14px',
background: 'var(--green-ghost)', borderRadius: 'var(--r-lg)', border: '2px solid rgba(0,200,83,0.15)',
background: 'var(--green-bg)', 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(--blue-ghost)', borderRadius: 'var(--r-sm)', padding: '10px 14px',
background: 'var(--accent-bg)', borderRadius: 'var(--r-sm)', padding: '10px 14px',
marginBottom: '12px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
cursor: 'pointer', border: '1px solid rgba(41,121,255,0.15)',
}}
>
<div>
<div style={{ fontSize: '0.7rem', fontWeight: 700, color: 'var(--blue)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
<div style={{ fontSize: '0.7rem', fontWeight: 700, color: 'var(--accent)', 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(--blue)', fontWeight: 700 }}>
<div style={{ fontSize: '0.78rem', color: 'var(--accent)', 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-ghost)',
padding: '14px', borderRadius: 'var(--r-sm)', background: 'var(--green-bg)',
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(--orange-glow)' : '1px solid var(--divider)',
border: featured ? '2px solid var(--accent-glow)' : '1px solid var(--divider)',
}}>
{featured && (
<div style={{
fontSize: '0.65rem', fontWeight: 800, color: 'var(--orange)',
fontSize: '0.65rem', fontWeight: 800, color: 'var(--accent)',
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-ghost)', borderRadius: 'var(--radius-sm)', color: 'var(--green-dark)', textAlign: 'center', marginBottom: '12px', fontWeight: 600 }}>{success}</div>}
{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>}
{/* 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, #FF8A65 0%, #FF5722 30%, #E64A19 60%, #1B1B2F 100%)',
background: 'linear-gradient(170deg, var(--accent-light) 0%, var(--accent) 30%, var(--accent-dark) 60%, var(--text) 100%)',
}}>
<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(--orange)' : 'transparent',
background: mode === m ? 'var(--accent)' : 'transparent',
color: mode === m ? '#fff' : 'var(--text-2)',
boxShadow: mode === m ? 'var(--shadow-orange)' : 'none',
boxShadow: mode === m ? 'var(--shadow-accent)' : 'none',
}}>
{m === 'login' ? 'Sign In' : 'Sign Up'}
</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 #4CAF50' :
itemStatus === 'skipped' ? '4px solid #F44336' :
itemStatus === 'substituted' ? '4px solid #FF9800' : '4px solid transparent',
borderLeft: itemStatus === 'followed' ? '4px solid var(--green)' :
itemStatus === 'skipped' ? '4px solid var(--red)' :
itemStatus === 'substituted' ? '4px solid var(--amber)' : '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' ? '#4CAF5022' : '#9E9E9E22',
color: p.status === 'active' ? '#4CAF50' : '#9E9E9E',
background: p.status === 'active' ? 'var(--green)22' : 'var(--text-3)22',
color: p.status === 'active' ? 'var(--green)' : 'var(--text-3)',
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(--orange)' }}>
color: c.compliant_day ? 'var(--green)' : 'var(--accent)' }}>
{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="#FF5722" />
<MacroBar label="Protein" value={m.protein_g} target={t.protein_g} unit="g" color="var(--accent)" />
<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(--orange)' }} />
<div className="progress-bar-fill" style={{ width: `${progress}%`, background: 'var(--accent)' }} />
</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(--orange)', fontWeight: 700 }}>{pct}%</span>
<span style={{ color: 'var(--accent)', 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(--orange)',
background: pct >= 100 ? 'var(--green)' : 'var(--accent)',
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-ghost)', borderRadius: 'var(--r)', padding: '16px',
background: 'var(--green-bg)', 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(--orange-glow)',
border: '2px solid var(--accent-glow)',
}}>
<div style={{
fontSize: '0.72rem', fontWeight: 700, color: 'var(--orange)',
fontSize: '0.72rem', fontWeight: 700, color: 'var(--accent)',
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(--orange)', color: '#fff',
fontSize: '0.65rem', background: 'var(--accent)', 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-ghost)', color: 'var(--green-dark)',
background: 'var(--green-bg)', 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(--blue-ghost)', padding: '10px 12px', borderRadius: 'var(--r-sm)',
background: 'var(--accent-bg)', 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(--blue)', marginTop: '2px' }}>
<div style={{ fontSize: '0.75rem', color: 'var(--accent)', marginTop: '2px' }}>
🏋 {exercise.weight_instruction}
</div>
)}

View file

@ -4,8 +4,8 @@ import { api } from '../api'
const DIFFICULTY_COLORS = {
beginner: 'var(--green)',
intermediate: 'var(--blue)',
advanced: 'var(--purple)',
intermediate: 'var(--accent)',
advanced: 'var(--accent-light)',
}
const CATEGORY_ICONS = {
@ -17,8 +17,8 @@ const CATEGORY_ICONS = {
const STATUS_LABELS = {
pending: { label: 'Queued', color: 'var(--amber)' },
crawling: { label: 'Fetching...', color: 'var(--blue)' },
extracting: { label: 'Analyzing...', color: 'var(--purple)' },
crawling: { label: 'Fetching...', color: 'var(--accent)' },
extracting: { label: 'Analyzing...', color: 'var(--accent-light)' },
ready: { label: 'Ready', color: 'var(--green)' },
failed: { label: 'Failed', color: 'var(--red)' },
}
@ -117,7 +117,7 @@ export default function ProgramSearch() {
}}
/>
<button type="submit" style={{
background: 'var(--blue)', color: '#fff', padding: '12px 18px',
background: 'var(--accent)', 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(--orange)',
width: '100%', background: researching ? 'var(--text-3)' : 'var(--accent)',
color: '#fff', padding: '14px', marginBottom: '1rem',
boxShadow: researching ? 'none' : 'var(--shadow-orange)',
boxShadow: researching ? 'none' : 'var(--shadow-accent)',
opacity: !query.trim() ? 0.5 : 1,
}}
>
@ -140,8 +140,8 @@ export default function ProgramSearch() {
<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-ghost)' : 'var(--blue-ghost)',
color: message.type === 'error' ? 'var(--red)' : message.type === 'success' ? 'var(--green-dark)' : 'var(--blue)',
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)',
}}>
{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(--orange-glow)',
border: '2px solid var(--accent-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(--orange)', fontWeight: 700 }}>
<span style={{ fontSize: '0.8rem', color: 'var(--accent)', 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(--orange)',
height: '100%', borderRadius: '3px', background: 'var(--accent)',
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(--blue)', padding: '8px', fontSize: '0.82rem',
color: 'var(--accent)', 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, #FF5722, #FF8A65)',
background: 'linear-gradient(135deg, var(--accent), var(--accent-light))',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontFamily: 'var(--font-display)', fontWeight: 900, fontSize: '1.2rem',
}}>
@ -60,6 +60,75 @@ 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>
@ -119,6 +188,12 @@ 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: '#4CAF50',
configured: '#FF9800',
not_configured: '#9E9E9E',
connected: 'var(--green)',
configured: 'var(--amber)',
not_configured: 'var(--text-3)',
}
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(--orange)' : 'var(--card)',
background: m.sender === 'user' ? 'var(--accent)' : '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-ghost)', borderRadius: 'var(--r-sm)',
padding: '8px', background: 'var(--green-bg)', 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(--orange)',
background: sending ? 'var(--text-3)' : 'var(--accent)',
color: '#fff', borderRadius: 'var(--r-full)',
width: '44px', height: '44px', padding: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '1.1rem', boxShadow: 'var(--shadow-orange)',
fontSize: '1.1rem', boxShadow: 'var(--shadow-accent)',
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(--orange-glow)' : '1px solid var(--card-border)',
border: t.unread_admin > 0 ? '2px solid var(--accent-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(--orange)', color: '#fff', fontSize: '0.65rem', fontWeight: 800,
background: 'var(--accent)', 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-ghost)' : 'var(--red-ghost)',
background: ctx.gate_passed ? 'var(--green-bg)' : '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(--blue)' : 'var(--card)',
background: m.sender === 'admin' ? 'var(--accent)' : '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(--blue)',
background: sending ? 'var(--text-3)' : 'var(--accent)',
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(--purple-ghost)' }}>
<div className="card" style={{ textAlign: 'center', background: 'var(--accent-bg)' }}>
<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(--orange)' : 'var(--text)',
color: isToday ? 'var(--accent)' : 'var(--text)',
}}>
{days[i]}
</div>

View file

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

View file

@ -12,6 +12,7 @@ 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)
@ -21,8 +22,12 @@ _client: httpx.AsyncClient | None = None
async def api(method: str, path: str, **kwargs) -> dict:
global _client
if _client is None:
_client = httpx.AsyncClient(base_url=FITNESS_API_URL, timeout=30)
# TODO: add service account API key auth header
headers = {}
if API_TOKEN:
headers["Authorization"] = f"Bearer {API_TOKEN}"
_client = httpx.AsyncClient(
base_url=FITNESS_API_URL, timeout=30, headers=headers
)
resp = await getattr(_client, method)(f"/api{path}", **kwargs)
resp.raise_for_status()
return resp.json()

29
setup.ps1 Normal file
View file

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